教程:实现并核对 Web Mercator
这一节把上一章的推导落实为一个可复用的浏览器 ES 模块。完成后,你将拥有后续瓦片地址、视口覆盖和渲染坐标共同依赖的四个接口:
js
MAX_MERCATOR_LAT
clampLatitude(lat)
projectLngLat(lng, lat)
unprojectMercator(x, y)
源码位于:
text
yinqing/examples/vector-tile-handbook/shared/math/mercator.js
1. 先写清输入输出
在敲公式之前,先固定契约:
| 函数 | 输入 | 输出 | 单位 |
|---|---|---|---|
clampLatitude |
纬度 | 截断后的纬度 | 度 |
projectLngLat |
经度、纬度 | {x,y} |
输入为度;输出无量纲 |
unprojectMercator |
normalized x/y | {lng,lat} |
输入无量纲;输出为度 |
经度不截断也不折返。181° 会投影到略大于 1 的 x;这样下一章才能把它规范化为 x=0 附近、wrap=1 的瓦片地址。
2. 定义常量和纬度边界
js
export const MAX_MERCATOR_LAT = 85.0511287798066;
const DEG_TO_RAD = Math.PI / 180;
const RAD_TO_DEG = 180 / Math.PI;
export function clampLatitude(lat) {
const num = Number(lat);
if (Number.isNaN(num)) return NaN;
return Math.max(
-MAX_MERCATOR_LAT,
Math.min(MAX_MERCATOR_LAT, num),
);
}
显式使用 Number(lat),让 DOM <input> 的字符串值也能进入计算。同时通过 Number.isNaN 校验防止无效输入伪装成合法数值。UI 层必须负责向用户报告无效输入,数学层不偷偷替换成某个默认位置。
3. 实现正向投影
js
export function projectLngLat(lng, lat) {
const numLng = Number(lng);
const phi = clampLatitude(lat) * DEG_TO_RAD;
return Object.freeze({
x: (numLng + 180) / 360,
y: 0.5
- Math.log(Math.tan(Math.PI / 4 + phi / 2))
/ (2 * Math.PI),
});
}
实现细节与推导说明:
- 经度映射 :经度范围
[-180, 180]到归一化[0, 1]本质上是纯线性映射(lng + 180) / 360,无需经过弧度转换,避免了额外的浮点转换与精度积累误差; - 纬度映射:先截断,再转换为弧度进行墨卡图投影计算;
- 不可变对象:冻结返回的小型对象,确保数据契约安全。
性能提示 :
Object.freeze在教程与工具库模块中能够防止误修改坐标对象。但若后续用于海量顶点转换(如每帧上万次调用)的热点代码中,应移除Object.freeze或改用数组 / TypedArray,以减少 GC(垃圾回收)开销。
4. 实现逆投影
js
export function unprojectMercator(x, y) {
const numX = Number(x);
const numY = Number(y);
return Object.freeze({
lng: numX * 360 - 180,
lat: Math.atan(
Math.sinh(Math.PI * (1 - 2 * numY)),
) * RAD_TO_DEG,
});
}
逆投影同样对 x 到经度的转换使用线性逆变换 x * 360 - 180。
逆投影没有强制把 x/y 限制在 [0,1]。这允许:
x<0或x>1还原为连续世界中的经度;- 调试瓦片边界外的缓冲几何;
- 让"是否规范化"成为瓦片地址模块的显式责任。
5. 用 Node 直接核对同一个浏览器模块
在仓库根目录执行:
powershell
node --input-type=module -e "import('./yinqing/examples/vector-tile-handbook/shared/math/mercator.js').then(({ projectLngLat, unprojectMercator }) => { const origin = projectLngLat(0, 0); const shanghai = projectLngLat(121.4737, 31.2304); const roundTrip = unprojectMercator(shanghai.x, shanghai.y); console.log({ origin, shanghai, roundTrip }); })"
应看到三类事实:
origin.x与origin.y都是0.5;- 上海的
x约为0.8374269444444444,y约为0.40860372471489265; roundTrip回到121.4737, 31.2304,误差小于1e-9°。
再检查纬度边界:
js
projectLngLat(0, 90); // y 接近 0
projectLngLat(0, -90); // y 接近 1
如果没有先截断,第一行会越来越接近负无穷,最终破坏后续的瓦片索引计算。
6. 在浏览器检查器中展示什么
Stage 01 创建后,会直接导入这个模块,并同时展示:
- 输入的度;
- 截断后的纬度;
- normalized x/y(至少 10 位小数);
- 逆投影结果与往返误差;
- 由同一个 x/y 推出的 XYZ 瓦片地址。
检查器不是第二套计算实现。它只格式化数学模块的结果。这样文字示例、Node 核对与浏览器画面共享同一事实来源。
7. 常见错误与定位顺序
结果非常大或出现 NaN
先检查输入是否是有限数字,再检查是否把度直接交给了三角函数。不要先在结果上做 || 0;那会把真正的输入错误伪装成赤道。
南北颠倒
确认归一化公式是 0.5 - ...,不是 0.5 + ...。XYZ 的 y 从北向南增大。
180° 落到世界外
这是连续投影的正确结果:180° → x=1。下一章在离散寻址时会把它规范化成下一世界的 x=0, wrap=1。不要在投影函数里提前 % 1,否则会丢失 wrap。
高纬输入得到 Infinity
检查纬度是不是在换弧度之前调用了 clampLatitude,并确认上限常量没有被四舍五入成 85。
8. 完成标准
完成本教程后,你应该能解释:
- 为什么输入角度而输出没有单位;
- 为什么 y 轴从北向南;
- 为什么纬度截断而经度不折返;
- 为什么
(0°,0°)必须得到(0.5,0.5); - 为什么投影模块不应该知道相机、瓦片缓存或 Three.js。
接下来将把连续的 normalized Mercator 离散化为 XYZ 四叉树地址,并给重复世界建立稳定身份。