第16章 - 动态数据动画与时间维度

16.1 动态数据的四种驱动方式

地图上”会动”的东西分四类,API 完全不同,混用会写出卡顿的代码:

驱动方式 API 刷新粒度 典型场景
相机动画 jumpTo / easeTo / flyTo 由 MapLibre 内部逐帧插值 视角跳转、巡航
样式过渡 setPaintProperty + transition 属性级、300ms 默认 颜色渐变、透明度切换
数据驱动 source.setData / updateImage / setFeatureState 每次调用 实时点位、热力刷新
逐帧绘制 requestAnimationFrame + triggerRepaint 你自己的帧率 自定义图层动画

判断标准:变化的是相机就去调相机 API,变化的是数据就去改数据,变化的是像素就去写自定义图层。最常见的错误是用 setData 去实现”点慢慢移动”,每次都重建瓦片索引,10 万点时会直接卡死。

16.2 相机动画

三个方法的区别在插值方式:

方法 时长 特点
jumpTo({...}) 0 瞬间跳转,不触发动画事件流
easeTo({...}) 默认 500 ms 线性/自定义缓动,center 与 zoom 同时插值
flyTo({...}) 由 speed 与距离推导 沿缩放弧线飞行,跨越远距离时才”飞起来”

flyTo 的默认参数(src/ui/camera.ts):

options = extend({
    offset: [0, 0],
    speed: 1.2,     // 每秒飞过 1.2 个屏幕跨度
    curve: 1.42,    // 飞行弧线的"隆起"程度
    easing: defaultEasing
}, options);

duration 是推导量而非输入量:给了 speed 就按距离算时长,给了 maxDuration 且算出来的时长超限,则 duration 被设为 0——长距离飞行会变成瞬移。做”跨越半个中国的巡航动画”时把 speed 调小(0.6–0.8)或显式给 duration,否则会意外跳变。

easeTo 的默认时长是 500ms,bearing/pitch 复位类方法的默认值是 1000ms。环境开启”减少动态效果”时,flyTo 会自动降级为 easeTo(源码里判 browser.prefersReducedMotion,除非传 essential: true)。

// 平滑飞过去
map.flyTo({ center: [121.47, 31.23], zoom: 12, pitch: 45, bearing: 30, speed: 0.8 });

