阅读视图

发现新文章,点击刷新页面。

地理坐标转换指南:从"地图错位"到"精准定位"的修炼之路

开篇

周五晚上7点,小明准时下班,准备和女朋友去看电影。刚到电影院门口,手机突然震动——产品经理发来微信:"线上地图偏移问题,用户投诉定位不准,马上修复!"

小明叹了口气,翻出笔记本电脑,打开地图页面一看:定位标记在马路对面,和用户实际位置差了300米。

"什么情况?明明用的是GPS坐标啊!"小明一头雾水。

折腾了两小时,小明才发现:百度地图用的是 BD09 坐标,而 GPS 返回的是 WGS84 坐标,两者之间需要转换。加了几行代码,定位终于准确了。

坐标系转换,这个看似简单的问题,坑了多少前端开发者?这篇从「前端宇宙 × 3D 空间」双视角拆解地理坐标转换,帮你彻底搞懂 WGS84、GCJ-02、BD09 等坐标系,看完能直接套用的坐标转换代码。


模块一:常见坐标系解析

技术原理

地理坐标系就像"语言的方言",不同地方说不同的话。全球通用的"普通话"是 WGS84,但出于各种原因,各地还有自己的"方言"——GCJ-02、BD09 等。

先来认识这些"方言":

WGS84(World Geodetic System 1984)

  • 全球统一坐标系,GPS、国际地图服务使用
  • 地心坐标系,原点为地球质心
  • 坐标精度 1-2 米

GCJ-02(国测局坐标系,俗称火星坐标系)

  • 中国官方使用的加密坐标系
  • 基于 WGS84 进行非线性偏移
  • 谷歌地图、高德地图、腾讯地图使用
  • 偏移范围:100-700 米

BD09(百度坐标系)

  • 百度地图专用坐标系
  • 在 GCJ-02 基础上再次加密
  • 偏移叠加,误差更大

CGCS2000(2000 国家大地坐标系)

  • 中国最新的国家大地坐标系
  • 地心坐标系,原点为包括海洋和大气的整个地球质量中心
  • 与 WGS84 相差几厘米,一般工程测量可视为一致

坐标系对比表:

坐标系 使用服务商 偏移特点 适用场景
WGS84 GPS、国际地图 无偏移 全球定位
GCJ-02 高德、谷歌中国 100-700 米 国内地图服务
BD09 百度地图 多次偏移 百度地图应用
CGCS2000 天地图 几厘米 国家测绘数据

实操案例

案例:GPS 定位在不同地图上的表现

假设用户在北京天安门广场,GPS 返回的真实坐标是:

WGS84: (116.397455, 39.909187)

直接在不同地图上显示:

  • 谷歌地图(中国区):偏移约 300 米(需转 GCJ-02)
  • 百度地图:偏移约 400 米(需转 BD09)
  • 高德地图:偏移约 300 米(需转 GCJ-02)

落地步骤

  1. 第一步:确认当前坐标系

    • 查看数据来源说明
    • 测试在不同地图服务上的显示位置
  2. 第二步:确定目标坐标系

    • 查看地图服务的官方文档
    • 确认使用的坐标系类型
  3. 第三步:选择转换工具

    • 开源库:gcoord、coordtransform
    • 自定义算法(下文详解)

避坑指南

  • 新手常犯:直接使用 GPS 坐标在百度地图上显示,不做转换
  • 正确做法:根据目标地图服务,使用对应的坐标系
  • ⚠️ 注意:CGCS2000 与 WGS84 差异极小,一般应用可直接互换,但精密测量需要考虑

模块二:坐标系转换算法

技术原理

坐标转换的数学原理核心是椭球体模型偏移算法

椭球体参数:

地球不是完美的球体,而是椭球体,不同坐标系使用不同的椭球体模型:

