需求只有一句话:默认底图从天地图换成高德,用户可以在面板里自己切回来。我改完瓦片地址,地图确实亮了。然后那天剩下的时间,全花在"为什么图标集体偏出去三四百米"上。
这篇是那次迁移的完整还原:遇到什么现象 → 怎么排查 → 最后落在哪几行代码。这里没有底图原理科普,只有"把天地图换成高德"这一件事要做对的地方。如果你手上正好是同一个需求,下面的顺序可以直接照走。
一、先看清两套图源到底差在哪
动手之前,我把两家的图源参数并排写在一张纸上。这不是严谨,是被坑怕了------后面每一个"切换后不对劲",几乎都能回溯到这张纸上的某一行。
| 天地图 | 高德 | |
|---|---|---|
| 坐标基准 | 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 天然是单独一层,这是它的优势。而高德的矢量底图样式把路网和地名烘死在同一张瓦片里,前端无论如何都关不掉,只能换"卫星影像 + 透明注记"两颗配对使用。
落地清单(第一节)
- 每个图源各自维护:子域列表、最大层级、瓦集名/样式参数、矩阵编号基数;
- 不要抽象一个万能拼地址函数,两家参数不同构;
- 动代码前先确认:两家都能提供"纯影像 + 透明注记"两套瓦片。
二、现象:换完地址,所有点位集体偏移几百米
底图是出来了,但地图上所有东西都不对:
-
机场、单位的图标整体往东南方向偏出去几百米,落进旁边的街区;
-
三维模型也偏,偏的方向和距离跟图标一致;
-
换个缩放级别,偏移量看着又不太一样。
我先排查了三轮。
第一轮,怀疑数据。 拿一个已知地标比对------人工确认过的那栋楼,图标应该在它正上方,结果偏了。单条数据错?换三个不同的点,全都偏,且偏的方向一致。不是数据问题。
第二轮,怀疑固定偏移。 如果只是差一个常数,那加回去就完了。于是我在图上取了三个相距很远的点,量各自的偏移量:方向大体一致,但距离不一样。不是常数。
第三轮,回到坐标系本身。 这里就清楚了:天地图用 WGS84,高德用 GCJ-02。后者是国内对公开坐标做的非线性偏移加密,偏移量由一组三角函数叠加生成,所以它随经纬度变化。
这一步之后有个关键判断,我认为是整个迁移里最重要的一次决策:
我们的业务数据------点位、航线、围栏、模型坐标------是 WGS84,这是数据的真相,不能为了迁就底图去改。
一旦改了,历史数据、外部接口、导出文件、对接方,全都要跟着改。一次 UI 需求会被升级成一次数据迁移,而且改完之后换回天地图又得再迁一遍。
所以结论是:该被纠正的不是业务坐标,是瓦片。
落地清单(第二节)
- 偏移先量化再动手:取三个相距远的点,看偏移是否恒定。不恒定就是坐标系差异,别去凑常数;
- 业务坐标保持 WGS84 不动,纠偏只发生在渲染层;
- 数据层、渲染层分开看------这样以后再接第三家底图,只需要再加一套图源参数。
三、纠偏:把坐标转换做进「瓦片编号 ↔ 经纬度矩形」这一层
想明白"不改业务坐标",落点就唯一了:在瓦片映射层动手。 地图引擎渲染瓦片时,一定会问切片方案(TilingScheme)两个问题:
-
屏幕上这个点,落在哪张瓦片?(经纬度 → 瓦片编号)
-
这张瓦片,该贴在哪个经纬度矩形上?(瓦片编号 → 经纬度矩形)
只要把这两个方法改写掉,引擎全程仍然用 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。天地图那套继续用标准墨卡托方案,两边各走各的,互不干扰。
落地清单(第三节)
- 新建一个切片方案子类,只重写这两个方法,其余全部继承;
- 正向用解析式,反向用迭代(4 轮);
- 高德 provider 挂新方案,天地图 provider 保持标准墨卡托;
- 验收标准:同一个 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------必须是"当前底图"并且"开关打开"。少写任何一个条件,都会出现"底图是天地图、注记却挂着高德的"这种错配,而且它只在特定操作顺序下才复现。
落地清单(第四节)
- 初始化时一次性加四层,切换只写
.show;- 加层前先
removeAll()清默认底图;- 注记显隐条件必须是
active && withLabels;- 组结构用"底图名 → {影像层, 注记层}",新增第三家底图时只加一个 key。
五、状态:让一次切换活到所有地方
到这里,主页面上的切换已经能用了。然后测试同学提了三个问题:
-
主页面切了,点开业务弹窗,里面的地图还是旧底图;
-
刷新一下,回到天地图;
-
退出重新登录,又回去了。
这三个是同一个问题的三张脸:偏好只是一次性传参,没有变成可传播的状态。
这一步我做了四件事。
① 状态按实例隔离,偏好全局共享。 用 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,这一步漏了就是"切回来还是旧的")。
落地清单(第五节)
- 偏好读写各封一个函数,全项目只走这两个函数,不要在业务里直接碰 localStorage;
- 提交 = 持久化 + 通知 UI + 扩散到其他存活实例;
storage监听 + 三处主动拉取(登录成功 / 进入地图页 / 页面重新激活);- 服务端下发的值要做白名单校验 + 静默降级,配置写错了不能挡住登录流程。
六、上线前我会再核一遍这几条
都是实际验证过的动作,不验证就发版,上面任何一条都可能翻车:
- 像素级对齐:同一个 WGS84 点位,在两张底图上打点,误差在个位数像素内;
- 接缝:从最小级别连续放大到最大级别,再缩回来,全程盯接缝,不能有细缝、重影;
- 越界层级:放大到高德的最大层级(18),再切回天地图,不能出空白或糊块;
- 注记:两张底图上,"路网和标注"开关都有效,且切底图后状态不错配;
- 多实例:打开业务弹窗,里面的地图与主图一致;关掉弹窗再切换,主图不受影响;
- 三条状态路径:刷新、跨标签页、重新登录,偏好都正确;
- 回收 :反复开关弹窗,内存不持续增长(
WeakRef那套确实在工作)。
关于资源:天地图和高德都提供面向开发者的免费额度------天地图在官网注册后申请 key,高德在开放平台注册应用获取 key,个人开发者可以零成本跑通上面全部实现。不过配额、商用授权和服务条款各家差异很大,上线前一定以官方开放平台的当前条款为准。
七、小结
如果只用一句话总结这次迁移:
从天地图换到高德,真正要改的不是一个地址,而是三件事------把坐标转换塞进瓦片映射层、按各家规则去取瓦片、把偏好做成会自己扩散的状态。
| 现象 | 真正的病根 | 落点 |
|---|---|---|
| 点位集体偏几百米 | WGS84 与 GCJ-02 基准不同,偏移随经纬度变化 | 在切片方案里做双向转换,正反严格互逆 |
| 缩放时闪细缝 | 反向用了近似解,瓦片矩形对不齐 | 迭代求逆,四轮收敛 |
| 注记关不掉 | 服务端把路网和地名烘死在影像瓦片里 | 换"影像 + 透明注记"两颗配对 |
| 切换闪一下、相机重置 | 每次切换都重建 provider | 四层常驻,只切 show |
| 弹窗里的地图不跟着变 | 偏好只是一次性传参 | WeakMap 隔离实例 + 提交时扩散 |
| 刷新 / 重新登录回退 | 用了模块级快照 | 初始化时实时读存储 |
| 跨页不生效 | storage 事件不回调本页 |
监听 + 主动拉,两条腿走路 |
最后说个让我意外的部分:真正花时间的不是"怎么换",而是"为什么换了不对"。前者只是照着参数表拼字符串,后者要求你先把"谁该变、谁不该变"想清楚。
想清楚之后,再接第三家底图,就只剩下一件事------往那张参数表里,再加一行。