three.js地图数学基础(三):实战墨卡托

教程:实现并核对 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),
  });
}

实现细节与推导说明:

  1. 经度映射 :经度范围 [-180, 180] 到归一化 [0, 1] 本质上是纯线性映射 (lng + 180) / 360,无需经过弧度转换,避免了额外的浮点转换与精度积累误差;
  2. 纬度映射:先截断,再转换为弧度进行墨卡图投影计算;
  3. 不可变对象:冻结返回的小型对象,确保数据契约安全。

性能提示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<0x>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.xorigin.y 都是 0.5
  • 上海的 x 约为 0.8374269444444444y 约为 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. 完成标准

完成本教程后,你应该能解释:

  1. 为什么输入角度而输出没有单位;
  2. 为什么 y 轴从北向南;
  3. 为什么纬度截断而经度不折返;
  4. 为什么 (0°,0°) 必须得到 (0.5,0.5)
  5. 为什么投影模块不应该知道相机、瓦片缓存或 Three.js。

接下来将把连续的 normalized Mercator 离散化为 XYZ 四叉树地址,并给重复世界建立稳定身份。


相关推荐
雪碧聊技术1 小时前
力扣 回溯法 | LCR 020. 回文子串
javascript·算法·leetcode
敲敲敲敲暴你脑袋1 小时前
升级啦!纯前端打包下载离线地图
javascript·gis·canvas
SendTomo2 小时前
send.wang私传网:P2P直连传大文件首选工具
javascript·网络·网络协议·webrtc·p2p
晓得迷路了2 小时前
栗子前端技术周刊第 141 期 - Next.js 16.3、npm 安全事件、2026 CSS 现状调查报告结果...
前端·javascript·npm
To_OC10 小时前
别再瞎写 React Router!7 个高频踩坑点一次性讲透
前端·javascript·react.js
岭南灯火13 小时前
前端通用交互式几何编辑器的设计法则 3 - 数据对象的设计
前端·javascript·架构
岭南灯火13 小时前
前端通用交互式几何编辑器的设计法则 2 - 交互事件流
前端·javascript·架构
岭南灯火13 小时前
前端通用交互式几何编辑器的设计法则 1 - 入口的对象及其成员
前端·javascript·架构
满栀58513 小时前
Vue.js 接口封装最佳实践:从基础到高级
前端·javascript·vue.js