在 CesiumJS 的 WGS84 地球上正确加载 GCJ02 坐标系的高德瓦片,核心是一个自定义 TilingScheme。附带分屏对比,用来直观验证纠偏效果。
背景
在 CesiumJS 里加载高德地图是个老话题了。高德用的是 GCJ02 坐标系(火星坐标系),而 CesiumJS 的地球是 WGS84。直接把高德瓦片贴上去,路网、建筑会偏移几十到几百米。
网上的方案大多是抄一段代码就完了,很少有人把纠偏的原理讲清楚。这篇文章会把整个方案的每一层拆开来讲——TilingScheme 怎么做坐标转换、ImageryProvider 怎么封装、以及怎么用 Cesium 的分屏功能直观验证纠偏效果。
最后会附上完整的单文件代码,复制保存双击浏览器就能跑。
环境搭建
一个 HTML 文件就能跑,三个 CDN 依赖:
Cesium@1.133.0—— 3D 地球引擎Vue@3.5.13—— 轻量数据绑定,主要管图层切换状态gcoord@1.0.7—— 坐标系转换库(WGS84↔GCJ02↔BD09)
gcoord 是一个轻量级的地理坐标系转换库,支持 WGS84、GCJ02、BD09 三种坐标系之间的互转。API 非常简洁——gcoord.transform([lng, lat], from, to) 一个函数搞定所有转换,不需要任何配置。本文只用到了 WGS84↔GCJ02 两个方向的转换,如果要接入百度地图(BD09),也是同一套调用方式。
全部从 jsDelivr CDN 加载,不需要 npm install,不需要构建工具。本地开发用 npx serve . 或 VS Code 的 Live Server 起个 HTTP 服务即可(Cesium 的 Worker 要求 HTTP 协议)。如果 ArcGIS 底图加载偏慢,可以换成天地图或其他国内源,URL 格式完全兼容。
实现内容
一句话:==在 CesiumJS 的 WGS84 地球上正确加载高德卫星图(GCJ02),并提供分屏对比来直观验证纠偏效果。==
上面这张是未纠偏的高德卫星图,和左侧 ArcGIS 底图对比,路网、建筑位置明显对不上——这就是 GCJ02 偏移的直观效果。
这张是纠偏后的,高德图层跟 ArcGIS 底图几乎完全吻合。下面逐步拆解是怎么做到的。
核心:GCJ02 纠偏——AmapMercatorTilingScheme
整个方案最关键的部分,就是 ++AmapMercatorTilingScheme++ 这个类。
背景
高德地图用的是 GCJ02 坐标系,在 WGS84 基础上加了非线性偏移。CesiumJS 的 3D 地球原生使用 WGS84。直接用 WebMercator 投影加载高德瓦片,瓦片请求的位置和实际显示位置之间就隔了一层坐标偏差。
原理
AmapMercatorTilingScheme 继承自 Cesium 内置的 Cesium.WebMercatorTilingScheme,覆盖了内部 projection 对象的两个关键方法:
WGS84 经纬度 → project() → 屏幕坐标(用于请求瓦片)
屏幕坐标 → unproject() → WGS84 经纬度(用于位置拾取)
project 方法——控制"该请求哪些瓦片"。我们把流程改成:WGS84 经纬度 → 通过 gcoord 转成 GCJ02 → 再用原始 WebMercator 投影算出屏幕坐标。这样 Cesium 实际请求的是高德服务器上"正确位置"的瓦片。
unproject 方法——控制"从屏幕位置反推经纬度"。流程反过来:屏幕坐标 → 原始 WebMercator 反投影得到 GCJ02 经纬度 → gcoord 逆向转为 WGS84。这样在高德图层上点击拾取到的坐标是准确的 WGS84 值。
class AmapMercatorTilingScheme extends Cesium.WebMercatorTilingScheme {
constructor(options) {
super(options);
let projection = new Cesium.WebMercatorProjection();
// 重写 project:WGS84 → GCJ02 → 屏幕坐标
this._projection.project = function (cartographic, result) {
result = gcoord.transform(
[Cesium.Math.toDegrees(cartographic.longitude),
Cesium.Math.toDegrees(cartographic.latitude)],
gcoord.WGS84,
gcoord.GCJ02,
);
result = projection.project(
new Cesium.Cartographic(
Cesium.Math.toRadians(result[0]),
Cesium.Math.toRadians(result[1]),
),
);
return new Cesium.Cartesian2(result.x, result.y);
};
// 重写 unproject:屏幕坐标 → GCJ02 → WGS84
this._projection.unproject = function (cartesian, result) {
let cartographic = projection.unproject(cartesian);
result = gcoord.transform(
[Cesium.Math.toDegrees(cartographic.longitude),
Cesium.Math.toDegrees(cartographic.latitude)],
gcoord.GCJ02,
gcoord.WGS84,
);
return new Cesium.Cartographic(
Cesium.Math.toRadians(result[0]),
Cesium.Math.toRadians(result[1]),
);
};
}
}
关键点:project 和 unproject 必须成对改写。只改 project 能让瓦片加载到正确位置,但地图拾取会拿到 GCJ02 坐标,在地球上会偏。两个方向都走正确的转换,==一整条链路才算是通的==。
封装:AmapImageryProvider
有了纠偏的 TilingScheme,++AmapImageryProvider++ 就是一层薄封装。
它继承自 Cesium.UrlTemplateImageryProvider,构造时做两件事:自动填子域名、注入纠偏的 TilingScheme。
class AmapImageryProvider extends Cesium.UrlTemplateImageryProvider {
constructor(options = {}) {
if (!options.subdomains || !options.subdomains.length) {
options["subdomains"] = ["1", "2", "3", "4"];
}
options["tilingScheme"] = new AmapMercatorTilingScheme();
super(options);
}
}
使用方式跟普通 UrlTemplateImageryProvider 完全一样,传个 URL 就行。高德卫星图的 URL 模板是 webst0{s}.is.autonavi.com,style=6 代表卫星影像。子域名默认 ["1", "2", "3", "4"],拼出 webst01-webst04 四路并行加载。
这种"==不改变接口,只增强行为=="的装饰器模式,在实际项目中很方便——已有的代码把 UrlTemplateImageryProvider 换成 AmapImageryProvider,其他地方完全不用动。
验证:Cesium.SplitDirection 分屏对比
纠偏做完了,怎么确认它确实有效?肉眼对比最直观。Cesium 的 Cesium.SplitDirection 可以在同一个地球上左右显示不同图层。
每个图层通过 splitDirection 属性控制显示区域:
Cesium.SplitDirection.LEFT—— 只在分屏线左侧显示Cesium.SplitDirection.RIGHT—— 只在分屏线右侧显示- 不设置(默认)—— 整屏显示
分屏位置由 viewer.scene.splitPosition 控制,0.0 到 1.0。
这里的图层布局策略是:ArcGIS 作为全屏底图(不设 splitDirection),高德作为右侧覆盖层(RIGHT)。这样左侧始终是 ArcGIS,右侧是高德叠在 ArcGIS 上面。
// 底图不设 splitDirection,左右两侧都有
viewer.imageryLayers.addImageryProvider(
new Cesium.UrlTemplateImageryProvider({
url: "https://server.arcgisonline.com/.../tile/{z}/{y}/{x}",
}),
);
// 高德只显示在右侧,覆盖底图
amapLayer.splitDirection = Cesium.SplitDirection.RIGHT;
分屏线拖拽
分屏线支持拖拽,方便自由调整对比区域。
实现思路:在 #cesiumContainer 内放一个绝对定位的 div 作为滑块(width: 2px,cursor: ew-resize),用 Cesium.ScreenSpaceEventHandler 绑定鼠标事件。拖拽时同步更新 slider.style.left 和 viewer.scene.splitPosition。
const slider = document.getElementById("slider");
const handler = new Cesium.ScreenSpaceEventHandler(slider);
let moveActive = false;
handler.setInputAction(() => { moveActive = true; },
Cesium.ScreenSpaceEventType.LEFT_DOWN);
handler.setInputAction((movement) => {
if (!moveActive) return;
const relativeOffset = movement.endPosition.x;
const splitPosition =
(slider.offsetLeft + relativeOffset) / slider.parentElement.offsetWidth;
slider.style.left = `${100.0 * splitPosition}%`;
viewer.scene.splitPosition = splitPosition;
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
handler.setInputAction(() => { moveActive = false; },
Cesium.ScreenSpaceEventType.LEFT_UP);
ScreenSpaceEventHandler 直接绑在 slider 元素上而非 canvas,这样拖拽事件只在分屏线上触发,不影响正常的地图交互。
纠偏/未纠偏一键切换
右上角提供了一个下拉框,可以在"纠偏"和"未纠偏"之间切换右侧的高德图层,直观对比纠偏前后的差异。
实现方式是同时创建两个高德图层——一个用 AmapImageryProvider(纠偏),一个用普通 UrlTemplateImageryProvider(未纠偏)——都设 splitDirection = RIGHT,通过 show 属性切换显示。
onAmapModeChange(e) {
const mode = e.target.value;
if (mode === "corrected") {
this.amapLayerCorrected.show = true;
this.amapLayerUncorrected.show = false;
} else {
this.amapLayerCorrected.show = false;
this.amapLayerUncorrected.show = true;
}
}
为什么是创建两个图层而不是动态切换 TilingScheme?因为 UrlTemplateImageryProvider 的 tilingScheme 在构造时就固定了,不可更改。两个图层各司其职,用 show 控制显示,隐藏的图层不会发起瓦片请求,性能没有额外开销。
写在最后
整个方案的架构分三层:==坐标纠偏==(TilingScheme)→ ==图层封装==(ImageryProvider)→ ==验证交互==(SplitDirection + 拖拽 + 切换开关)。每一层职责单一,改动范围可控。
AmapMercatorTilingScheme 是核心——在 project 和 unproject 两个方向上完成 WGS84↔GCJ02 的双向转换,保证瓦片请求和位置拾取都准确无误。AmapImageryProvider 把纠偏逻辑和子域名配置封装成跟原生接口一致的图层类,零学习成本。分屏对比和切换开关则是验证纠偏效果的可视化工具。
后面如果要加高德矢量图(style=8)或百度地图,照这个套路再包一层就行,纠偏和分屏的逻辑不用动。
完整代码贴在下面,保存为 HTML 文件即可运行。