写给前端的地图坐标转换指南:为什么同一个点,天地图和高德给你的数不一样

写给前端的地图坐标转换指南:为什么同一个点,天地图和高德给你的数不一样

第一次做地图业务的人,几乎都会踩同一个坑:后端给的坐标,画到地图上偏了几百米,点跑到马路外面。你以为是数据错了,其实是坐标系没搞对。这篇文章把坐标转换这件事彻底讲清楚。

一个真实的翻车现场

假设你接到一个需求:把 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 坐标做加密偏移后才能展示。这个偏移有几个特点:

  1. 非线性:偏移量和经纬度相关,不是固定值
  2. 全国不统一:A 点偏 300 米,B 点可能偏 480 米
  3. 没有官方逆公式:民间算法是逆向拟合的,精度约 1~2 米,业务够用
  4. 只在国内生效:国外坐标不做偏移

这就是为什么「不能简单相减」——A 点的偏移量套到 B 点上不对。所以从 GCJ02 反推 WGS84,通常用迭代法:先猜一个值,正向算偏移,反推差值,循环两三次收敛。

三大坐标系的转换链路

WGS84  ⇄  GCJ02  ⇄  BD09
 天地图     高德      百度

核心算法是 2009 年左右民间逆向拟合出来的,下面是 JavaScript 版本的完整实现。理解原理时读一遍,生产环境建议直接用 coordtransformgcoord 这类成熟库,别自己手写。

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);

团队约定(强烈建议统一)

  1. 统一以 WGS84 为存储基准,渲染时按底图转换,不要存多种坐标系
  2. 转换函数收口到一个 utils/coord-transform.ts,全项目共用,禁止各处自己写
  3. 转换只做一次,不要既存转换后坐标又再转一次,累积误差
  4. 给每条坐标数据带上 coordinateSystemType 标记,换底图时能识别它原本是什么系
  5. 国外坐标跳过 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 是「火星偏移再加百度偏移」,国外不加密。

写在最后

坐标转换不复杂,但它是个「不知道的时候踩坑、知道了之后受益很久」的知识点。核心就三句话:

  1. 搞清坐标来源是什么系(高德拾取是 GCJ02,GPS 是 WGS84,百度是 BD09)
  2. 搞清目标底图是什么系
  3. 统一用 WGS84 存储,渲染时按底图转

做到这三点,地图业务里 90% 的坐标偏移问题都能避免。


image
image
image
image
image

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

posted on 2026-08-12 13:57  中文还在写码  阅读(38)  评论(0)    收藏  举报