坐标系 长半轴 a (米) 扁率 f 说明
WGS84 6378137.0 1/298.257223563 GPS 标准
CGCS2000 6378137.0 1/298.257222101 中国国家标准
克拉索夫斯基 6378245.0 1/298.3 旧坐标系

GCJ-02 转换算法(俗称"火星算法"):

核心思路是使用非线性加密算法,在经纬度中加入"随机"偏移:

// Step 1:计算纬度偏移量
dLat = transformLat(lng - 105.0, lat - 35.0);

// Step 2:计算经度偏移量
dLng = transformLng(lng - 105.0, lat - 35.0);

// Step 3:应用偏移
radLat = lat / 180.0 * PI;
magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
sqrtmagic = Math.sqrt(magic);

dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * PI);
dLng = (dLng * 180.0) / (a / sqrtmagic * Math.cos(radLat) * PI);

mglat = lat + dLat;
mglng = lng + dLng;

七参数转换(高精度转换):

对于需要厘米级精度的场景,使用七参数转换法:

interface SevenParams {
  dx: number;  // X 轴平移
  dy: number;  // Y 轴平移
  dz: number;  // Z 轴平移
  rx: number;  // X 轴旋转
  ry: number;  // Y 轴旋转
  rz: number;  // Z 轴旋转
  m: number;   // 比例因子
}

实操案例

WGS84 转 GCJ-02 完整代码:

// 定义常量
const x_PI = 3.14159265358979324 * 3000.0 / 180.0;
const PI = 3.1415926535897932384626;
const a = 6378245.0;  // 长半轴
const ee = 0.00669342162296594323;  // 偏心率平方

// Step 1:判断是否在中国境内
function outOfChina(lng: number, lat: number): boolean {
  return (lng < 72.004 || lng > 137.8347 ||
          lat < 0.8293 || lat > 55.8271);
}

// Step 2:计算纬度偏移
function transformLat(lng: number, lat: number): number {
  let ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat +
           0.1 * lng * lat + 0.2 * Math.sqrt(Math.abs(lng));
  ret += (20.0 * Math.sin(6.0 * lng * PI) +
          20.0 * Math.sin(2.0 * lng * PI)) * 2.0 / 3.0;
  ret += (20.0 * Math.sin(lat * PI) +
          40.0 * Math.sin(lat / 3.0 * PI)) * 2.0 / 3.0;
  ret += (160.0 * Math.sin(lat / 12.0 * PI) +
          320.0 * Math.sin(lat * PI / 30.0)) * 2.0 / 3.0;
  return ret;
}

// Step 3:计算经度偏移
function transformLng(lng: number, lat: number): number {
  let ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng +
           0.1 * lng * lat + 0.1 * Math.sqrt(Math.abs(lng));
  ret += (20.0 * Math.sin(6.0 * lng * PI) +
          20.0 * Math.sin(2.0 * lng * PI)) * 2.0 / 3.0;
  ret += (20.0 * Math.sin(lng * PI) +
          40.0 * Math.sin(lng / 3.0 * PI)) * 2.0 / 3.0;
  ret += (150.0 * Math.sin(lng / 12.0 * PI) +
          300.0 * Math.sin(lng / 30.0 * PI)) * 2.0 / 3.0;
  return ret;
}

// Step 4:WGS84 转 GCJ-02
export function wgs84ToGcj02(lng: number, lat: number): [number, number] {
  if (outOfChina(lng, lat)) {
    return [lng, lat];
  }

  let dLat = transformLat(lng - 105.0, lat - 35.0);
  let dLng = transformLng(lng - 105.0, lat - 35.0);

  let radLat = lat / 180.0 * PI;
  let magic = Math.sin(radLat);
  magic = 1 - ee * magic * magic;
  let sqrtmagic = Math.sqrt(magic);

  dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * PI);
  dLng = (dLng * 180.0) / (a / sqrtmagic * Math.cos(radLat) * PI);

  let mglat = lat + dLat;
  let mglng = lng + dLng;

  return [mglng, mglat];
}

