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 四叉树地址,并给重复世界建立稳定身份。


相关推荐
高级程序源6 小时前
django大学生创新创业项目管理系统94923-计算机课程设计、毕业设计
javascript·vue.js·spring boot·后端·python·django·课程设计
lizz22766 小时前
Babel插件手册
javascript
VXbishe9 小时前
【课程设计】基于SpringBoot的乡村政务服务系统的设计与实现-计算机毕设77011
javascript·vue.js·spring boot·python·php·课程设计·政务
志尊宝9 小时前
Vue3 零基础每日笔记(037):动手封装 useMouse 与 useWindowSize——事件监听的标准模板
前端·javascript·vue.js·笔记·html5
shmily麻瓜小菜鸡10 小时前
TDD(测试驱动开发)详解
开发语言·javascript·typescript·node.js·ecmascript
wcy031011 小时前
POST为什么会发送两次请求
javascript·ajax
志尊宝11 小时前
Vue3 零基础每日笔记(047):动态路由与路由参数——:id 传参、query 传参、props 解耦
前端·javascript·vue.js·笔记·html5
朝屯暮蒙vi15 小时前
随记:h5+中uploader不能添加参数数据,要通过task
javascript
泯泷1 天前
那段文字是谁删的?Yjs 14 正式版之前,一套删除归属方案的实现与边界
前端·javascript·算法
hold?fish:palm1 天前
34 合并K个升序链表
javascript·算法·链表