three.js最小地图运行时(二):实现视图状态

67 阅读3分钟

教程:创建不可变 ViewState

本教程实现第二篇的第一个运行时模块。完成后,可以把任意 API、交互或 resize 输入规范化成同一种不可变地图状态。

源码位置:

yinqing/examples/vector-tile-handbook/shared/runtime/view-state.js

1. 导入第一篇的纬度规则

不要在运行时复制 Mercator 边界常量:

import { clampLatitude } from "../math/mercator.js";

这样 Stage 01、ViewState 和后续 cover 对纬度定义域使用同一事实来源。

2. 建立数值边界函数

function requireFinite(name, value) {
  if (typeof value !== "number" || !Number.isFinite(value)) {
    throw new TypeError(`${name} must be a finite number.`);
  }
  return value;
}

公共状态边界不能用 Number(value) || 0,也不能单纯用 Number(value) 转换。因为 Number(null) 会隐式转换为 0,从而绕过校验。必须严格限定 typeof value === "number",确保错误输入(如 NaN、null、"")能带字段名抛出异常,调用方才能准确定位错误来源。

viewport 再增加正数约束:

function requirePositive(name, value) {
  const number = requireFinite(name, value);
  if (number <= 0) {
    throw new RangeError(`${name} must be greater than zero.`);
  }
  return number;
}

3. 规范 bearing

function normalizeBearing(value) {
  const bearing = requireFinite("bearing", value);
  const normalized = ((((bearing + 180) % 360) + 360) % 360) - 180;
  return Object.is(normalized, -0) ? 0 : normalized;
}

两次 % 把 JavaScript 的负余数转换为欧几里得模。最后消除 -0,避免它在日志和某些比较输出中制造无意义差异。

立即核对:

 540° → −180°
 180° → −180°
−181° →  179°
  10° →   10°

4. 构造 viewport 快照

在创建 viewport 之前,先进行对象有效性保护:

function createViewport(input = {}) {
  if (!input || typeof input !== "object") {
    throw new TypeError("viewport must be an object.");
  }

  const width = requirePositive("viewport.width", input.width);
  const height = requirePositive("viewport.height", input.height);
  const devicePixelRatio = requirePositive(
    "viewport.devicePixelRatio",
    input.devicePixelRatio ?? 1,
  );

  return Object.freeze({
    width,
    height,
    devicePixelRatio,
    deviceWidth: Math.max(1, Math.round(width * devicePixelRatio)),
    deviceHeight: Math.max(1, Math.round(height * devicePixelRatio)),
  });
}

浏览器布局尺寸可能是小数,所以 CSS width/height 不取整;drawing buffer 尺寸必须是像素整数,因此派生字段使用 Math.round。

5. 创建完整 ViewState

核心实现保持“验证 → 规范化 → 冻结”的顺序:

export function createViewState(input) {
  if (!input || typeof input !== "object") {
    throw new TypeError("input must be a valid object.");
  }
  if (!Array.isArray(input.center) || input.center.length < 2) {
    throw new TypeError("center must be an array with at least 2 numbers.");
  }

  const longitude = requireFinite("center[0]", input.center[0]);
  const latitude = clampLatitude(
    requireFinite("center[1]", input.center[1]),
  );
  const zoom = requireFinite("zoom", input.zoom);
  const bearing = normalizeBearing(input.bearing);
  const pitch = Math.max(
    0,
    Math.min(85, requireFinite("pitch", input.pitch)),
  );

  return Object.freeze({
    center: Object.freeze([longitude, latitude]),
    zoom,
    bearing,
    pitch,
    viewport: createViewport(input.viewport),
  });
}

注意两个有意不做的操作:

  • 不把 longitude 折返到 ±180°,因为连续平移需要 wrap;
  • 不按任何逻辑瓦片剖面的 minZoom/maxZoom 夹取 zoom,因为 ViewState 是相机事实,不是 source policy。

6. 实现事务式 patch

定义属性检测函数 has(利用原生的 Object.hasOwn),避免未定义变量引发 ReferenceError:

export function updateViewState(previous, patch = {}) {
  if (!previous || typeof previous !== "object") {
    throw new TypeError("previous viewState must be an object.");
  }
  if (!patch || typeof patch !== "object") {
    throw new TypeError("patch must be an object.");
  }

  const has = (key) => Object.hasOwn(patch, key);

  return createViewState({
    center: has("center") ? patch.center : previous.center,
    zoom: has("zoom") ? patch.zoom : previous.zoom,
    bearing: has("bearing") ? patch.bearing : previous.bearing,
    pitch: has("pitch") ? patch.pitch : previous.pitch,
    viewport: {
      ...previous.viewport,
      ...(has("viewport") ? patch.viewport : {}),
    },
  });
}

使用属性存在性而不是 ??,可以使 { zoom: null } 或 { zoom: undefined } 在通过 createViewState 时引发明确错误,而不是悄悄沿用旧 zoom。

7. 生成稳定 key

对象属性顺序不应成为隐式协议。用固定数组显式排序:

export function getViewStateKey(viewState) {
  return JSON.stringify([
    viewState.center[0],
    viewState.center[1],
    viewState.zoom,
    viewState.bearing,
    viewState.pitch,
    viewState.viewport.width,
    viewState.viewport.height,
    viewState.viewport.devicePixelRatio,
    viewState.viewport.deviceWidth,
    viewState.viewport.deviceHeight,
  ]);
}

key 在相同规范输入下必须逐字符相等。

8. 在 Node 中核对浏览器模块

在仓库根目录运行:

node --input-type=module -e "import('./yinqing/examples/vector-tile-handbook/shared/runtime/view-state.js').then(({ createViewState, updateViewState, getViewStateKey }) => { const first = createViewState({ center: [540, 90], zoom: 13.25, bearing: 540, pitch: 92, viewport: { width: 801.5, height: 600.25, devicePixelRatio: 1.5 } }); const next = updateViewState(first, { bearing: -181, viewport: { width: 1024 } }); console.log({ first, next, key: getViewStateKey(next) }); })"

预期:

  • longitude 保持 540;latitude 截断到 85.0511287798066;
  • bearing 540 → −180,patch 后 −181 → 179;
  • pitch 92 → 85;
  • 首次 device viewport 为 1202×900;
  • patch 只改 width 后为 1536×900;
  • first 的任何字段都没有变化。

9. 接入运行时时不要做什么

  • 不在 pointer handler 中直接写 viewState.center[0];
  • 不把 camera.rotation 复制回 bearing/pitch;
  • 不在 source 层夹取 zoom;
  • 不把 device pixels 当作指针 CSS pixels;
  • 不以对象的默认字符串化结果作为稳定 key。

下一步将让 PlanarTransform 只从这个快照派生 Three.js 相机和坐标转换。