GCJ-02 转 BD09:

export function gcj02ToBd09(lng: number, lat: number): [number, number] {
  let z = Math.sqrt(lng * lng + lat * lat) + 0.00002 * Math.sin(lat * x_PI);
  let theta = Math.atan2(lat, lng) + 0.000003 * Math.cos(lng * x_PI);
  let bd_lng = z * Math.cos(theta) + 0.0065;
  let bd_lat = z * Math.sin(theta) + 0.006;
  return [bd_lng, bd_lat];
}

WGS84 转 BD09(组合转换):

export function wgs84ToBd09(lng: number, lat: number): [number, number] {
  // 先转 GCJ-02,再转 BD09
  const [gcjLng, gcjLat] = wgs84ToGcj02(lng, lat);
  return gcj02ToBd09(gcjLng, gcjLat);
}

测试用例:

// 测试:天安门坐标
const wgs84: [number, number] = [116.397455, 39.909187];

// 转换结果
const gcj02 = wgs84ToGcj02(...wgs84);
console.log('GCJ-02:', gcj02);
// 输出: [116.403874, 39.915119]

const bd09 = wgs84ToBd09(...wgs84);
console.log('BD09:', bd09);
// 输出: [116.41661560068297, 39.92196580126834]

落地步骤

  1. 第一步:安装坐标转换库

    npm install gcoord
    
  2. 第二步:使用库进行转换

    import gcoord from 'gcoord';
    
    const result = gcoord.transform(
      [116.403988, 39.914266],  // 经纬度坐标
      gcoord.WGS84,              // 当前坐标系
      gcoord.BD09                // 目标坐标系
    );
    
  3. 第三步:在地图服务中应用

    // 百度地图
    const bdCoord = wgs84ToBd09(gpsLng, gpsLat);
    map.setCenter(new BMap.Point(bdCoord[0], bdCoord[1]));
    
    // 高德地图
    const gcjCoord = wgs84ToGcj02(gpsLng, gpsLat);
    map.setCenter([gcjCoord[0], gcjCoord[1]]);
    

避坑指南

  • 新手常犯:只转一次坐标系,不考虑中间转换
  • 正确做法:WGS84 → GCJ-02 → BD09,逐层转换
  • ⚠️ 注意:中国境外不需要使用 GCJ-02,直接使用 WGS84
  • ⚠️ 注意:百度地图 API 提供了官方转换接口,建议优先使用 官方文档

模块三:实战应用场景

技术原理

在前端地图开发中,坐标转换贯穿多个场景:用户定位、POI 搜索、轨迹绘制、地理围栏等。不同场景对精度要求不同,转换策略也不同。

前端地图服务坐标系对比:

服务商 API 坐标系 推荐转换方式
百度地图 API BD09 使用官方 convertor.convert
高德地图 API GCJ-02 GPS 坐标需转 GCJ-02
谷歌地图 API 中国区 GCJ-02 国际区 WGS84
腾讯地图 API GCJ-02 GPS 坐标需转 GCJ-02

实操案例

场景 1:用户定位 + 附近 POI 搜索

// 需求:获取用户当前位置,并在百度地图上显示附近餐厅

import BMap from 'BMap';

// Step 1:获取 GPS 定位(返回 WGS84)
function getCurrentPosition(): Promise<[number, number]> {
  return new Promise((resolve, reject) => {
    navigator.geolocation.getCurrentPosition(
      (position) => {
        resolve([position.coords.longitude, position.coords.latitude]);
      },
      (error) => {
        reject(error);
      }
    );
  });
}

