---
title: "从天地图切换到高德地图：一次真实的底图迁移实战 - Angryecho"
description: "需求只有一句话：默认底图从天地图换成高德，用户可以在面板里自己切回来。我改完瓦片地址，地图确实亮了。然后那天剩下的时间，全花在\"为什么图标集体偏出去三四百米\"上。 这篇是那次迁移的完整还原：遇到什么现象 → 怎么排查 → 最后落在哪几行代码。这里没有底图原理科普，只有\"把天地图换成高德\"这一件事要做对的地方。"
canonical_url: "https://www.angryecho.com/c/qian_duan_she_qu/p/P2TBC2iURDf3NlbhklVHi"
meta:
  "og:description": "需求只有一句话：默认底图从天地图换成高德，用户可以在面板里自己切回来。我改完瓦片地址，地图确实亮了。然后那天剩下的时间，全花在\"为什么图标集体偏出去三四百米\"上。 这篇是那次迁移的完整还原：遇到什么现象 → 怎么排查 → 最后落在哪几行代码。这里没有底图原理科普，只有\"把天地图换成高德\"这一件事要做对的地方。"
  "og:image": "https://www.angryecho.com/_og/d/c_Post%2Ctitle_~5ZCQ5qe9572R%2Cdescription_~5ZCQ5qe95LiA5LiL77yM5bCx5LiK5ZCQ5qe9572R44CC%2CpostId_P2TBC2iURDf3NlbhklVHi%2Crevision_~MjAyNi0xMS0wM1QwMjo1NTo1Ni4wMDQ2NzRa%2Cp_Ii9jL3FpYW5fZHVhbl9zaGVfcXUvcC9QMlRCQzJpVVJEZjNObGJoa2xWSGki%2Cs_Q1Q4otyytvaxbd1P.png"
  "og:image:height": "630"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": 从天地图切换到高德地图：一次真实的底图迁移实战
---

