教程:创建不可变 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 相机和坐标转换。