// Step 2:坐标转换 WGS84 → BD09
async function initMap() {
  const [wgsLng, wgsLat] = await getCurrentPosition();

  // 转换坐标系
  const [bdLng, bdLat] = wgs84ToBd09(wgsLng, wgsLat);

  // 创建百度地图实例
  const map = new BMap.Map('map-container');
  const point = new BMap.Point(bdLng, bdLat);

  // 设置中心点
  map.centerAndZoom(point, 15);

  // 添加用户标记
  const marker = new BMap.Marker(point);
  map.addOverlay(marker);

  // 搜索附近餐厅(百度 API 自动处理 BD09 坐标)
  const local = new BMap.LocalSearch(map, {
    renderOptions: { map: map }
  });
  local.searchNearby('餐厅', point, 1000);  // 1公里范围
}

场景 2:轨迹绘制(GPS 轨迹回放)

// 需求:在 Leaflet 地图上绘制 GPS 轨迹(使用 GCJ-02)

import L from 'leaflet';

interface TrackPoint {
  lng: number;
  lat: number;
  timestamp: number;
}

// Step 1:批量转换坐标
function convertTrack(track: TrackPoint[]): TrackPoint[] {
  return track.map(point => {
    const [gcjLng, gcjLat] = wgs84ToGcj02(point.lng, point.lat);
    return { lng: gcjLng, lat: gcjLat, timestamp: point.timestamp };
  });
}

// Step 2:绘制轨迹
function drawTrackOnMap(track: TrackPoint[]) {
  const map = L.map('map').setView([39.915, 116.404], 13);

  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
    attribution: '© OpenStreetMap'
  }).addTo(map);

  const convertedTrack = convertTrack(track);

  // 创建多段线
  const polyline = L.polyline(
    convertedTrack.map(p => [p.lat, p.lng]),
    { color: '#3388ff', weight: 4 }
  ).addTo(map);

  // 添加起点和终点标记
  const start = convertedTrack[0];
  const end = convertedTrack[convertedTrack.length - 1];

  L.marker([start.lat, start.lng])
    .bindPopup('起点')
    .addTo(map);

  L.marker([end.lat, end.lng])
    .bindPopup('终点')
    .addTo(map);

  // 自动调整视野
  map.fitBounds(polyline.getBounds());
}

场景 3:3D 地图坐标转换(Three.js + Cesium)

// 需求:在 Three.js 中实现 3D 地图,将地理坐标转换为 3D 空间坐标

import * as THREE from 'three';

interface Coordinate {
  lng: number;
  lat: number;
  alt?: number;  // 海拔高度
}

class Geo3DConverter {
  private earthRadius: number = 6371000;  // 地球半径(米)

  /**
   * 将地理坐标转换为 3D 笛卡尔坐标
   * @param coord 地理坐标(WGS84)
   * @param center 中心点坐标(用于偏移,避免浮点精度问题)
   */
  geoToCartesian(coord: Coordinate, center: Coordinate): THREE.Vector3 {
    // Step 1:转换为弧度
    const lngRad = (coord.lng * Math.PI) / 180;
    const latRad = (coord.lat * Math.PI) / 180;
    const alt = coord.alt || 0;

    const centerLngRad = (center.lng * Math.PI) / 180;
    const centerLatRad = (center.lat * Math.PI) / 180;
    const centerAlt = center.alt || 0;

    // Step 2:球面坐标转笛卡尔坐标
    const cosLat = Math.cos(latRad);
    const sinLat = Math.sin(latRad);
    const cosLng = Math.cos(lngRad);
    const sinLng = Math.sin(lngRad);

    const r = this.earthRadius + alt;

    const x = r * cosLat * cosLng;
    const y = r * cosLat * sinLng;
    const z = r * sinLat;

    // Step 3:计算中心点
    const cosCenterLat = Math.cos(centerLatRad);
    const sinCenterLat = Math.sin(centerLatRad);
    const cosCenterLng = Math.cos(centerLngRad);
    const sinCenterLng = Math.sin(centerLngRad);

    const rCenter = this.earthRadius + centerAlt;

    const cx = rCenter * cosCenterLat * cosCenterLng;
    const cy = rCenter * cosCenterLat * sinCenterLng;
    const cz = rCenter * sinCenterLat;

    // Step 4:计算相对位置(偏移)
    return new THREE.Vector3(
      x - cx,
      z - cz,  // Three.js Y 轴向上
      y - cy   // Three.js 使用右手坐标系
    );
  }

