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

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

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


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

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

天地图 高德
坐标基准 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 计算,我们却在中间插了一次坐标系转换。

ts 复制代码
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;
  }
}

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

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

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

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

ts 复制代码
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。

ts 复制代码
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 页面上,这种串台查起来极痛苦。

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

ts 复制代码
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 事件不回调本页 监听 + 主动拉,两条腿走路

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

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

相关推荐
用户360055579001 小时前
Kestrel_VLLM - 27 · 寄存器常驻与融合发射:两次「少发一次」
前端
前端小万1 小时前
用"道法术器"重构你和 AI 的协作方式
前端·人工智能·程序员
鶴哥只手遮天1 小时前
Angular 22 源码级深度解析-第 06 章 模板指令全解:`ɵɵelementStart`、`ɵɵproperty`、`ɵɵadvance` 与绑定指令
前端·开源·angular.js
像我这样帅的人丶你还2 小时前
我html生成的doc文档为何在手机文档显示不了图片?
前端·javascript
前端双越老师3 小时前
程序员 1v1 访谈系列第十期:纯前端坚持不了多久了
前端·ai编程·全栈
ruanno_top5 小时前
Vue 3 后台权限路由:我漏掉了按钮级裁剪,还把 403 写成了 404
vue.js
门思科技5 小时前
LoRaWAN 网络排障的五层漏斗:从"上不到云"到"数值不对",每一层该用什么工具证明
前端
计算机魔术师5 小时前
Cursor 官方插件商城现恶意 Claude Code 扩展,约 11 小时影响最多 5000 名开发者
前端
掘金者阿豪5 小时前
MongoDB迁移到金仓数据库:商品属性继续灵活存,订单报表交给 SQL
前端·后端