Cesium 轨迹回放播放器

**摘要:**本文介绍了一个基于 Cesium 的轨迹回放播放器 CesiumTrackPlayer 的实现与应用。该播放器将时间、经纬度和高度数据转换为 SampledPositionProperty 动态属性,通过 Entity 承载模型、图标或点以及轨迹线,并托管 viewer.clock 的时间区间、倍速、循环和相机跟随。文中详细说明了类的设计要点、核心方法(load、play、pause、stop、seek 等)以及模型朝向、静止保持、pitch 限幅等高级特性,并提供了一个完整的 HTML 应用实例,演示了如何初始化 Viewer、准备轨迹数据并加载播放器实现模型沿轨迹运动。

javascript 复制代码
class CesiumTrackPlayer {
  /**
   * Cesium 轨迹回放播放器
   *
   * 职责:
   *  1. 把 [{time, lon, lat, height}] 数据转成 SampledPositionProperty(时间→位置采样);
   *  2. 用 Entity 承载 模型/图标/点 + path 轨迹线;
   *  3. 托管 viewer.clock 的时间区间、倍速、循环、跟随;
   *  4. 可选:模型朝向(速度方向)、每帧回调、播放控制。
   *
   * 设计要点:
   *  - 位置/朝向都用 Cesium 的 Property(动态属性),由时钟驱动,无需手写 requestAnimationFrame;
   *  - path 的 leadTime/trailTime 控制"历史/未来"轨迹显示,不传则用 Cesium 默认;
   *  - 所有可调参数集中在 options,load() 内部一次性消费,便于外部替换数据重载。
   *
   * @param {Cesium.Viewer} viewer
   * @param {Object} options
   */
  constructor(viewer, options = {}) {
    if (!viewer) throw new Error('viewer is required');
this.viewer = viewer;
this.options = Object.assign({
  // ---- 实体标识 ----
  id: null, // 不传则自动生成 `track-${Date.now()}`,避免多实例 id 冲突

  // ---- 模型(三选一:modelUrl > iconUrl > point)----
  modelUrl: null,               // glTF/glb 地址;有值时用 model,并自动启用速度朝向
  modelScale: 1,
  modelMinimumPixelSize: 64,    // 模型缩到极小时保底像素,防止远处"消失"
  modelMaximumScale: 20000,     // 配合 minimumPixelSize 的上限,避免过近时爆炸放大
  iconUrl: null,                // 无 modelUrl 时用 billboard
  iconScale: 1,
  pointColor: Cesium.Color.YELLOW,
  pointPixelSize: 8,

  // ---- 轨迹线 ----
  showPath: true,
  pathWidth: 3,
  pathMaterial: Cesium.Color.CYAN.withAlpha(0.8), // 传 Color 会自动包成 ColorMaterialProperty
  pathResolution: 1,            // 单位:度。越大顶点越少越"折",越小越贴合曲面但性能下降
  // 不传则使用 Cesium 默认行为;传 0 表示不显示历史/未来轨迹
  // leadTime:从当前位置往前画多久(未来);trailTime:往后画多久(历史)
  pathLeadTime: undefined,
  pathTrailTime: undefined,

  // ---- 时钟/播放 ----
  speed: 1,                     // clock.multiplier,1 表示 1 秒数据=1 秒真实
  loop: true,                   // true=LOOP_STOP 循环;false=CLAMP 停在终点
  shouldAnimate: true,          // load 后是否自动播放
  follow: false,                // true=viewer.trackedEntity 锁定相机;与 flyTo 互斥
  flyTo: true,                  // follow=false 时,load 后飞向实体

  // ---- 插值(决定位置曲线的平滑度,也影响朝向求导)----
  interpolation: 'Lagrange',    // 'Lagrange' | 'Hermite'
  interpolationDegree: 5,       // 阶数越高越平滑,但采样点少时会产生 Runge 过冲(弧度鼓包)

  // ---- 模型朝向(仅 modelUrl 生效)----
  // 模型朝向偏移(度)------修正模型自身 +X 不是前方的问题
  modelHeadingOffset: 0,
  modelPitchOffset: 0,
  modelRollOffset: 0,
  // 静止判定阈值(m/s),速度低于此值保持上一帧朝向(避免重复点导致朝向乱跳)
  orientationStopThreshold: 0.1,
  // pitch 限幅(度),防止高度突变时模型俯冲/翻跟头;null 表示不限
  orientationMaxPitch: null,

  // ---- 时间覆盖(不传则用数据首尾时间)----
  startTime: null,
  stopTime: null,

  // ---- 回调 ----
  onTick: null,   // (julianTime, entity, self) 每帧
  onPlay: null,
  onPause: null,
  onStop: null,
}, options);

// ---- 运行时状态(load 时创建,destroy 时清空)----
this.entity = null;            // 当前轨迹 Entity
this.positionProperty = null;  // SampledPositionProperty,供 path/orientation 复用
this.startTime = null;         // JulianDate,时钟区间起点
this.stopTime = null;          // JulianDate,时钟区间终点
this._tickListener = null;     // onTick 的移除函数(Event.addEventListener 的返回值)
this._isPlaying = false;       // 与 clock.shouldAnimate 同步,供 isPlaying() 查询
}
/**
加载轨迹数据
@param {Array} data
@returns {Cesium.Entity}
*/
load(data) {
// 先销毁旧实体/监听,保证重复 load 不泄漏(同一实例可反复换数据)
this.destroy();
if (!Array.isArray(data) || data.length === 0) {
  throw new Error('轨迹数据不能为空');
}

const opts = this.options;
const viewer = this.viewer;

// 1. 按时间排序
// SampledPositionProperty 要求采样时间单调递增,否则插值/求导会错乱
const sorted = data.slice().sort((a, b) => this._getTimeValue(a) - this._getTimeValue(b));

// 2. 创建位置属性
// 用 SampledPositionProperty 而非 CallbackProperty:Cesium 内部做插值 + 求导,性能好
const positionProperty = new Cesium.SampledPositionProperty();

// 插值算法选择:Lagrange 更平滑但易过冲;Hermite 更稳但需要切线信息(无则退化为近似)
if (opts.interpolation === 'Hermite') {
  positionProperty.setInterpolationOptions({
    interpolationDegree: opts.interpolationDegree,
    interpolationAlgorithm: Cesium.HermitePolynomialApproximation,
  });
} else if (opts.interpolation === 'Lagrange') {
  positionProperty.setInterpolationOptions({
    interpolationDegree: opts.interpolationDegree,
    interpolationAlgorithm: Cesium.LagrangePolynomialApproximation,
  });
}

let firstTime = null;
let lastTime = null;

// 逐点写入采样:time 支持 ISO/Date/13位时间戳/相对秒数(见 _toJulianDate)
sorted.forEach((item, index) => {
  const time = this._toJulianDate(item.time, opts.startTime, index);
  const position = this._toCartesian3(item);
  positionProperty.addSample(time, position);

  if (!firstTime) firstTime = time;
  lastTime = time;
});

// 时钟区间:外部可覆盖(startTime/stopTime),否则用数据首尾
this.startTime = opts.startTime
  ? this._toJulianDate(opts.startTime)
  : firstTime;

this.stopTime = opts.stopTime
  ? this._toJulianDate(opts.stopTime)
  : lastTime;

// 3. 创建 Entity
const entityOptions = {
  id: opts.id || `track-${Date.now()}`,
  // availability 限定实体可见时间窗,窗外不渲染,避免时间轴拖到区间外仍显示
  availability: new Cesium.TimeIntervalCollection([
    new Cesium.TimeInterval({
      start: this.startTime,
      stop: this.stopTime,
    }),
  ]),
  position: positionProperty,
};

// 模型朝向运动方向(带偏移 / 静止保持 / pitch 限幅)
// 仅 modelUrl 时启用:billboard/point 是二维图标,无"朝向"概念
if (opts.modelUrl) {
  entityOptions.orientation = this._createOrientation(positionProperty);
}

// 轨迹线
if (opts.showPath) {
  // path.material 需要 Property,传 Color 时自动包一层
  let material = opts.pathMaterial;
  if (material instanceof Cesium.Color) {
    material = new Cesium.ColorMaterialProperty(material);
  }

  const pathOptions = {
    resolution: opts.pathResolution, // 度;越小越贴合曲面
    material,
    width: opts.pathWidth,
  };

  // 仅在显式传入时才设置 leadTime/trailTime,避免 undefined 覆盖 Cesium 默认
  if (opts.pathLeadTime !== undefined) pathOptions.leadTime = opts.pathLeadTime;
  if (opts.pathTrailTime !== undefined) pathOptions.trailTime = opts.pathTrailTime;

  entityOptions.path = pathOptions;
}

// 模型 / 图标 / 点:优先级 modelUrl > iconUrl > point
if (opts.modelUrl) {
  entityOptions.model = {
    uri: opts.modelUrl,
    scale: opts.modelScale,
    minimumPixelSize: opts.modelMinimumPixelSize, // 远处保底像素
    maximumScale: opts.modelMaximumScale,         // 近处上限
    clampAnimations: true,                        // 动画循环时不插值到首帧之外
  };
} else if (opts.iconUrl) {
  entityOptions.billboard = {
    image: opts.iconUrl,
    scale: opts.iconScale,
    verticalOrigin: Cesium.VerticalOrigin.BOTTOM, // 图标底部对准坐标点
    disableDepthTestDistance: Number.POSITIVE_INFINITY, // 不被地形遮挡
  };
} else {
  entityOptions.point = {
    pixelSize: opts.pointPixelSize,
    color: opts.pointColor,
    outlineColor: Cesium.Color.BLACK,
    outlineWidth: 1,
    disableDepthTestDistance: Number.POSITIVE_INFINITY,
  };
}

this.entity = viewer.entities.add(entityOptions);
this.positionProperty = positionProperty;

// 4. 配置时钟
// 注意:clock 是 viewer 全局的,多实例会互相覆盖;单实例场景足够
const clock = viewer.clock;
clock.startTime = this.startTime.clone();
clock.stopTime = this.stopTime.clone();
clock.currentTime = this.startTime.clone();
// LOOP_STOP:到 stopTime 跳回 startTime;CLAMP:停在 stopTime
clock.clockRange = opts.loop
  ? Cesium.ClockRange.LOOP_STOP
  : Cesium.ClockRange.CLAMP;
// SYSTEM_CLOCK_MULTIPLIER:multiplier 表示"真实 1s = 模拟 multiplier 秒"
clock.clockStep = Cesium.ClockStep.SYSTEM_CLOCK_MULTIPLIER;
clock.multiplier = opts.speed;
clock.shouldAnimate = opts.shouldAnimate;

// 5. 时间轴范围:让 timeline 只显示本轨迹区间
if (viewer.timeline) {
  viewer.timeline.zoomTo(this.startTime, this.stopTime);
}

// 6. 相机:follow 优先级高于 flyTo(trackedEntity 会接管相机,flyTo 会被打断)
if (opts.follow) {
  viewer.trackedEntity = this.entity;
} else if (opts.flyTo) {
  viewer.flyTo(this.entity);
}

// 7. 每帧回调:保存移除函数,destroy 时调用,避免多次 load 叠加监听
if (opts.onTick) {
  this._tickListener = viewer.clock.onTick.addEventListener((clock) => {
    opts.onTick(clock.currentTime, this.entity, this);
  });
}

this._isPlaying = clock.shouldAnimate;
return this.entity;
}
/** 播放:若已到/超过终点,先回到起点再播,避免"卡在末尾" */
play() {
const clock = this.viewer.clock;
if (Cesium.JulianDate.greaterThanOrEquals(clock.currentTime, this.stopTime)) {
clock.currentTime = this.startTime.clone();
}
clock.shouldAnimate = true;
this._isPlaying = true;
if (this.options.onPlay) this.options.onPlay(this);
}
/** 暂停:只停时钟,位置/实体保留 */
pause() {
this.viewer.clock.shouldAnimate = false;
this._isPlaying = false;
if (this.options.onPause) this.options.onPause(this);
}
/** 停止并回到起点:pause + 重置 currentTime */
stop() {
this.pause();
this.viewer.clock.currentTime = this.startTime.clone();
if (this.options.onStop) this.options.onStop(this);
}
/**
跳转到指定时间
复用 _toJulianDate:支持 ISO/Date/13位时间戳/相对秒数(相对 startTime)
@param {String|Date|Number|Cesium.JulianDate} time
*/
seek(time) {
const julian = this._toJulianDate(time, this.startTime);
this.viewer.clock.currentTime = julian;
}
/** 设置播放倍速:同步 clock 与 options,便于后续查询 */
setSpeed(multiplier) {
this.viewer.clock.multiplier = multiplier;
this.options.speed = multiplier;
}
/** 设置是否相机跟随:undefined 解除 trackedEntity */
setFollow(follow) {
this.viewer.trackedEntity = follow ? this.entity : undefined;
}
/** 销毁:清监听 → 解跟随 → 删实体 → 清字段。可重复调用 */
destroy() {
// 1) 先移除 onTick 监听,否则后续 tick 仍会回调已删除的 entity
if (this._tickListener) {
this._tickListener();
this._tickListener = null;
}
// 2) 若相机正跟随本实体,先解除,避免删除后 trackedEntity 指向空
if (this.viewer.trackedEntity === this.entity) {
  this.viewer.trackedEntity = undefined;
}

// 3) 删实体
if (this.entity) {
  this.viewer.entities.remove(this.entity);
  this.entity = null;
}

// 4) 清引用,便于 GC 与状态判断
this.positionProperty = null;
this.startTime = null;
this.stopTime = null;
this._isPlaying = false;
}
/** 是否正在播放:与 clock.shouldAnimate 同步的本地副本 */
isPlaying() {
return this._isPlaying;
}
// ---------------- 私有方法 ----------------
/**
创建模型朝向 Property
有偏移/限幅/静止保持需求时包装 VelocityOrientationProperty
无任何特殊需求时直接返回原生 VelocityOrientationProperty(零开销)
原理:VelocityOrientationProperty 通过对 positionProperty 做中心差分求导
得到速度方向,再构建 ENU 旋转矩阵(模型 +X 对齐速度),转成四元数。
*/
_createOrientation(positionProperty) {
const opts = this.options;
const hasOffset =
opts.modelHeadingOffset || opts.modelPitchOffset || opts.modelRollOffset;
const hasGuard =
opts.orientationStopThreshold > 0 || opts.orientationMaxPitch != null;
// 快速路径:无任何增强需求时,直接用原生 Property,避免 CallbackProperty 每帧开销
if (!hasOffset && !hasGuard) {
  return new Cesium.VelocityOrientationProperty(positionProperty);
}

// 基础朝向:速度方向
const base = new Cesium.VelocityOrientationProperty(positionProperty);

// 模型自身朝向偏移四元数
// 例:模型 +Z 朝前 → modelHeadingOffset = -90 修正
let offsetQuat = null;
if (hasOffset) {
  offsetQuat = Cesium.Quaternion.fromHeadingPitchRoll(
    new Cesium.HeadingPitchRoll(
      Cesium.Math.toRadians(opts.modelHeadingOffset || 0),
      Cesium.Math.toRadians(opts.modelPitchOffset || 0),
      Cesium.Math.toRadians(opts.modelRollOffset || 0)
    )
  );
}

const stopThreshold = opts.orientationStopThreshold;
const maxPitch = opts.orientationMaxPitch != null
  ? Cesium.Math.toRadians(opts.orientationMaxPitch)
  : null;

// 闭包缓存上一帧朝向:静止/速度为零时 VelocityOrientationProperty 会返回 undefined
// 或跳到默认朝向(东),此处用缓存保持,避免模型"抽搐"
let lastQuat = null;

return new Cesium.CallbackProperty((time) => {
  // 1) 取基础朝向
  let q = base.getValue(time);
  if (!q) return lastQuat || undefined;

  // 2) 静止检测:用位置前后差分估速度模长
  //    注意 dt 取 0.5s 而非更小,是为了让差分跨越插值噪声区;
  //    若数据点稀疏,0.5s 可能跨不过一个采样区间,需按实际采样间隔调整
  if (stopThreshold > 0) {
    const dt = 0.5;
    const t1 = Cesium.JulianDate.addSeconds(time, -dt, new Cesium.JulianDate());
    const t2 = Cesium.JulianDate.addSeconds(time, dt, new Cesium.JulianDate());
    const p1 = positionProperty.getValue(t1);
    const p2 = positionProperty.getValue(t2);
    if (p1 && p2) {
      const speed = Cesium.Cartesian3.distance(p1, p2) / (2 * dt);
      if (speed < stopThreshold && lastQuat) {
        return lastQuat; // 保持上一帧朝向
      }
    }
  }

  // 3) pitch 限幅
  // ⚠️ 注意:_clampPitch 内部用 Cartesian3.ZERO 作参考位置,结果不可靠;
  //    如需启用 orientationMaxPitch,请先把当前位置传入 _clampPitch。
  if (maxPitch != null) {
    q = this._clampPitch(q, maxPitch);
  }

  // 4) 叠加模型自身朝向偏移
  //    顺序很重要:先速度朝向,再叠加模型偏移(右乘),得到"模型前方对准速度"的正确结果
  if (offsetQuat) {
    q = Cesium.Quaternion.multiply(q, offsetQuat, new Cesium.Quaternion());
  }

  lastQuat = q;
  return q;
}, false); // false = 非恒定,Cesium 每帧求值
}
/**
对四元数施加 pitch 限幅(在局部 ENU 下限制俯仰角)
⚠️ 已知问题:Transforms.headingPitchRollQuaternion 需要"参考位置"来定义 ENU,
这里传 Cartesian3.ZERO 会把 HPR 解释为地心坐标系下的朝向,结果与期望不符。
正确做法是由调用方传入当前位置(positionProperty.getValue(time))。
由于当前调用处未传 position,此方法实际不可靠,建议暂不启用 orientationMaxPitch。
*/
_clampPitch(quat, maxPitch) {
// 转成 heading/pitch/roll
const hpr = Cesium.HeadingPitchRoll.fromQuaternion(quat);
if (Math.abs(hpr.pitch) <= maxPitch) return quat;
const clamped = new Cesium.HeadingPitchRoll(
hpr.heading,
Math.sign(hpr.pitch) * maxPitch,
hpr.roll
);
return Cesium.Transforms.headingPitchRollQuaternion(
Cesium.Cartesian3.ZERO, // FIXME: 应传当前位置
clamped
);
}
/**
取数据项的时间数值(毫秒),仅用于排序
支持:JulianDate / Date / 13位时间戳 / ISO 字符串 / 相对秒数
无法解析时返回 0(排最前),避免排序抛错
*/
_getTimeValue(item) {
const t = item.time ?? item.timestamp ?? item.date;
if (t instanceof Cesium.JulianDate) return Cesium.JulianDate.toDate(t).getTime();
if (t instanceof Date) return t.getTime();
if (typeof t === 'number') return t;
if (typeof t === 'string') {
try {
return Cesium.JulianDate.toDate(Cesium.JulianDate.fromIso8601(t)).getTime();
} catch (e) {
// 兼容 'YYYY-MM-DD HH:mm:ss' 这类非 ISO 格式
return new Date(t.replace(/-/g, '/')).getTime();
}
}
return 0;
}
/**
把各种时间输入转成 JulianDate
优先级/规则:
JulianDate / Date:直接转换
number > 1e12:视为 13 位毫秒时间戳
number <= 1e12:视为"相对秒数",基准为 startTime,缺省为 JulianDate.now()
string:先 ISO8601,失败再 new Date(兼容 'YYYY/MM/DD')
其他:fallback 用索引 index 作为相对秒数
*/
_toJulianDate(time, startTime, index = 0) {
if (time instanceof Cesium.JulianDate) return time.clone();
if (time instanceof Date) return Cesium.JulianDate.fromDate(time);
if (typeof time === 'number') {
  // 13 位时间戳
  if (time &gt; 1e12) {
    return Cesium.JulianDate.fromDate(new Date(time));
  }
  // 相对秒数
  const base = startTime
    ? this._toJulianDate(startTime)
    : Cesium.JulianDate.now();
  return Cesium.JulianDate.addSeconds(base, time, new Cesium.JulianDate());
}

if (typeof time === 'string') {
  try {
    return Cesium.JulianDate.fromIso8601(time);
  } catch (e) {
    // 非 ISO 格式(如 '2024-01-01 00:00:00')走 Date 解析
    const date = new Date(time.replace(/-/g, '/'));
    if (!isNaN(date.getTime())) {
      return Cesium.JulianDate.fromDate(date);
    }
  }
}

// fallback:按索引秒数(数据缺 time 字段时,按数组顺序每隔 1 秒一个点)
const base = startTime
  ? this._toJulianDate(startTime)
  : Cesium.JulianDate.now();
return Cesium.JulianDate.addSeconds(base, index, new Cesium.JulianDate());
}
/**
把各种位置输入转成 Cartesian3
支持形式(按优先级):
Cartesian3 实例
item.position:Cartesian3 / [lon,lat,h] / {lon,lat,height}
顶层字段:lon/lng/longitude/x + lat/latitude/y + height/alt/altitude/z
经纬度单位:度(fromDegrees);高度缺省 0
*/
_toCartesian3(item) {
if (item instanceof Cesium.Cartesian3) return item.clone();
if (item.position) {
  if (item.position instanceof Cesium.Cartesian3) return item.position.clone();

  if (Array.isArray(item.position)) {
    return Cesium.Cartesian3.fromDegrees(
      item.position[0],
      item.position[1],
      item.position[2] || 0
    );
  }

  if (typeof item.position === 'object') {
    const lon = item.position.lon ?? item.position.lng ?? item.position.longitude;
    const lat = item.position.lat ?? item.position.latitude;
    const height = item.position.height ?? item.position.alt ?? item.position.altitude ?? 0;
    if (typeof lon === 'number' &amp;&amp; typeof lat === 'number') {
      return Cesium.Cartesian3.fromDegrees(lon, lat, height);
    }
  }
}

const lon = item.lon ?? item.lng ?? item.longitude ?? item.x;
const lat = item.lat ?? item.latitude ?? item.y;
const height = item.height ?? item.alt ?? item.altitude ?? item.z ?? 0;

if (typeof lon === 'number' &amp;&amp; typeof lat === 'number') {
  return Cesium.Cartesian3.fromDegrees(lon, lat, height);
}

// 解析失败直接抛错,带上原始数据便于定位
throw new Error('无法解析位置数据: ' + JSON.stringify(item));
}
}