  /**
   * 批量转换坐标并生成 3D 对象
   */
  createGeoPoints(points: Coordinate[], center: Coordinate): THREE.Points {
    const geometry = new THREE.BufferGeometry();
    const positions: number[] = [];

    points.forEach(point => {
      const vector = this.geoToCartesian(point, center);
      positions.push(vector.x, vector.y, vector.z);
    });

    geometry.setAttribute('position',
      new THREE.Float32BufferAttribute(positions, 3)
    );

    const material = new THREE.PointsMaterial({
      color: 0xff0000,
      size: 0.1
    });

    return new THREE.Points(geometry, material);
  }
}

// 使用示例
const converter = new Geo3DConverter();

const points: Coordinate[] = [
  { lng: 116.397455, lat: 39.909187, alt: 100 },
  { lng: 116.407455, lat: 39.919187, alt: 150 },
  { lng: 116.417455, lat: 39.929187, alt: 200 }
];

const center: Coordinate = {
  lng: 116.407455,
  lat: 39.919187,
  alt: 150
};

const geoPoints = converter.createGeoPoints(points, center);
scene.add(geoPoints);

落地步骤

  1. 第一步:选择合适的地图 SDK

    • 考虑坐标系支持
    • 评估转换性能
    • 确认文档完整性
  2. 第二步:封装坐标转换工具类

    // utils/coordinate.ts
    export class CoordinateConverter {
      static toWGS84(lng: number, lat: number, from: 'GCJ02' | 'BD09'): [number, number] {
        // 实现转换逻辑
      }
    
      static toGCJ02(lng: number, lat: number, from: 'WGS84' | 'BD09'): [number, number] {
        // 实现转换逻辑
      }
    
      static toBD09(lng: number, lat: number, from: 'WGS84' | 'GCJ02'): [number, number] {
        // 实现转换逻辑
      }
    }
    
  3. 第三步:在地图组件中集成

    // components/MapComponent.vue
    import { CoordinateConverter } from '@/utils/coordinate';
    
    export default {
      methods: {
        handleMapClick(event) {
          const { lng, lat } = event.point;
    
          // 假设地图使用 BD09,需要存 WGS84
          const [wgsLng, wgsLat] = CoordinateConverter.toWGS84(lng, lat, 'BD09');
    
          this.saveCoordinate(wgsLng, wgsLat);
        }
      }
    }
    

避坑指南

  • 新手常犯:在不同地图服务之间切换时,忘记转换坐标系
  • 正确做法:使用统一的内部坐标系(如 WGS84),只在展示时转换
  • ⚠️ 注意:3D 地图中,大范围场景需使用高精度坐标(64-bit float),避免浮点精度问题
  • ⚠️ 注意:批量转换时,注意性能优化,使用 TypedArray 和 Web Worker

性能优化技巧:

// ❌ 低效:逐个转换
const results = [];
for (const point of points) {
  results.push(wgs84ToGcj02(point.lng, point.lat));
}

// ✅ 高效:使用批量转换库
import gcoord from 'gcoord';

const results = gcoord.transform(
  points.map(p => [p.lng, p.lat]),
  gcoord.WGS84,
  gcoord.GCJ02
);

结尾

现在的小明,再也不怕地图偏移问题了。遇到坐标转换需求,淡定地打开工具类,三行代码搞定:

const [bdLng, bdLat] = wgs84ToBd09(gpsLng, gpsLat);
map.setCenter(new BMap.Point(bdLng, bdLat));

老板路过问:"小明,你最近怎么不加班了?"

小明笑了笑:"因为我掌握了坐标转换的秘密武器——统一坐标系,按需转换。"