// 固定时长、自定义曲线
map.easeTo({
  center: [121.47, 31.23],
  zoom: 12,
  duration: 1200,
  easing: (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2  // easeInOutQuad
});

// 打断:任何新的相机调用都会中断正在进行的动画
map.stop();

map.stop() 只停相机动画,不会停自定义图层的 requestAnimationFrame,两者要分别清理。

16.3 样式过渡:不写 rAF 的插值

setPaintProperty 的第三个参数控制过渡:

map.setPaintProperty('area-fill', 'fill-color', '#e63946', { duration: 600, delay: 0 });

style spec 里 transition 的默认值是 duration: 300、delay: 0。不传第三个参数就用图层自身或样式根级的 transition 设置。

颜色、透明度、数值型属性都支持过渡,表达式也可以,只要首尾都是合法表达式:

// 按数据值渐变的颜色切换,同样有过渡
map.setPaintProperty('buildings', 'fill-extrusion-height',
  ['interpolate', ['linear'], ['get', 'floors'], 1, 3, 30, 90],
  { duration: 800 });

三个限制要记住:

  1. 不是所有属性都可过渡。 布局属性(visibility、text-field 等,图标特别标注的除外)不支持平滑过渡,切换是瞬时的。
  2. 数据驱动属性(*-color 用 get)在过渡期间仍可能触发重算。 大量要素时把批量变化合并到一次调用,而不是逐个 set。
  3. 用 setStyle 换整份样式会重建一切,过渡失效。 需要整样式切换时优先改根级配置或逐属性设置。

16.4 用 requestAnimationFrame 做时间维度

需要”按真实时间推进”的效果(流动的线、脉冲的点、随秒数变化的透明度),唯一稳妥的做法是自己算时间、逐帧改属性,并在每帧后触发重绘。

关键点:MapLibre 是按需渲染。没有相机变化、没有数据变化时它不会重绘,所以你必须调用 map.triggerRepaint();但也不要在静止时无条件每帧调用,那等于让地图满帧运行。

let rafId = null;
let start = null;

function pulse(now) {
  if (start === null) start = now;
  const t = (now - start) / 1000;             // 秒
  const phase = (Math.sin(t * Math.PI * 2) + 1) / 2;   // 0..1,1 秒一个周期

  map.setPaintProperty('pulse-dot', 'circle-radius', 6 + phase * 10);
  map.setPaintProperty('pulse-dot', 'circle-opacity', 0.9 - phase * 0.6);

  rafId = requestAnimationFrame(pulse);
}

function startPulse() {
  if (rafId === null) rafId = requestAnimationFrame(pulse);
}
function stopPulse() {
  if (rafId !== null) cancelAnimationFrame(rafId);
  rafId = null;
  start = null;
}

每帧两次 setPaintProperty 会重建对应图层的绘制状态,要素不多时没问题;要素上万时改成”只改一个 uniform 类属性”,或者干脆移到自定义图层里用 uniform 传时间——这是第 12 章 CustomLayer 最实用的场景之一。

16.4.1 setFeatureState:只改少数要素

需要高亮/变色的要素数量少(几百个以内)时,setFeatureState 比 setData 便宜得多,因为它只更新样式状态,不重建瓦片:

// 前提:源上的要素有 id(数据里带 id,或源上配 promoteId)
map.setFeatureState({ source: 'sites', id: 42 }, { hover: true });

// 图层里用 feature-state 读
map.addLayer({
  id: 'sites-hl',
  type: 'circle',
  source: 'sites',
  paint: {
    'circle-radius': ['case', ['boolean', ['feature-state', 'hover'], false], 12, 5],
    'circle-color': ['case', ['boolean', ['feature-state', 'hover'], false], '#e63946', '#1d3557']
  }
});

两个前提缺一不可:要素必须有 id(否则 setFeatureState 无效且不报错),feature-state 只能用于支持它的属性(circle-radius、fill-color 等,spec 中标注 “Supports feature-state”)。动画时同样要逐帧调用并 triggerRepaint,但它更新的是要素状态,不重建几何。

16.5 时间轴回放:把”时间”当成筛选条件

业务里的”时间维度”多数不是动画,而是按时间筛选。三种实现从轻到重:

// 方案 A:数据里带时间字段,用 filter 切换(最轻,不重建源)
let currentHour = 8;
function showHour(h) {
  currentHour = h;
  map.setFilter('sensor-points', ['==', ['get', 'hour'], h]);
}

// 方案 B:GeoJSON 源换数据(会重建瓦片索引,几十万点慎用)
async function loadHour(h) {
  const data = await fetch('/api/sensors?hour=' + h).then((r) => r.json());
  map.getSource('sensors').setData(data);
}

// 方案 C:动画播放,按时间片轮播
const hours = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
let i = 0;
const timer = setInterval(() => {
  showHour(hours[i % hours.length]);
  i += 1;
}, 800);

setFilter 只影响渲染,不重建几何,切小时是毫秒级;setData 要重新跑 geojson-vt 与 supercluster,数据量大时每次都是一次成本。数据总量可控时优先 A,数据本身随参数变化的(服务端按需返回)才用 B。

播放控制需要”暂停/继续/跳转”,本质是管理一个定时器加当前索引;跳转时记得先 clearInterval 再重建,否则会出现两个定时器同时跑、帧率翻倍的经典 bug。

16.6 实时数据流的节流

GPS 轨迹、车辆位置这类高频推送(10–20 Hz),直接 setData 会来不及渲染。三层节流:

// 1) 入站合并:不管推多快,最多每 200ms 合成一次
const pending = new Map();
let flushTimer = null;

function onPosition(msg) {
  pending.set(msg.id, msg);
  if (flushTimer === null) {
    flushTimer = setTimeout(flush, 200);
  }
}

function flush() {
  flushTimer = null;
  const batch = Array.from(pending.values());
  pending.clear();

  // 2) 只更新变化的部分:setFeatureState 比 setData 便宜
  for (const p of batch) {
    map.setFeatureState({ source: 'vehicles', id: p.id }, { lon: p.lon, lat: p.lat });
  }
  // 3) 位置类移动仍需改几何,用 source.updateData diff 或按帧降频 setData
  map.triggerRepaint();
}

如果位置变化必须改几何(车辆真的在地图上移动),用 GeoJSONSource 的 updateData 增量 diff 比整份 setData 快;退一步,把移动要素压到 2–4 Hz 也远比 20 Hz 流畅——人眼对 200ms 一次的位置更新已经足够。

16.6.1 过渡与动画的选择

同样是”颜色从 A 变到 B”,两条路差别明显:

// 路线 1:样式过渡,交给 MapLibre 插值,一次调用即可
map.setPaintProperty('area-fill', 'fill-color', '#e63946', { duration: 600 });

// 路线 2:自己逐帧算,适合需要非线性节奏、需要与其他动画同步的场景
const from = [0, 0, 0], to = [230, 57, 70];
let t0 = performance.now();
function step(now) {
  const p = Math.min((now - t0) / 600, 1);
  const c = 'rgb(' + from.map((v, i) => Math.round(v + (to[i] - v) * p)).join(',') + ')';
  map.setPaintProperty('area-fill', 'fill-color', c);
  if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);

路线 1 在一帧内只做一次属性变更,MapLibre 把过渡编译进 uniform;路线 2 每次 setPaintProperty 都会让图层状态失效并重新求值,图层要素多时成本高一个量级。除非需要”颜色随数据同步脉动”这类联动,否则一律用路线 1。

16.7 排错

现象 原因 修法
动画完全不生效 赋值后没有触发重绘 相机 API 自带重绘;setPaintProperty 会触发;自定义绘制要 triggerRepaint()
setFeatureState 无效果 要素没有 id 数据带 id,或源上配 promoteId
flyTo 变成瞬移 duration 超过 maxDuration 被重置为 0 调小 speed 或去掉 maxDuration
过渡不生效 改的是不支持过渡的布局属性,或该属性被表达式锁定 换用可过渡属性;确认 spec 支持 transition
页面切到后台后动画跳帧 rAF 在后台被浏览器降频甚至暂停 监听 visibilitychange,隐藏时停止动画
内存持续增长 setData 频繁 + 未取消的定时器/RAF 统一在销毁时 clearInterval/cancelAnimationFrame

最后一条在单页应用里最普遍:组件卸载时忘了停 RAF,地图销毁后回调仍在跑,报 Cannot read properties of undefined。

16.8 生产建议

动画时长 200–800ms 是舒适区。 低于 200ms 感觉不到过渡,高于 1s 用户会以为卡了。跨城市的 flyTo 例外,可以用 1.5–3s,但仍要做可跳过处理。

尊重 prefers-reduced-motion。 MapLibre 的 flyTo 已经做了降级;自己写的 RAF 动画也应判断 window.matchMedia('(prefers-reduced-motion: reduce)').matches,命中时直接跳到终态。

批量变更合并到一帧。 一帧内多次 setPaintProperty 会触发多次失效计算,用 requestAnimationFrame 包一层把同一帧的修改合并。

数据刷新频率与渲染频率解耦。 数据 20 Hz 进来、渲染 5 Hz 出图是常见组合;用”入站队列 + 定时 flush”的结构,而不是让每个消息都直接触发渲染。

销毁路径要显式清理。 相机动画用 map.stop(),RAF 用 cancelAnimationFrame,定时器 clearInterval,事件 off。四件事缺一件都可能留下后台运行的循环。

16.9 本章小结

要点 事实
相机动画默认 easeTo 500ms;flyTo speed: 1.2、curve: 1.42;复位类 1000ms
flyTo 变瞬移 duration 超过 maxDuration 时被设为 0
样式过渡默认 transition.duration: 300、delay: 0
按需渲染 无变化不重绘,自定义动画要 triggerRepaint()
局部更新 setFeatureState 需要素有 id,比 setData 轻
时间筛选 优先 setFilter,源数据按需加载时才 setData
节流 入站合并 + 渲染降频,位置更新 2–4 Hz 已足够