第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 });
三个限制要记住:
- 不是所有属性都可过渡。 布局属性(
visibility、text-field等,图标特别标注的除外)不支持平滑过渡,切换是瞬时的。 - 数据驱动属性(
*-color用get)在过渡期间仍可能触发重算。 大量要素时把批量变化合并到一次调用,而不是逐个 set。 - 用
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 已足够 |