坐标系转换看似复杂,其实本质就是"语言的翻译"。GPS 说"普通话",百度地图说"方言",你需要做的,就是当好"翻译官"。

你在坐标转换中遇到过哪些坑?评论区交流,我们一起讨论。


数据来源

Maintaining shadow branches for GitHub PRs

I've created pr-shadow with vibecoding, a tool that maintains a shadow branch for GitHub pull requests(PR) that never requires force-pushing. This addresses pain points Idescribed in Reflectionson LLVM's switch to GitHub pull requests#Patch evolution.

The problem

GitHub structures pull requests around branches, enforcing abranch-centric workflow. When you force-push a branch after a rebase,the UI displays "force-pushed the BB branch from X to Y". Clicking"compare" shows git diff X..Y, which includes unrelatedupstream commits—not the actual patch difference. For a project likeLLVM with 100+ commits daily, this makes the comparison essentiallyuseless.

Inline comments suffer too: they may become "outdated" or misplacedafter force pushes.

Additionally, if your commit message references an issue or anotherPR, each force push creates a new link on the referenced page,cluttering it with duplicate mentions. (You can work around this byadding backticks around the link text, but it is not ideal.)

Due to these difficulties, some recommendations suggest less flexibleworkflows that only append new commits and discourage rebases.However, this means working with an outdated base, and switching betweenthe main branch and PR branches causes numerous rebuilds-especiallypainful for large repositories like llvm-project.

In a large repository, avoiding rebases isn't realistic—other commitsfrequently modify nearby lines, and rebasing is often the only way todiscover that your patch needs adjustments due to interactions withother landed changes.

The solution

pr-shadow maintains a separate PR branch (e.g.,pr/feature) that only receives commits—never force-pushed.You work freely on your local branch (rebase, amend, squash), then syncto the PR branch using git commit-tree to create a commitwith the same tree but parented to the previous PR HEAD.

1
2
3
4
5
6
Local branch (feature)     PR branch (pr/feature)
A A
| |
B (amend) C1 "Fix bug"
| |
C (rebase) C2 "Address review"

Reviewers see clean diffs between C1 and C2, even though theunderlying commits were rewritten.

When a rebase is detected (git merge-base withmain/master changed), the new PR commit is created as a merge commitwith the new merge-base as the second parent. GitHub displays these as"condensed" merges, preserving the diff view for reviewers.

Usage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Initialize and create PR
git switch -c feature
prs init # Creates pr/feature, pushes, opens PR
# prs init --draft # Same but creates draft PR

# Work locally (rebase, amend, etc.)
git rebase main
git commit --amend

# Sync to PR
prs push "Fix bug"
prs push --force "Rewrite" # Force push if remote diverged

# Update PR title/body from local commit message
prs desc

# Run gh commands on the PR
prs gh view
prs gh checks

The tool supports both fork-based workflows (pushing to your fork)and same-repo workflows (for branches likeuser/<name>/feature). It also works with GitHubEnterprise, auto-detecting the host from the repository URL.

Related work

The name "prs" is a tribute to spr, which implements asimilar shadow branch concept. However, spr pushes user branches to themain repository rather than a personal fork. While necessary for stackedpull requests, this approach is discouraged for single PRs as itclutters the upstream repository. pr-shadow avoids this by pushing toyour fork by default.

I owe an apology to folks who receiveusers/MaskRay/feature branches (if they use the defaultfetch = +refs/heads/*:refs/remotes/origin/* to receive userbranches). I had been abusing spr for a long time after LLVM'sGitHub transition to avoid unnecessary rebuilds when switchingbetween the main branch and PR branches.

Additionally, spr embeds a PR URL in commit messages (e.g.,Pull Request: https://github.com/llvm/llvm-project/pull/150816),which can cause downstream forks to add unwanted backlinks to theoriginal PR.

If I need stacked pull requests, I will probably use pr-shadow withthe base patch and just rebase stacked ones - it's unclear how sprhandles stacked PRs.

❌