应用实例:

下面是一个完整的 HTML 页面示例,演示如何将上述 CesiumTrackPlayer 类应用到实际项目中。该示例初始化了一个 Cesium Viewer,准备了一段包含时间、经纬度和高度的轨迹数据,然后创建轨迹回放实例并加载数据,实现模型沿轨迹运动、相机跟随、轨迹线绘制等功能。你可以直接复制这段代码,替换为自己的 Cesium Ion Token 和模型地址即可运行。

html 复制代码
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Cesium 原生建筑层轮廓</title>
        <link rel="stylesheet" href="https://unpkg.com/cesium@1.120.0/Build/Cesium/Widgets/widgets.css">
        <script src="https://unpkg.com/cesium@1.120.0/Build/Cesium/Cesium.js"></script>
        <style type="text/css">
            html, body, #containerId { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; }
            #btns { position: fixed; top: 10px; left: 10px; z-index: 999; }
            #btns button { min-width: 150px; font-size: 12px; padding: 5px 10px; margin-bottom: 10px; display: block; }
        </style>
    </head>
    <body>
        <div id="containerId"></div>
		<script src="common/CesiumTrackPlayer.js"></script>
        <script type="text/javascript">
        // 设置你自己的 Cesium ion Access Token
        Cesium.Ion.defaultAccessToken = '你自己的Token';
	// 1. 初始化 Viewer
	const viewer = new Cesium.Viewer('containerId', {
	  animation: true,
	  timeline: true,
	  shouldAnimate: true,
	  baseLayerPicker: false,
	});
	
	// 2. 准备轨迹数据
	const trackData = [
		// 116.40494839195037 39.91403246373624
	  { time: '2024-01-01T00:00:00Z', lon: 116.39701454940013, lat: 39.906934869462084, height: 0 },
	  { time: '2024-01-01T00:00:05Z', lon: 116.39751454940013, lat: 39.906934869462084, height: 0 },
	  { time: '2024-01-01T00:00:10Z', lon: 116.39867173491211, lat: 39.906934869462084, height: 0 },
	  { time: '2024-01-01T00:00:20Z', lon: 116.40052618305599, lat: 39.906934869462084, height: 0 },
	  { time: '2024-01-01T00:00:30Z', lon: 116.40052618305599, lat: 39.908444517196365, height: 0 },
	  { time: '2024-01-01T00:00:40Z', lon: 116.40052618305599, lat: 39.91070573183383, height: 0 },
	  { time: '2024-01-01T00:00:50Z', lon: 116.40063626733688, lat: 39.914128412024446, height: 0 },
	  { time: '2024-01-01T00:00:60Z', lon: 116.40146374906904, lat: 39.914128412024446, height: 0 },
	  { time: '2024-01-01T00:01:00Z', lon: 116.40360396559713, lat: 39.914128412024446, height: 0 },
	  { time: '2024-01-01T00:01:10Z', lon: 116.40494839195037, lat: 39.914128412024446, height: 0 },
	  { time: '2024-01-01T00:01:20Z', lon: 116.40494839195037, lat: 39.9117550671162, height: 0 },
	  // 
	];
	
	// 3. 创建轨迹回放实例
	const player = new CesiumTrackPlayer(viewer, {
	  modelUrl: 'assets/glb/dog.glb', // dog模型地址
		// 模型 +Z 朝前 → 需要 -90° heading 修正
		modelHeadingOffset: 40,
		// 静止时保持朝向
		orientationStopThreshold: 0.1,
		// 限制俯仰 ±30°,防止降落时翻跟头
		// orientationMaxPitch: 30,
	  modelScale: 1,
	  showPath: true,
	  pathWidth: 5,
	  pathMaterial: Cesium.Color.YELLOW,
	  speed: 5,
	  loop: true,
	  follow: true,       // 相机跟随
	  flyTo: true,        // 加载后飞向轨迹
	  onTick: (time, entity, self) =&amp;gt; {
	    // 每帧回调,可更新 UI 显示当前时间
	    // console.log('当前时间:', Cesium.JulianDate.toDate(time));
	  },
	  onPlay: (self) =&amp;gt; console.log('开始播放'),
	  onPause: (self) =&amp;gt; console.log('暂停'),
	  onStop: (self) =&amp;gt; console.log('停止'),
	});
	
	// 4. 加载并播放
	player.load(trackData);
	// player.play();   // 默认 shouldAnimate: true,会自动播放
	// player.pause();
	// player.stop();
	// player.seek('2024-01-01T00:00:15Z');
	// player.setSpeed(20);
	// player.setFollow(false);
	// player.destroy();

    // 视角定位到所有建筑
    if (viewer.entities.values.length &amp;gt; 0) {
        viewer.zoomTo(viewer.entities);
    }
	
&amp;lt;/script&amp;gt;
&lt;/body&gt;
</html>

效果图:

相关推荐
HolySaga1 小时前
我用 UTS 写了一个三端同构的蓝牙插件(Android / iOS / 鸿蒙 NEXT),把踩过的坑都讲给你听
前端
程序员老赵2 小时前
Docker 部署 XWiki:轻松搭建企业知识库与协作 Wiki
前端·docker·开源
console.log('npc')2 小时前
第 10 章 更多业务模块扩展
前端·人工智能·大模型
行者全栈架构师2 小时前
我用 TRAE Work 把桌游之夜搬进了浏览器:一个计分器的从创意到上线
前端·算法·架构
山雀~2 小时前
线上CI流水打包失败排查思路案例
前端·ci/cd
时间的情敌2 小时前
《回顾 2019 前端:React Hooks 问世,前端工程化爆发的一年》
前端·react.js·前端框架
燕落南枝2 小时前
CSS 类选择器详解:从入门到实战
前端·css
tryCbest3 小时前
Vue3——第 7 篇:组件基础——从注册到使用的完整流程
前端·javascript·vue.js
ai工具集合3 小时前
图标集用法,前端项目 SVG 图标 icon 完整接入实操
前端