> Index of every page on this site: [llms.txt](https://www.angryecho.com/llms.txt)

# **从天地图切换到高德地图：一次真实的底图迁移实战**

需求只有一句话：默认底图从天地图换成高德，用户可以在面板里自己切回来。我改完瓦片地址，地图确实亮了。然后那天剩下的时间，全花在"为什么图标集体偏出去三四百米"上。

这篇是那次迁移的完整还原：**遇到什么现象 → 怎么排查 → 最后落在哪几行代码**。这里没有底图原理科普，只有"把天地图换成高德"这一件事要做对的地方。如果你手上正好是同一个需求，下面的顺序可以直接照走。

---

## 一、先看清两套图源到底差在哪

动手之前，我把两家的图源参数并排写在一张纸上。这不是严谨，是被坑怕了——后面每一个"切换后不对劲"，几乎都能回溯到这张纸上的某一行。

|  | 天地图 | 高德 |
| --- | --- | --- |
| 坐标基准 | WGS84 | GCJ-02 |
| 投影方式 | Web 墨卡托 | Web 墨卡托 |
| 影像瓦集 | 影像瓦集 `img_w` | 卫星影像样式（`style=6`） |
| 注记瓦集 | 注记瓦集 `cia_w`，独立一层 | 透明注记样式（`style=8`） |
| 子域数量 | 8 个（`t0` \~ `t7`） | 4 个（`1` \~ `4`） |
| 最大层级 | 17 | 18 |
| 矩阵编号 | 0 基，与引擎 level 一一对应 | 0 基 |

这张表里有三个必须死记的点。

**① 子域数量不同，拼接规则就得分开写。** 天地图是 8 个子域轮转，高德是 4 个。写一个"通用拼地址函数"去套两家，最后一定有一边取不到图——因为参数根本不同构。

**② 最大层级不同。** 高德能到 18 级，天地图到 17 级就没了。`maximumLevel` 必须各自声明。不声明会怎样？用户放大到第 18 级，天地图那边会去请求根本不存在的瓦片，屏幕上就是糊成一片或者直接空白。

**③ 矩阵编号从 0 还是从 1 开始，是最容易错的一位。** 天地图的 `_w` 系列瓦集，`TILEMATRIX` 从 0 开始，和引擎的 level 一一对应，**不要做 +1 映射**。我一开始凭"感觉"加了 1，结果整张图"晚了一级"：低层级看着正常，一放大就不对，再缩回来又好了——这种只在某个层级区间出错的现象，最费时间。

还有一条决定"注记能不能单独关"：

**注记必须是独立的瓦集。** 天地图的 `cia_w` 天然是单独一层，这是它的优势。而高德的矢量底图样式把路网和地名烘死在同一张瓦片里，前端无论如何都关不掉，只能换"卫星影像 + 透明注记"两颗配对使用。

> **落地清单（第一节）**
>
> 1. 每个图源各自维护：子域列表、最大层级、瓦集名/样式参数、矩阵编号基数；
> 2. 不要抽象一个万能拼地址函数，两家参数不同构；
> 3. 动代码前先确认：两家都能提供"纯影像 + 透明注记"两套瓦片。

---

## 二、现象：换完地址，所有点位集体偏移几百米

底图是出来了，但地图上所有东西都不对：

- 机场、单位的图标整体往东南方向偏出去几百米，落进旁边的街区；
- 三维模型也偏，偏的方向和距离跟图标一致；
- 换个缩放级别，偏移量看着又不太一样。

我先排查了三轮。

**第一轮，怀疑数据。** 拿一个已知地标比对——人工确认过的那栋楼，图标应该在它正上方，结果偏了。单条数据错？换三个不同的点，全都偏，且偏的方向一致。不是数据问题。

**第二轮，怀疑固定偏移。** 如果只是差一个常数，那加回去就完了。于是我在图上取了三个相距很远的点，量各自的偏移量：方向大体一致，但距离不一样。**不是常数。**

**第三轮，回到坐标系本身。** 这里就清楚了：天地图用 WGS84，高德用 GCJ-02。后者是国内对公开坐标做的非线性偏移加密，偏移量由一组三角函数叠加生成，所以它随经纬度变化。

这一步之后有个关键判断，我认为是整个迁移里最重要的一次决策：

**我们的业务数据——点位、航线、围栏、模型坐标——是 WGS84，这是数据的真相，不能为了迁就底图去改。**

一旦改了，历史数据、外部接口、导出文件、对接方，全都要跟着改。一次 UI 需求会被升级成一次数据迁移，而且改完之后换回天地图又得再迁一遍。

所以结论是：**该被纠正的不是业务坐标，是瓦片。**

> **落地清单（第二节）**
>
> 1. 偏移先量化再动手：取三个相距远的点，看偏移是否恒定。不恒定就是坐标系差异，别去凑常数；
> 2. 业务坐标保持 WGS84 不动，纠偏只发生在渲染层；
> 3. 数据层、渲染层分开看——这样以后再接第三家底图，只需要再加一套图源参数。

---

## 三、纠偏：把坐标转换做进「瓦片编号 ↔ 经纬度矩形」这一层

想明白"不改业务坐标"，落点就唯一了：在瓦片映射层动手。 地图引擎渲染瓦片时，一定会问切片方案（TilingScheme）两个问题：

1. **屏幕上这个点，落在哪张瓦片？**（经纬度 → 瓦片编号）
2. **这张瓦片，该贴在哪个经纬度矩形上？**（瓦片编号 → 经纬度矩形）

只要把这两个方法改写掉，引擎全程仍然用 WGS84 计算，我们却在中间插了一次坐标系转换。

```
class OffsetTileScheme extends MercatorScheme {
  // ① 经纬度 -> 瓦片编号：先做正向转换，再按标准墨卡托取编号
  tileAt(lng, lat, zoom, out) {
    const [gLng, gLat] = forward(lng, lat); // WGS84 -> GCJ-02，有解析式
    return mercatorIndexOf(gLng, gLat, zoom, out);
  }
  // ② 瓦片编号 -> 它覆盖的经纬度矩形：先取加密坐标下的矩形，再逆向转回来
  rectAt(x, y, zoom, out) {
    const box = mercatorBounds(x, y, zoom); // GCJ-02 空间下的经纬度矩形
    const [w, n] = invert(box.west, box.north, 4);
    const [e, s] = invert(box.east, box.south, 4);
    out.west = toRad(w);
    out.north = toRad(n);
    out.east = toRad(e);
    out.south = toRad(s);
    return out;
  }
}
```

这段代码有三个必须做对的细节，每一个我都踩过。 **① 两个方法必须成对改，缺一个都不行。** 只改正向，瓦片会贴在错误的位置；只改反向，引擎会去取错的那张瓦片。**这两个方法天然是一对逆运算**，任何一边被"优化"掉，接缝立刻现形。

**② 反向没有解析逆，必须迭代求逆。** 正向是一组三角函数的叠加，能直接算；反向没有闭式解。常见的偷懒写法是"反向时减去同样的偏移"，但正向和反向的偏移量本身并不相等，会留下米级残余。

瓦片是有明确边界的。这点残余误差会让相邻瓦片的边缘错开，表现是：**缩放过程中偶尔闪过一条细缝，或者边缘出现半像素的重影。** 静态截图永远看不出来，必须一边缩放一边盯。

我的解法是迭代求逆——以目标点为起点，正向算一次，看差多少，补回去，重复几轮：

```
function invert(lng, lat, rounds) {
  let x = lng;
  let y = lat;
  for (let i = 0; i < rounds; i++) {
    const [fx, fy] = forward(x, y); // 正向再算一遍
    x += lng - fx; // 把残差补回去
    y += lat - fy;
  }
  return [x, y];
}
```

四轮之后残差已经到亚毫米级，瓦片边界严丝合缝。轮数不用太多，两三轮肉眼就够，四轮是图个省心。

**③ 不要自作聪明去翻转 Y 轴。** 很多人第一次写切片方案都想翻一下。实际上主流 XYZ 瓦片是从北往南编号的，和引擎内部一致，**不需要翻转**。真正容易错的不是 Y，是矩阵编号从 0 还是从 1 开始（第一节那条）。

最后补一句界限：这套改造**只作用于高德那一套 provider**。天地图那套继续用标准墨卡托方案，两边各走各的，互不干扰。

> **落地清单（第三节）**
>
> 1. 新建一个切片方案子类，**只重写这两个方法**，其余全部继承；
> 2. 正向用解析式，反向用迭代（4 轮）；
> 3. 高德 provider 挂新方案，天地图 provider 保持标准墨卡托；
> 4. 验收标准：同一个 WGS84 点位在两张底图上的像素位置，误差应在个位数像素内。

---

## 四、图层编排：四层一次性加载，切换只改显隐

纠偏解决之后，第二个问题暴露了：**切一次闪一次，相机还会被重置。**

原因是我最初的写法是"切换时重建 imagery provider"。地图引擎允许这么做，但代价是整层瓦片重新拉取，视觉上就是一次白闪；如果顺手重建了 viewer，那相机、模型、事件全得重新挂一遍。

改法很干脆：**两套底图，每套两层（影像 + 注记），四层在初始化时一次性全部加进去。之后切换，只改** `**show**`**。**

```
function initBasemaps(viewer) {
  // 先清掉引擎自带的默认底图，否则四层底下永远糊着它
  viewer.imageryLayers.removeAll();
  const scheme = new OffsetTileScheme();
  // 天地图：标准墨卡托，8 子域，最大 17 级
  const tdtBase = addLayer(viewer, rasterOf('tdt', 'image'));
  const tdtLabels = addLayer(viewer, rasterOf('tdt', 'label'));
  // 高德：纠偏切片方案，4 子域，最大 18 级
  const amapBase = addLayer(viewer, rasterOf('amap', 'image', scheme));
  const amapLabels = addLayer(viewer, rasterOf('amap', 'label', scheme));
  const groups = {
    tdt: { base: tdtBase, labels: tdtLabels },
    amap: { base: amapBase, labels: amapLabels },
  };
  applyVisibility(groups, loadPrefs());
}

// 切换的核心：只改显隐，不碰 provider
function applyVisibility(groups, pref) {
  for (const kind of Object.keys(groups)) {
    const active = kind === pref.kind;
    groups[kind].base.show = active;
    groups[kind].labels.show = active && pref.withLabels;
  }
}
```

这段代码带来两个直接收益。

**切换是同步的。** 没有加载态、没有闪烁，点下去立刻变。

**"换底图"和"注记开关"两个维度，收敛到一行表达式里。** 注意注记的条件是 `active && withLabels`——必须是"当前底图"**并且**"开关打开"。少写任何一个条件，都会出现"底图是天地图、注记却挂着高德的"这种错配，而且它只在特定操作顺序下才复现。

> **落地清单（第四节）**

> 1. 初始化时一次性加四层，切换只写 `.show`；
> 2. 加层前先 `removeAll()` 清默认底图；
> 3. 注记显隐条件必须是 `active && withLabels`；
> 4. 组结构用"底图名 → {影像层, 注记层}"，新增第三家底图时只加一个 key。

---

## 五、状态：让一次切换活到所有地方

到这里，主页面上的切换已经能用了。然后测试同学提了三个问题：

1. 主页面切了，点开业务弹窗，里面的地图还是旧底图；
2. 刷新一下，回到天地图；
3. 退出重新登录，又回去了。

这三个是同一个问题的三张脸：**偏好只是一次性传参，没有变成可传播的状态。**

这一步我做了四件事。

**① 状态按实例隔离，偏好全局共享。** 用 `WeakMap` 存"实例 → 状态"。**不能用模块级单例**——主页面和弹窗可能同时存在，单例会直接让 A 页面的切换作用到 B 页面上，这种串台查起来极痛苦。

**② 提交时既要持久化，也要扩散。**

```
function commit(pref, source) {
  savePrefs(pref); // 写 localStorage，刷新后还记得
  notify(pref, source); // 通知面板更新选中态
  forEachLive((v) => {
    // 扩散到其他存活的实例
    if (v === source) return;
    applyVisibility(groupsOf(v), pref);
  });
}
```

"存活实例"用 `WeakRef` 收集、`FinalizationRegistry` 剔除。**这一步不做就会内存泄漏**：地图实例销毁了，集合里还攥着它，切换时还在往一个死对象上写状态。

**③** `**storage**`** 事件只解决一半问题。**

> `storage` 事件只在**其他 document** 修改时触发。同一个标签页里 `localStorage.setItem`，不会回调自己。

所以：跨标签页同步靠监听 `storage`；而"本页内的其他逻辑写了偏好"（最典型的就是登录后从服务端拉配置），必须主动拉一次。 **④ 不要用模块级快照。** 这是坑最深的一个。

ESM 模块在同一个页面会话里**只会求值一次**。如果初始化时把偏好读进一个模块顶层变量，那么"退出登录 → 重新登录 → 服务端下发新配置"这条路上，那个变量永远停在第一次求值时的值。症状就是：DevTools 里 localStorage 明明是新的，页面上还是旧的，**F5 一下就好了**——这种"刷新能好"的 bug，八成是快照问题。

所以初始化时**必须实时读**，不要信模块求值那一刻的值。

除了 `storage` 监听，我最终在三个时机主动拉一次：登录成功后、进入地图页之前、缓存页面重新激活时（`keep-alive` 的页面不会重跑 `mounted`，这一步漏了就是"切回来还是旧的"）。

> **落地清单（第五节）**
>
> 1. 偏好读写各封一个函数，全项目只走这两个函数，不要在业务里直接碰 localStorage；
> 2. 提交 = 持久化 + 通知 UI + 扩散到其他存活实例；
> 3. `storage` 监听 + 三处主动拉取（登录成功 / 进入地图页 / 页面重新激活）；
> 4. 服务端下发的值要做白名单校验 + 静默降级，配置写错了不能挡住登录流程。

---

## 六、上线前我会再核一遍这几条

都是实际验证过的动作，不验证就发版，上面任何一条都可能翻车：

1. **像素级对齐**：同一个 WGS84 点位，在两张底图上打点，误差在个位数像素内；
2. **接缝**：从最小级别连续放大到最大级别，再缩回来，全程盯接缝，不能有细缝、重影；
3. **越界层级**：放大到高德的最大层级（18），再切回天地图，不能出空白或糊块；
4. **注记**：两张底图上，"路网和标注"开关都有效，且切底图后状态不错配；
5. **多实例**：打开业务弹窗，里面的地图与主图一致；关掉弹窗再切换，主图不受影响；
6. **三条状态路径**：刷新、跨标签页、重新登录，偏好都正确；
7. **回收**：反复开关弹窗，内存不持续增长（ `WeakRef` 那套确实在工作）。

> 关于资源：天地图和高德都提供面向开发者的免费额度——天地图在官网注册后申请 key，高德在开放平台注册应用获取 key，个人开发者可以零成本跑通上面全部实现。不过配额、商用授权和服务条款各家差异很大，**上线前一定以官方开放平台的当前条款为准**。

---

## 七、小结

如果只用一句话总结这次迁移：

**从天地图换到高德，真正要改的不是一个地址，而是三件事——把坐标转换塞进瓦片映射层、按各家规则去取瓦片、把偏好做成会自己扩散的状态。**

| 现象 | 真正的病根 | 落点 |
| --- | --- | --- |
| 点位集体偏几百米 | WGS84 与 GCJ-02 基准不同，偏移随经纬度变化 | 在切片方案里做双向转换，正反严格互逆 |
| 缩放时闪细缝 | 反向用了近似解，瓦片矩形对不齐 | 迭代求逆，四轮收敛 |
| 注记关不掉 | 服务端把路网和地名烘死在影像瓦片里 | 换"影像 + 透明注记"两颗配对 |
| 切换闪一下、相机重置 | 每次切换都重建 provider | 四层常驻，只切 `show` |
| 弹窗里的地图不跟着变 | 偏好只是一次性传参 | `WeakMap` 隔离实例 + 提交时扩散 |
| 刷新 / 重新登录回退 | 用了模块级快照 | 初始化时实时读存储 |
| 跨页不生效 | `storage` 事件不回调本页 | 监听 + 主动拉，两条腿走路 |

最后说个让我意外的部分：真正花时间的不是"怎么换"，而是"为什么换了不对"。前者只是照着参数表拼字符串，后者要求你先把"谁该变、谁不该变"想清楚。

想清楚之后，再接第三家底图，就只剩下一件事——往那张参数表里，再加一行。

排序方式 :

### **成为第一条评论！**

Nobody's responded to this post yet. Add your thoughts and get the conversation going.

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://www.angryecho.com/c/qian_duan_she_qu/p/P2TBC2iURDf3NlbhklVHi#post","@type":"DiscussionForumPosting","commentCount":0,"dateModified":"2026-11-03T02:55:56.004Z","datePublished":"2026-11-02T09:34:33.787Z","description":"需求只有一句话：默认底图从天地图换成高德，用户可以在面板里自己切回来。我改完瓦片地址，地图确实亮了。然后那天剩下的时间，全花在\"为什么图标集体偏出去三四百米\"上。 这篇是那次迁移的完整还原：遇到什么现象 → 怎么排查 → 最后落在哪几行代码。这里没有底图原理科普，只有\"把天地图换成高德\"这一件事要做对的地方。","headline":"从天地图切换到高德地图：一次真实的底图迁移实战","isPartOf":"https://www.angryecho.com/c/qian_duan_she_qu","mainEntityOfPage":"https://www.angryecho.com/c/qian_duan_she_qu/p/P2TBC2iURDf3NlbhklVHi","text":"需求只有一句话：默认底图从天地图换成高德，用户可以在面板里自己切回来。我改完瓦片地址，地图确实亮了。然后那天剩下的时间，全花在\"为什么图标集体偏出去三四百米\"上。\n\n这篇是那次迁移的完整还原：遇到什么现象 → 怎么排查 → 最后落在哪几行代码。这里没有底图原理科普，只有\"把天地图换成高德\"这一件事要做对的地方。如果你手上正好是同一个需求，下面的顺序可以直接照走。\n\n\n\n一、先看清两套图源到底差在哪\n\n动手之前，我把两家的图源参数并排写在一张纸上。这不是严谨，是被坑怕了——后面每一个\"切换后不对劲\"，几乎都能回溯到这张纸上的某一行。\n\n\n\n\n\n\n\n\n\n\n\n天地图\n\n\n\n高德\n\n\n\n\n\n坐标基准\n\n\n\nWGS84\n\n\n\nGCJ-02\n\n\n\n\n\n投影方式\n\n\n\nWeb 墨卡托\n\n\n\nWeb 墨卡托\n\n\n\n\n\n影像瓦集\n\n\n\n影像瓦集 img_w\n\n\n\n卫星影像样式（style=6）\n\n\n\n\n\n注记瓦集\n\n\n\n注记瓦集 cia_w，独立一层\n\n\n\n透明注记样式（style=8）\n\n\n\n\n\n子域数量\n\n\n\n8 个（t0 ~ t7）\n\n\n\n4 个（1 ~ 4）\n\n\n\n\n\n最大层级\n\n\n\n17\n\n\n\n18\n\n\n\n\n\n矩阵编号\n\n\n\n0 基，与引擎 level 一一对应\n\n\n\n0 基\n\n这张表里有三个必须死记的点。\n\n① 子域数量不同，拼接规则就得分开写。 天地图是 8 个子域轮转，高德是 4 个。写一个\"通用拼地址函数\"去套两家，最后一定有一边取不到图——因为参数根本不同构。\n\n② 最大层级不同。 高德能到 18 级，天地图到 17 级就没了。maximumLevel 必须各自声明。不声明会怎样？用户放大到第 18 级，天地图那边会去请求根本不存在的瓦片，屏幕上就是糊成一片或者直接空白。\n\n③ 矩阵编号从 0 还是从 1 开始，是最容易错的一位。 天地图的 _w 系列瓦集，TILEMATRIX 从 0 开始，和引擎的 level 一一对应，不要做 +1 映射。我一开始凭\"感觉\"加了 1，结果整张图\"晚了一级\"：低层级看着正常，一放大就不对，再缩回来又好了——这种只在某个层级区间出错的现象，最费时间。\n\n还有一条决定\"注记能不能单独关\"：\n\n注记必须是独立的瓦集。 天地图的 cia_w 天然是单独一层，这是它的优势。而高德的矢量底图样式把路网和地名烘死在同一张瓦片里，前端无论如何都关不掉，只能换\"卫星影像 + 透明注记\"两颗配对使用。\n\n\n\n落地清单（第一节）\n\n\n\n\n\n每个图源各自维护：子域列表、最大层级、瓦集名/样式参数、矩阵编号基数；\n\n\n\n不要抽象一个万能拼地址函数，两家参数不同构；\n\n\n\n动代码前先确认：两家都能提供\"纯影像 + 透明注记\"两套瓦片。\n\n\n\n二、现象：换完地址，所有点位集体偏移几百米\n\n底图是出来了，但地图上所有东西都不对：\n\n\n\n\n\n机场、单位的图标整体往东南方向偏出去几百米，落进旁边的街区；\n\n\n\n三维模型也偏，偏的方向和距离跟图标一致；\n\n\n\n换个缩放级别，偏移量看着又不太一样。\n\n我先排查了三轮。\n\n第一轮，怀疑数据。 拿一个已知地标比对——人工确认过的那栋楼，图标应该在它正上方，结果偏了。单条数据错？换三个不同的点，全都偏，且偏的方向一致。不是数据问题。\n\n第二轮，怀疑固定偏移。 如果只是差一个常数，那加回去就完了。于是我在图上取了三个相距很远的点，量各自的偏移量：方向大体一致，但距离不一样。不是常数。\n\n第三轮，回到坐标系本身。 这里就清楚了：天地图用 WGS84，高德用 GCJ-02。后者是国内对公开坐标做的非线性偏移加密，偏移量由一组三角函数叠加生成，所以它随经纬度变化。\n\n这一步之后有个关键判断，我认为是整个迁移里最重要的一次决策：\n\n我们的业务数据——点位、航线、围栏、模型坐标——是 WGS84，这是数据的真相，不能为了迁就底图去改。\n\n一旦改了，历史数据、外部接口、导出文件、对接方，全都要跟着改。一次 UI 需求会被升级成一次数据迁移，而且改完之后换回天地图又得再迁一遍。\n\n所以结论是：该被纠正的不是业务坐标，是瓦片。\n\n\n\n落地清单（第二节）\n\n\n\n\n\n偏移先量化再动手：取三个相距远的点，看偏移是否恒定。不恒定就是坐标系差异，别去凑常数；\n\n\n\n业务坐标保持 WGS84 不动，纠偏只发生在渲染层；\n\n\n\n数据层、渲染层分开看——这样以后再接第三家底图，只需要再加一套图源参数。\n\n\n\n三、纠偏：把坐标转换做进「瓦片编号 ↔ 经纬度矩形」这一层\n\n想明白\"不改业务坐标\"，落点就唯一了：在瓦片映射层动手。\n地图引擎渲染瓦片时，一定会问切片方案（TilingScheme）两个问题：\n\n\n\n\n\n屏幕上这个点，落在哪张瓦片？（经纬度 → 瓦片编号）\n\n\n\n这张瓦片，该贴在哪个经纬度矩形上？（瓦片编号 → 经纬度矩形）\n\n只要把这两个方法改写掉，引擎全程仍然用 WGS84 计算，我们却在中间插了一次坐标系转换。\n\nclass OffsetTileScheme extends MercatorScheme {\n  // ① 经纬度 -> 瓦片编号：先做正向转换，再按标准墨卡托取编号\n  tileAt(lng, lat, zoom, out) {\n    const [gLng, gLat] = forward(lng, lat); // WGS84 -> GCJ-02，有解析式\n    return mercatorIndexOf(gLng, gLat, zoom, out);\n  }\n  // ② 瓦片编号 -> 它覆盖的经纬度矩形：先取加密坐标下的矩形，再逆向转回来\n  rectAt(x, y, zoom, out) {\n    const box = mercatorBounds(x, y, zoom); // GCJ-02 空间下的经纬度矩形\n    const [w, n] = invert(box.west, box.north, 4);\n    const [e, s] = invert(box.east, box.south, 4);\n    out.west = toRad(w);\n    out.north = toRad(n);\n    out.east = toRad(e);\n    out.south = toRad(s);\n    return out;\n  }\n}\n\n这段代码有三个必须做对的细节，每一个我都踩过。\n① 两个方法必须成对改，缺一个都不行。 只改正向，瓦片会贴在错误的位置；只改反向，引擎会去取错的那张瓦片。这两个方法天然是一对逆运算，任何一边被\"优化\"掉，接缝立刻现形。\n\n② 反向没有解析逆，必须迭代求逆。 正向是一组三角函数的叠加，能直接算；反向没有闭式解。常见的偷懒写法是\"反向时减去同样的偏移\"，但正向和反向的偏移量本身并不相等，会留下米级残余。\n\n瓦片是有明确边界的。这点残余误差会让相邻瓦片的边缘错开，表现是：缩放过程中偶尔闪过一条细缝，或者边缘出现半像素的重影。 静态截图永远看不出来，必须一边缩放一边盯。\n\n我的解法是迭代求逆——以目标点为起点，正向算一次，看差多少，补回去，重复几轮：\n\nfunction invert(lng, lat, rounds) {\n  let x = lng;\n  let y = lat;\n  for (let i = 0; i \u003C rounds; i++) {\n    const [fx, fy] = forward(x, y); // 正向再算一遍\n    x += lng - fx; // 把残差补回去\n    y += lat - fy;\n  }\n  return [x, y];\n}\n\n四轮之后残差已经到亚毫米级，瓦片边界严丝合缝。轮数不用太多，两三轮肉眼就够，四轮是图个省心。\n\n③ 不要自作聪明去翻转 Y 轴。 很多人第一次写切片方案都想翻一下。实际上主流 XYZ 瓦片是从北往南编号的，和引擎内部一致，不需要翻转。真正容易错的不是 Y，是矩阵编号从 0 还是从 1 开始（第一节那条）。\n\n最后补一句界限：这套改造只作用于高德那一套 provider。天地图那套继续用标准墨卡托方案，两边各走各的，互不干扰。\n\n\n\n落地清单（第三节）\n\n\n\n\n\n新建一个切片方案子类，只重写这两个方法，其余全部继承；\n\n\n\n正向用解析式，反向用迭代（4 轮）；\n\n\n\n高德 provider 挂新方案，天地图 provider 保持标准墨卡托；\n\n\n\n验收标准：同一个 WGS84 点位在两张底图上的像素位置，误差应在个位数像素内。\n\n\n\n四、图层编排：四层一次性加载，切换只改显隐\n\n纠偏解决之后，第二个问题暴露了：切一次闪一次，相机还会被重置。\n\n原因是我最初的写法是\"切换时重建 imagery provider\"。地图引擎允许这么做，但代价是整层瓦片重新拉取，视觉上就是一次白闪；如果顺手重建了 viewer，那相机、模型、事件全得重新挂一遍。\n\n改法很干脆：两套底图，每套两层（影像 + 注记），四层在初始化时一次性全部加进去。之后切换，只改 show。\n\nfunction initBasemaps(viewer) {\n  // 先清掉引擎自带的默认底图，否则四层底下永远糊着它\n  viewer.imageryLayers.removeAll();\n  const scheme = new OffsetTileScheme();\n  // 天地图：标准墨卡托，8 子域，最大 17 级\n  const tdtBase = addLayer(viewer, rasterOf('tdt', 'image'));\n  const tdtLabels = addLayer(viewer, rasterOf('tdt', 'label'));\n  // 高德：纠偏切片方案，4 子域，最大 18 级\n  const amapBase = addLayer(viewer, rasterOf('amap', 'image', scheme));\n  const amapLabels = addLayer(viewer, rasterOf('amap', 'label', scheme));\n  const groups = {\n    tdt: { base: tdtBase, labels: tdtLabels },\n    amap: { base: amapBase, labels: amapLabels },\n  };\n  applyVisibility(groups, loadPrefs());\n}\n\n// 切换的核心：只改显隐，不碰 provider\nfunction applyVisibility(groups, pref) {\n  for (const kind of Object.keys(groups)) {\n    const active = kind === pref.kind;\n    groups[kind].base.show = active;\n    groups[kind].labels.show = active && pref.withLabels;\n  }\n}\n\n这段代码带来两个直接收益。\n\n切换是同步的。 没有加载态、没有闪烁，点下去立刻变。\n\n\"换底图\"和\"注记开关\"两个维度，收敛到一行表达式里。 注意注记的条件是 active && withLabels——必须是\"当前底图\"并且\"开关打开\"。少写任何一个条件，都会出现\"底图是天地图、注记却挂着高德的\"这种错配，而且它只在特定操作顺序下才复现。\n\n\n\n落地清单（第四节）\n\n\n\n\n\n\n\n初始化时一次性加四层，切换只写 .show；\n\n\n\n加层前先 removeAll() 清默认底图；\n\n\n\n注记显隐条件必须是 active && withLabels；\n\n\n\n组结构用\"底图名 → {影像层, 注记层}\"，新增第三家底图时只加一个 key。\n\n\n\n五、状态：让一次切换活到所有地方\n\n到这里，主页面上的切换已经能用了。然后测试同学提了三个问题：\n\n\n\n\n\n主页面切了，点开业务弹窗，里面的地图还是旧底图；\n\n\n\n刷新一下，回到天地图；\n\n\n\n退出重新登录，又回去了。\n\n这三个是同一个问题的三张脸：偏好只是一次性传参，没有变成可传播的状态。\n\n这一步我做了四件事。\n\n① 状态按实例隔离，偏好全局共享。 用 WeakMap 存\"实例 → 状态\"。不能用模块级单例——主页面和弹窗可能同时存在，单例会直接让 A 页面的切换作用到 B 页面上，这种串台查起来极痛苦。\n\n② 提交时既要持久化，也要扩散。\n\nfunction commit(pref, source) {\n  savePrefs(pref); // 写 localStorage，刷新后还记得\n  notify(pref, source); // 通知面板更新选中态\n  forEachLive((v) => {\n    // 扩散到其他存活的实例\n    if (v === source) return;\n    applyVisibility(groupsOf(v), pref);\n  });\n}\n\n\"存活实例\"用 WeakRef 收集、FinalizationRegistry 剔除。这一步不做就会内存泄漏：地图实例销毁了，集合里还攥着它，切换时还在往一个死对象上写状态。\n\n③ storage 事件只解决一半问题。\n\n\n\nstorage 事件只在其他 document 修改时触发。同一个标签页里 localStorage.setItem，不会回调自己。\n\n所以：跨标签页同步靠监听 storage；而\"本页内的其他逻辑写了偏好\"（最典型的就是登录后从服务端拉配置），必须主动拉一次。\n④ 不要用模块级快照。 这是坑最深的一个。\n\nESM 模块在同一个页面会话里只会求值一次。如果初始化时把偏好读进一个模块顶层变量，那么\"退出登录 → 重新登录 → 服务端下发新配置\"这条路上，那个变量永远停在第一次求值时的值。症状就是：DevTools 里 localStorage 明明是新的，页面上还是旧的，F5 一下就好了——这种\"刷新能好\"的 bug，八成是快照问题。\n\n所以初始化时必须实时读，不要信模块求值那一刻的值。\n\n除了 storage 监听，我最终在三个时机主动拉一次：登录成功后、进入地图页之前、缓存页面重新激活时（keep-alive 的页面不会重跑 mounted，这一步漏了就是\"切回来还是旧的\"）。\n\n\n\n落地清单（第五节）\n\n\n\n\n\n偏好读写各封一个函数，全项目只走这两个函数，不要在业务里直接碰 localStorage；\n\n\n\n提交 = 持久化 + 通知 UI + 扩散到其他存活实例；\n\n\n\nstorage 监听 + 三处主动拉取（登录成功 / 进入地图页 / 页面重新激活）；\n\n\n\n服务端下发的值要做白名单校验 + 静默降级，配置写错了不能挡住登录流程。\n\n\n\n六、上线前我会再核一遍这几条\n\n都是实际验证过的动作，不验证就发版，上面任何一条都可能翻车：\n\n\n\n\n\n像素级对齐：同一个 WGS84 点位，在两张底图上打点，误差在个位数像素内；\n\n\n\n接缝：从最小级别连续放大到最大级别，再缩回来，全程盯接缝，不能有细缝、重影；\n\n\n\n越界层级：放大到高德的最大层级（18），再切回天地图，不能出空白或糊块；\n\n\n\n注记：两张底图上，\"路网和标注\"开关都有效，且切底图后状态不错配；\n\n\n\n多实例：打开业务弹窗，里面的地图与主图一致；关掉弹窗再切换，主图不受影响；\n\n\n\n三条状态路径：刷新、跨标签页、重新登录，偏好都正确；\n\n\n\n回收：反复开关弹窗，内存不持续增长（WeakRef 那套确实在工作）。\n\n\n\n关于资源：天地图和高德都提供面向开发者的免费额度——天地图在官网注册后申请 key，高德在开放平台注册应用获取 key，个人开发者可以零成本跑通上面全部实现。不过配额、商用授权和服务条款各家差异很大，上线前一定以官方开放平台的当前条款为准。\n\n\n\n七、小结\n\n如果只用一句话总结这次迁移：\n\n从天地图换到高德，真正要改的不是一个地址，而是三件事——把坐标转换塞进瓦片映射层、按各家规则去取瓦片、把偏好做成会自己扩散的状态。\n\n\n\n\n\n\n\n现象\n\n\n\n真正的病根\n\n\n\n落点\n\n\n\n\n\n点位集体偏几百米\n\n\n\nWGS84 与 GCJ-02 基准不同，偏移随经纬度变化\n\n\n\n在切片方案里做双向转换，正反严格互逆\n\n\n\n\n\n缩放时闪细缝\n\n\n\n反向用了近似解，瓦片矩形对不齐\n\n\n\n迭代求逆，四轮收敛\n\n\n\n\n\n注记关不掉\n\n\n\n服务端把路网和地名烘死在影像瓦片里\n\n\n\n换\"影像 + 透明注记\"两颗配对\n\n\n\n\n\n切换闪一下、相机重置\n\n\n\n每次切换都重建 provider\n\n\n\n四层常驻，只切 show\n\n\n\n\n\n弹窗里的地图不跟着变\n\n\n\n偏好只是一次性传参\n\n\n\nWeakMap 隔离实例 + 提交时扩散\n\n\n\n\n\n刷新 / 重新登录回退\n\n\n\n用了模块级快照\n\n\n\n初始化时实时读存储\n\n\n\n\n\n跨页不生效\n\n\n\nstorage 事件不回调本页\n\n\n\n监听 + 主动拉，两条腿走路\n\n最后说个让我意外的部分：真正花时间的不是\"怎么换\"，而是\"为什么换了不对\"。前者只是照着参数表拼字符串，后者要求你先把\"谁该变、谁不该变\"想清楚。\n\n想清楚之后，再接第三家底图，就只剩下一件事——往那张参数表里，再加一行。","url":"https://www.angryecho.com/c/qian_duan_she_qu/p/P2TBC2iURDf3NlbhklVHi","author":{"@type":"Person","name":"小鹿小陆","url":"https://www.angryecho.com/u/F2Oxiao_lu_xiao_lu"},"interactionStatistic":[{"@type":"InteractionCounter","interactionType":"https://schema.org/LikeAction","userInteractionCount":0},{"@type":"InteractionCounter","interactionType":"https://schema.org/DislikeAction","userInteractionCount":0},{"@type":"InteractionCounter","interactionType":"https://schema.org/ViewAction","userInteractionCount":1},{"@type":"InteractionCounter","interactionType":"https://schema.org/ShareAction","userInteractionCount":0}]}]}
```

## Sitemap

See the full [sitemap](https://www.angryecho.com/sitemap.md) for all pages.
