ViewState:地图运行时唯一的状态权威
地图相机看起来只是一个 Three.js PerspectiveCamera,但相机对象不适合作为地图状态。它保存矩阵、四元数、父子关系和内部脏标记;交互、resize 或动画如果各自直接修改它,运行时就无法回答"这一帧究竟对应哪一个地图视图"。
本手册把地图输入集中为不可变 ViewState。相机、坐标转换、调试网格和后续的视口 cover 都只消费同一个快照,不从渲染结果反向猜测状态。
1. ViewState 描述什么
规范的结构定义为:
js
{
center: [longitude, latitude],
zoom,
bearing,
pitch,
viewport: {
width,
height,
devicePixelRatio,
deviceWidth,
deviceHeight,
},
}
每个字段都必须写清单位:
| 字段 | 单位 | 允许范围 | 含义 |
|---|---|---|---|
center[0] |
degrees | 任意有限值 | 中心经度;允许超出 ±180° 以保留连续世界 |
center[1] |
degrees | 截断到 ±85.0511287798066° |
中心纬度 |
zoom |
zoom level | 任意有限值 | 连续缩放,不等于某个 source tile 层级 |
bearing |
degrees | 归一化到 [−180,180) |
绕世界上轴的地图朝向 |
pitch |
degrees | 截断到 [0,85] |
0° 为正俯视,增大后看向地平线 |
viewport.width/height |
CSS pixels | >0 |
页面布局中的视口大小 |
devicePixelRatio |
device px / CSS px | >0 |
渲染缓冲区的像素倍率 |
deviceWidth/deviceHeight |
device pixels | 正整数 | CSS 大小乘 DPR 后 Math.round 计算得到 |
center 是地理输入,不是 normalized Mercator;viewport 是屏幕输入,不是相机视锥。它们会由下一章的 PlanarTransform 转换成渲染事实。
2. 为什么 zoom 可以是小数
XYZ 内容层级是整数,但地图相机应连续缩放。定义一整个 Mercator 世界的渲染宽度:
worldSize=tileSize⋅2zoom
当 zoom 从 12 变化到 12.5:
worldSize(12)worldSize(12.5)=20.5=2
画面尺度连续变化,不需要在整数边界跳跃。内容选择稍后会将连续 display zoom 映射到整数 tile 层级,并明确处理 overzoom;不能把相机 zoom 直接当作 source identity。
3. bearing 为什么归一化
10°、370° 和 −350° 表示同一朝向。如果状态 key 保留原输入,等价视图会触发重复相机更新、cover 和请求。
归一化采用欧几里得模运算,将角度映射到半开区间 [−180, 180)(此时 180° 规范化写为 −180°):
bnormalized=((b+180)mod360+360)mod360−180
JavaScript 实现陷阱 :JavaScript 中的
%是取余(Remainder)而不是取模(Modulo)。直接写(b + 180) % 360 - 180在处理负数(如-350°)时会得到错误结果-350°。代码中需引入正数偏移修正:
jsconst normalizeBearing = (b) => ((b + 180) % 360 + 360) % 360 - 180;
4. pitch 为什么不是普通欧拉角
本手册的 pitch 使用地图语义:
0°:相机正对地面;85°:接近地平线,但仍保留数值和交互余量。
它不是直接赋给 camera.rotation.x 的欧拉角。PlanarTransform 会把地图 pitch、bearing 和中心组合成相机位置与四元数。
上限 85° 是教学运行时的明确策略,不是 Web Mercator 纬度上限。两个 85 来自不同问题:一个限制相机视线,一个限制投影定义域,不能共用同一个常量。
5. CSS 像素与设备像素必须同时存在
指针事件的 clientX/clientY 使用 CSS pixels;WebGL drawing buffer 通常使用 device pixels。若:
text
width = 800 CSS px
height = 600 CSS px
DPR = 2
设备像素遵循 Math.round 策略四舍五入计算:
text
deviceWidth = Math.round(800 * 2) = 1600 device px
deviceHeight = Math.round(600 * 2) = 1200 device px
相机 aspect 使用 CSS 或 device 尺寸的比值都相同,但以下工作不能混用:
- DOM 指针到 NDC:使用 CSS width/height;
renderer.setPixelRatio:使用 DPR;- drawing buffer 预算:使用 deviceWidth/deviceHeight;
- 每像素显存和填充率统计:使用设备像素。
把五个字段存入同一不可变 viewport 快照,可以证明一次 resize 对交互、相机和渲染缓冲使用了同一版本。
6. 不可变不是语法偏好
如果交互处理器直接执行:
js
runtime.viewState.center[0] += delta;
其他模块持有的"旧快照"也被修改。此时:
- 旧 key 不再描述旧内容;
viewchange事件的 previous/current 可能相同;- Worker 请求无法判断自己基于哪一版视图;
- 一帧中先运行的模块与后运行的模块看到不同事实。
因此 createViewState 冻结:
center元组;viewport对象;- 顶层 ViewState。
更新必须创建新对象:
js
next = updateViewState(previous, patch);
这不是为了阻止所有错误,而是让越权写入尽早暴露,并让引用相等可表示"是否还是同一快照"。
7. Patch 是一次事务
updateViewState(previous, patch) 的执行必须遵循严格的更新管道(Sanitization Pipeline):
- 合并 Patch :浅层合并顶层属性与
viewport属性; - 规范化与截断 :将
center[1]截断至±85.0511287798066°,pitch截断至[0, 85],bearing执行半开区间模运算; - 强制计算派生字段 :只要
width、height或devicePixelRatio任意一项发生变更,必须强制重新计算deviceWidth/deviceHeight = Math.round(size * DPR),不允许直接信任外部传入的不匹配像素值; - 递归冻结 :对最终生成的对象执行
Object.freeze。
js
const next = updateViewState(previous, {
center: [121.48, 31.24],
zoom: 13.5,
});
中心与 zoom 同时提交。运行时不应先改中心、派生一次相机,再改 zoom、派生第二次相机。一个用户意图对应一个新 ViewState 和一次 viewchange。
8. 稳定 key 的用途
getViewStateKey 按固定顺序序列化规范字段:
text
lng, lat, zoom, bearing, pitch,
CSS width, CSS height, DPR, device width, device height
为了规避 JavaScript 浮点数微小抖动(例如 12.500000000000002)导致无意义的缓存失效和重复渲染, Key 序列化时应对浮点数实施固定精度截断(例如 lng/lat 保留 6 位,zoom/bearing/pitch 保留 4 位小数)。
key 用于:
- 判断 transform 快照是否仍对应当前视图;
- 给异步 cover 或调试输出标记输入版本;
- 合并等价更新;
- 记录可复现的运行时状态。
它不是公开 URL 格式,也不承诺跨版本永久兼容。不要把它用作瓦片缓存 key;瓦片身份由独立 TileKey 定义。
9. 单向权威链
第二篇的状态流为:
text
DOM intent / API patch / resize
→ updateViewState(previous, patch)
→ new frozen ViewState
→ PlanarTransform.update(viewState)
→ camera + TransformSnapshot
→ render / debug grid / conversions
禁止的反向路径:
text
camera.position / quaternion
→ 猜 center, zoom, bearing, pitch
→ 回写 ViewState
相机可以作为派生对象暴露给 renderer,但不能成为主状态。否则一次浮点矩阵分解就可能改变原本稳定的地图输入。
10. 本章契约
ViewState 必须满足:
- 所有数字有限;viewport 尺寸与 DPR 为正;
- 纬度、bearing、pitch 在创建与 patch 事务时强制清洗和规范化;
- 经度和 zoom 不因内容源范围被偷偷夹取;
deviceWidth/deviceHeight始终由Math.round(size * DPR)派生,不可手动篡改;- center、viewport 和顶层对象被递归冻结;
- key 由精度裁剪后的规范字段生成,不受浮点抖动干扰;
- 相机永远是消费者,不是 ViewState 的反向权威。