three.js最小地图运行时(七): MapRuntime 与地图交互

教程:构建最小 MapRuntime 与地图交互

本教程把前两节的 ViewStatePlanarTransform 连接成一个可运行的 Three.js 地图内核。完成后你会得到:

  • 一个只从 ViewState 派生相机的 MapRuntime
  • 合并重复请求的按需 RAF 调度;
  • viewchangerenderdispose 三个生命周期事件;
  • 左键平移、滚轮锚定缩放、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.js
  • shared/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.aspectrenderer.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,会自动:

  1. 释放仍被 capture 的 pointer;
  2. disconnect() ResizeObserver;
  3. 删除六类 DOM listener;
  4. 恢复元素原来的 touch-action
  5. 删除自己的 runtime listener。

随后 runtime 取消待执行 RAF、dispose renderer 并清空其余 listener。你也可以更早调用 controls.dispose();幂等保护确保 pagehide 不会重复释放。

12. 手工验证

在下一阶段页面接上线框后,按顺序检查:

  1. 持续左键拖动,地图中心随地面移动,松手后不继续渲染;
  2. 把光标放在一个格点上滚轮缩放,该格点尽量留在光标下;
  3. Shift + 左键 水平拖动,bearing 连续跨过 ±180° 而不跳画面;
  4. 垂直拖到 pitch 上下限,值稳定停在 85°
  5. 改变窗口大小,中心仍在画布中心,线框比例不因 DPR 改变;
  6. 离开页面后,不再出现 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 线框检验相机与交互是否真的共享同一套坐标合同。


相关推荐
mONESY1 小时前
搞懂 Session 与 JWT 区别 + Axios 拦截器实战
javascript
sibylyue2 小时前
# Web端流媒体JS播放器开源库
前端·javascript·开源
铁皮饭盒2 小时前
网页端, 5.5mb谷歌模型, 识别躯干, 视频都不卡
前端·javascript·后端
码哥DFS3 小时前
LRU缓存
前端·javascript·缓存
zzzzzz3104 小时前
别急着把页面做成舞台:从 react-bits 看动画组件该怎么选
javascript·react.js·开源
冬夜戏雪15 小时前
agent的trace 和eval
开发语言·前端·javascript
感谢一路走过的人17 小时前
AGGrid刷新数据后执行筛选
javascript·ag-grid
水獭比特17 小时前
工具都批准了,为什么还不能执行?给 Agent 补上第二道校验
javascript·人工智能·node.js