写给前端的地图坐标转换指南:为什么同一个点,天地图和高德给你的数不一样
写给前端的地图坐标转换指南:为什么同一个点,天地图和高德给你的数不一样
第一次做地图业务的人,几乎都会踩同一个坑:后端给的坐标,画到地图上偏了几百米,点跑到马路外面。你以为是数据错了,其实是坐标系没搞对。这篇文章把坐标转换这件事彻底讲清楚。
一个真实的翻车现场
假设你接到一个需求:把 GPS 设备上报的坐标实时画到地图上。后端给你的数据长这样:
{ "lng": 114.167, "lat": 22.458 }
你拿这个数直接画到高德地图上,发现点不在它该在的位置,偏了几百米。你去找后端,后端说「这是 GPS 真实坐标啊」。你又试了天地图,发现天地图上准的。
同样的坐标,为什么换个底图就偏了?
这就是国内地图开发绕不开的坑——坐标系不统一。
先分清两个最容易混的概念
很多人干了几年也分不清「坐标系」和「投影」,这是理解坐标转换的前提。
坐标系:描述「经纬度数值」
同一地点,不同厂商用的数值不一样,因为它们用的是不同的坐标系:
| 坐标系 | 是什么 | 谁在用 |
|---|---|---|
| WGS84 | 国际标准 GPS 原始坐标 | 天地图、Google Earth、GPS 设备 |
| GCJ02 | 国测局「火星坐标」,在 WGS84 上加了非线性加密偏移 | 高德、腾讯、Google 中国 |
| BD09 | 百度在 GCJ02 基础上再加密一次 | 百度地图 |
| CGCS2000 | 国家大地坐标系,与 WGS84 差异极小(厘米级) | 国家测绘、政务系统 |
回到开头那个翻车现场:后端给你的 GPS 坐标是 WGS84,但高德底图是 GCJ02,两套坐标系之间有几百米的非线性偏移,所以点画上去就偏了。天地图用 WGS84,所以不偏。
投影:描述「怎么把球面铺成平面」
EPSG:4326:经纬度直接当坐标,单位是「度」EPSG:3857(Web 墨卡托):经纬度投影成米,单位是「米」,所有 Web 瓦片地图都用它
记住一个关键区分,以后排查问题很有用:
- 坐标系用错 → 偏几百米,点跑到隔壁马路
- 投影用错 → 整张图飞掉,完全错位
排查时按「先投影 → 再坐标系 → 最后数据」的顺序,能少走很多弯路。
GCJ02 到底是什么
GCJ02 俗称「火星坐标」。国家出于安全考虑,要求国内地图厂商对 WGS84 坐标做加密偏移后才能展示。这个偏移有几个特点:
- 非线性:偏移量和经纬度相关,不是固定值
- 全国不统一:A 点偏 300 米,B 点可能偏 480 米
- 没有官方逆公式:民间算法是逆向拟合的,精度约 1~2 米,业务够用
- 只在国内生效:国外坐标不做偏移
这就是为什么「不能简单相减」——A 点的偏移量套到 B 点上不对。所以从 GCJ02 反推 WGS84,通常用迭代法:先猜一个值,正向算偏移,反推差值,循环两三次收敛。
三大坐标系的转换链路
WGS84 ⇄ GCJ02 ⇄ BD09
天地图 高德 百度
核心算法是 2009 年左右民间逆向拟合出来的,下面是 JavaScript 版本的完整实现。理解原理时读一遍,生产环境建议直接用 coordtransform 或 gcoord 这类成熟库,别自己手写。
const PI = 3.1415926535897932384626;
const a = 6378245.0; // 克拉索夫斯基椭球长半轴
const ee = 0.00669342162296594323; // 偏心率平方
/**
* 纬度方向偏移量计算(非线性加密核心)
*/
function transformLat(x, y) {
let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y
+ 0.2 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(y * PI) + 40.0 * Math.sin(y / 3.0 * PI)) * 2.0 / 3.0;
ret += (160.0 * Math.sin(y / 12.0 * PI) + 320 * Math.sin(y * PI / 30.0)) * 2.0 / 3.0;
return ret;
}
/**
* 经度方向偏移量计算(非线性加密核心)
*/
function transformLon(x, y) {
let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y
+ 0.1 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(x * PI) + 40.0 * Math.sin(x / 3.0 * PI)) * 2.0 / 3.0;
ret += (150.0 * Math.sin(x / 12.0 * PI) + 300.0 * Math.sin(x / 30.0 * PI)) * 2.0 / 3.0;
return ret;
}
/**
* 判断坐标是否国内(国内才需要加密偏移,国外直接返回原值)
*/
function outOfChina(lng, lat) {
return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271;
}
/**
* WGS84 -> GCJ02(GPS 原始坐标 → 高德/腾讯坐标)
*/
function wgs84ToGcj02(lng, lat) {
if (outOfChina(lng, lat)) return [lng, lat]; // 国外不加密
let dLat = transformLat(lng - 105.0, lat - 35.0);
let dLon = transformLon(lng - 105.0, lat - 35.0);
const radLat = (lat / 180.0) * PI;
let magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / (((a * (1 - ee)) / (magic * sqrtMagic)) * PI);
dLon = (dLon * 180.0) / ((a / sqrtMagic) * Math.cos(radLat) * PI);
return [lng + dLon, lat + dLat];
}
/**
* GCJ02 -> WGS84(迭代反解,因为加密非线性)
*/
function gcj02ToWgs84(lng, lat) {
if (outOfChina(lng, lat)) return [lng, lat];
// 初始猜测 = 当前值,用正向函数算差值反推,循环 2~3 次收敛
let wgsLng = lng, wgsLat = lat;
for (let i = 0; i < 3; i++) {
const [gLng, gLat] = wgs84ToGcj02(wgsLng, wgsLat);
wgsLng += lng - gLng;
wgsLat += lat - gLat;
}
return [wgsLng, wgsLat];
}
/**
* GCJ02 -> BD09(高德 → 百度,百度在 GCJ02 上再加密一次)
*/
function gcj02ToBd09(lng, lat) {
const z = Math.sqrt(lng * lng + lat * lat) + 0.00002 * Math.sin(lat * PI * 3000.0 / 180.0);
const theta = Math.atan2(lat, lng) + 0.000003 * Math.cos(lng * PI * 3000.0 / 180.0);
return [z * Math.cos(theta) + 0.0065, z * Math.sin(theta) + 0.006];
}
/**
* BD09 -> GCJ02(百度 → 高德)
*/
function bd09ToGcj02(lng, lat) {
const x = lng - 0.0065, y = lat - 0.006;
const z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * PI * 3000.0 / 180.0);
const theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * PI * 3000.0 / 180.0);
return [z * Math.cos(theta), z * Math.sin(theta)];
}
跑一下看看同一地点三个坐标系的数值差异:
const wgs = [114.167, 22.458]; // WGS84(天地图/GPS)
const gcj = wgs84ToGcj02(wgs[0], wgs[1]); // GCJ02(高德/腾讯)
const bd = gcj02ToBd09(gcj[0], gcj[1]); // BD09(百度)
console.log('WGS84:', wgs); // [114.167, 22.458]
console.log('GCJ02:', gcj); // [114.172..., 22.456...] 偏移约 480 米
console.log('BD09 :', bd); // [114.179..., 22.462...] 偏移约 560 米
// 三个数值指向同一地点,但数值不同——混用就偏
实际开发中怎么落地
生产环境别手写,用成熟库
npm i gcoord # 或 coordtransform
import gcoord from 'gcoord';
// 业务数据统一以 WGS84 存储,渲染时按当前底图转换
const [lng, lat] = gcoord.transform(
[114.167, 22.458], // WGS84 原始坐标
gcoord.WGS84, // 源坐标系
gcoord.GCJ02 // 目标坐标系(当前底图是高德)
);
同一坐标画到不同底图的效果对比
const wgsLng = 114.167, wgsLat = 22.458;
// ① 画到天地图底图(WGS84)—— 不用转换,准确
L.marker([wgsLat, wgsLng]).addTo(map);
// ② 直接画到高德底图(GCJ02)—— 偏移几百米,点跑到马路外(错误用法)
L.marker([wgsLat, wgsLng]).addTo(amap);
// ③ WGS84 → GCJ02 转换后再画到高德 —— 准确
const [gcjLng, gcjLat] = wgs84ToGcj02(wgsLng, wgsLat);
L.marker([gcjLat, gcjLng]).addTo(amap);
团队约定(强烈建议统一)
- 统一以 WGS84 为存储基准,渲染时按底图转换,不要存多种坐标系
- 转换函数收口到一个
utils/coord-transform.ts,全项目共用,禁止各处自己写 - 转换只做一次,不要既存转换后坐标又再转一次,累积误差
- 给每条坐标数据带上
coordinateSystemType标记,换底图时能识别它原本是什么系 - 国外坐标跳过 GCJ02 转换(
outOfChina判断),硬转会引入误差
我踩过的几个坑
坑 1:只转了点没转线/面
管线是 LineString,要逐顶点转换,不能只转首尾,否则线会变形。
// ❌ 错误:只转首尾,中间顶点没转,线变形
const line = [[114.167, 22.458], [114.180, 22.470], [114.200, 22.480]];
// ✅ 正确:每个顶点都要转
const convertedLine = line.map(([lng, lat]) => wgs84ToGcj02(lng, lat));
// 面(Polygon)同理,外环 + 内环每个顶点都要转
坑 2:投影坐标系用错
4326 的坐标直接喂给 3857 图层,整图飞到海里。这种错误不是偏几百米,是整张图完全错位。
// 错误:把经纬度(4326,单位"度")当成 3857(单位"米")塞给地图
// 3857 的米坐标本来应该是 12700000, 2560000 这种量级
// 直接把 114, 22 当米画,点飞到几万公里外
坑 3:缓存了转换后的坐标
底图换了但数据没重转,出现历史脏数据。正确做法是统一存原始坐标系,渲染时按底图现转。
坑 4:拾取坐标的坐标系没认准
这是最隐蔽的坑——你去各家地图控制台拾取坐标,拿到的是不同坐标系:
- 高德控制台拾取的是 GCJ02
- 天地图拾取的是 WGS84
- 百度拾取的是 BD09
拿高德拾取的坐标直接画到天地图上,必偏。拾取时一定先搞清来源坐标系。
坑 5:CGCS2000 当 WGS84 用
两者差异在厘米级,绝大多数业务场景直接当 WGS84 用没事。但高精度场景(测绘、工程放样)会暴露,需要按需严格转换。
排查口诀
最后给一个排查坐标问题的顺序,遇到偏移照着走:
先查投影(飞图 / 错位)→ 再查坐标系(百米级偏移)→ 最后查数据本身(脏坐标)
- 整张图飞掉、完全错位 → 投影问题
- 偏几百米、点跑到隔壁马路 → 坐标系问题
- 个别点位置不对、数据时好时坏 → 数据本身脏
速查表
收藏带走,下次遇到直接查表。
坐标系速查:
| 你的坐标是… | 画到…底图 | 要转吗 |
|---|---|---|
| WGS84(GPS 原始) | 天地图 | ❌ 不用 |
| WGS84 | 高德 / 腾讯 | ✅ 转 GCJ02 |
| WGS84 | 百度 | ✅ 转 BD09 |
| GCJ02(高德拾取) | 天地图 | ✅ 转 WGS84 |
| GCJ02 | 百度 | ✅ 转 BD09 |
| BD09(百度拾取) | 高德 | ✅ 转 GCJ02 |
投影速查:
| 场景 | 用什么 |
|---|---|
| 存坐标、传坐标 | EPSG:4326(经纬度) |
| Web 地图瓦片渲染 | EPSG:3857(Web 墨卡托,米) |
| 厂商瓦片服务交互 | 按服务文档指定 |
记忆口诀:
WGS84 是「真值」,GCJ02 是「加了火星偏移」,BD09 是「火星偏移再加百度偏移」,国外不加密。
写在最后
坐标转换不复杂,但它是个「不知道的时候踩坑、知道了之后受益很久」的知识点。核心就三句话:
- 搞清坐标来源是什么系(高德拾取是 GCJ02,GPS 是 WGS84,百度是 BD09)
- 搞清目标底图是什么系
- 统一用 WGS84 存储,渲染时按底图转
做到这三点,地图业务里 90% 的坐标偏移问题都能避免。





本文算法为民间逆向拟合,精度约 1~2 米,满足绝大多数业务场景。如需高精度测绘,请使用官方转换服务。
浙公网安备 33010602011771号