教程:构建最小 MapRuntime 与地图交互
本教程把前两节的 ViewState 和 PlanarTransform 连接成一个可运行的 Three.js 地图内核。完成后你会得到:
- 一个只从 ViewState 派生相机的
MapRuntime; - 合并重复请求的按需 RAF 调度;
viewchange、render、dispose三个生命周期事件;- 左键平移、滚轮锚定缩放、bearing/pitch 拖动与 resize;
- 可重复调用而不会重复释放资源的清理函数。
这一阶段仍不加载 PBF,也不决定视口需要哪些瓦片。
1. 本节文件
text
yinqing/examples/vector-tile-handbook/shared/runtime/
├── view-state.js
├── planar-transform.js
├── map-runtime.js
└── map-interactions.js
打开源码:
shared/runtime/map-runtime.jsshared/runtime/map-interactions.js
2. 创建运行时
在浏览器阶段页中,最小装配方式是:
js
import { MapRuntime } from "../../shared/runtime/map-runtime.js";
import { installMapInteractions } from "../../shared/runtime/map-interactions.js";
const runtime = new MapRuntime({
canvas,
initialViewState: {
center: [121.4737, 31.2304],
zoom: 12,
bearing: 0,
pitch: 45,
viewport: {
width: 1,
height: 1,
devicePixelRatio: 1,
},
},
});
const controls = installMapInteractions(runtime, canvas, {
minZoom: 0,
maxZoom: 15,
maxDevicePixelRatio: 2,
});
初始 viewport 允许先用 1×1 占位。interaction 安装后会立即读取 canvas 的 getBoundingClientRect(),通过一次 ViewState 事务写入真实 CSS 尺寸和 DPR。
3. 构造器只完成一次完整初始化
构造器的顺序应当是:
js
this.viewState = createViewState(initialViewState);
this.transform.update(this.viewState);
this.renderer.setPixelRatio(this.viewState.viewport.devicePixelRatio);
this.renderer.setSize(
this.viewState.viewport.width,
this.viewState.viewport.height,
false,
);
this.requestRender();
setSize(..., false) 的第三个参数很重要:canvas 的 CSS 宽高由布局决定,renderer 只更新 drawing buffer,不把内联 style 写回页面并与 ResizeObserver 互相触发。
如果调用者没有注入 renderer,运行时以给定 canvas 创建 THREE.WebGLRenderer。教学模块同时允许注入已有 renderer、scene、transform 和帧调度函数,方便在别的宿主环境复用;这些对象一旦交给 runtime,就由 runtime 负责 renderer 的最终 dispose。
4. 实现状态事务
核心方法只接受 patch:
js
setViewState(patch) {
const previous = this.viewState;
const next = updateViewState(previous, patch);
if (getViewStateKey(next) === getViewStateKey(previous)) {
return previous;
}
this.viewState = next;
const transformSnapshot = this.transform.update(next);
if (next.viewport !== previous.viewport) {
this.syncRendererViewport();
}
this.emit(Object.freeze({
type: "viewchange",
previousViewState: previous,
viewState: next,
transformSnapshot,
}));
this.requestRender();
return next;
}
注意这里比较的是规范化后的 key,而不是 patch 对象引用。bearing: 360 会被规范化为 0;若旧值已经是 0,就没有语义变化,也不需要更新矩阵和安排新帧。
5. 实现一帧闸门
运行时内部把"没有 RAF"表示为 null,不要使用 0。浏览器通常返回正整数,但注入的调度器并没有这个保证。
js
requestRender() {
if (this.disposed || this.frameHandle !== null) {
return false;
}
this.frameHandle = this.requestFrame((time) => {
this.frameHandle = null;
if (!this.disposed) this.render(time);
});
return true;
}
返回布尔值便于调试:true 表示本次登记了一帧,false 表示请求被合并或 runtime 已销毁。调用方不应根据返回值跳过场景更新;场景要先更新,然后无条件请求渲染。
6. 发布同步事件
这里使用一个很小的 DOM 风格 listener registry,而不是把 MapRuntime 继承为 DOM EventTarget。事件对象是冻结的普通对象,既能在浏览器使用,也不依赖 CustomEvent 的环境差异。
js
runtime.addEventListener("viewchange", ({ viewState, transformSnapshot }) => {
updateScene(viewState, transformSnapshot);
});
runtime.addEventListener("render", ({ time }) => {
if (animationStillActive(time)) runtime.requestRender();
});
事件同步发出。setViewState 返回前,所有 viewchange 监听者都已经完成当前更新;RAF 只负责稍后 draw。
7. 安装 resize 意图
map-interactions.js 建立 ResizeObserver,但 observer 只提交 viewport:
js
runtime.setViewState({
viewport: {
width: Math.max(1, bounds.width),
height: Math.max(1, bounds.height),
devicePixelRatio: Math.min(window.devicePixelRatio || 1, 2),
},
});
不要在 observer 中直接写 camera.aspect 或 renderer.setSize。这些派生写操作集中在 runtime 事务中,才能确保 resize 与其他交互有相同事件顺序。
8. 实现平移
pointerdown 记录 element-local CSS 坐标。pointermove 时分别反投影上次位置和当前位置:
js
const previousHit = runtime.transform.screenPointToGround(previous.x, previous.y);
const currentHit = runtime.transform.screenPointToGround(current.x, current.y);
if (previousHit && currentHit) {
const origin = runtime.transform.snapshot.origin;
const center = unprojectMercator(
origin.x + previousHit.mercator.x - currentHit.mercator.x,
origin.y + previousHit.mercator.y - currentHit.mercator.y,
);
runtime.setViewState({ center: [center.lng, center.lat] });
}
因为两次命中都在同一个 transform 下计算,差值不受当前 origin 抵消方式影响。每次 move 后把 current 保存成下一次 previous,避免从 pointerdown 起累计一个越来越大的增量。
9. 实现 bearing 与 pitch 拖动
本阶段约定:
Shift + 左键拖动:进入 orbit;- 右键拖动:同样进入 orbit;
- 水平位移每 CSS pixel 改变
0.35°bearing; - 垂直位移每 CSS pixel 改变
0.25°pitch。
js
runtime.setViewState({
bearing: runtime.viewState.bearing + dx * 0.35,
pitch: runtime.viewState.pitch + dy * 0.25,
});
不用在交互层 clamp 或 normalize;updateViewState 是唯一规范化入口。这样鼠标、触控板、未来键盘导航和程序动画都遵守同一规则。
右键交互需要阻止 canvas 的 contextmenu 默认行为。pointerdown 后使用 pointer capture,使指针短暂离开 canvas 时仍能收到 move/up;结束或 dispose 时必须释放 capture。
10. 实现光标锚定滚轮缩放
先把 clientX/clientY 减去元素边界,转为 canvas-local CSS pixels,再取得光标命中:
js
const hit = runtime.transform.screenPointToGround(point.x, point.y);
const zoom = clamp(currentZoom - deltaPixels * 0.002, minZoom, maxZoom);
若命中地面,一次提交 center 和 zoom:
js
const nextWorldSize = snapshot.tileSize * 2 ** zoom;
const center = unprojectMercator(
hit.mercator.x - hit.renderLocal.x / nextWorldSize,
hit.mercator.y - hit.renderLocal.z / nextWorldSize,
);
runtime.setViewState({ zoom, center: [center.lng, center.lat] });
浏览器的 wheel deltaMode 可能是 pixels、lines 或 pages。模块先统一为 CSS pixels,再应用 zoom 灵敏度;wheel listener 必须注册为 { passive: false },否则 preventDefault() 无法阻止页面滚动。
11. 精确清理所有权
页面卸载时只调用 runtime:
js
window.addEventListener("pagehide", () => {
runtime.dispose();
}, { once: true });
interaction 模块已监听 runtime 的 dispose,会自动:
- 释放仍被 capture 的 pointer;
disconnect()ResizeObserver;- 删除六类 DOM listener;
- 恢复元素原来的
touch-action; - 删除自己的 runtime listener。
随后 runtime 取消待执行 RAF、dispose renderer 并清空其余 listener。你也可以更早调用 controls.dispose();幂等保护确保 pagehide 不会重复释放。
12. 手工验证
在下一阶段页面接上线框后,按顺序检查:
- 持续左键拖动,地图中心随地面移动,松手后不继续渲染;
- 把光标放在一个格点上滚轮缩放,该格点尽量留在光标下;
Shift + 左键水平拖动,bearing 连续跨过±180°而不跳画面;- 垂直拖到 pitch 上下限,值稳定停在
0°或85°; - 改变窗口大小,中心仍在画布中心,线框比例不因 DPR 改变;
- 离开页面后,不再出现 RAF、ResizeObserver 或 WebGL 资源继续工作的迹象。
13. 完成标准
setViewState一次发布完整 ViewState + TransformSnapshot;- 相同规范化状态不发事件、不请求帧;
- 多个
requestRender只保留一个 RAF; - 平移、orbit、wheel、resize 都只提交 ViewState patch;
- screen point 先转成 element-local CSS pixels;
- dispose 幂等,交互与 renderer 各释放一次;
- 没有从 camera 反写 ViewState 的路径。
下一章将定义瓦片 key,并用 world-space 线框检验相机与交互是否真的共享同一套坐标合同。