第08章 坐标换算算法详解

8.1 坐标系统概述

map-360-demo 涉及两套坐标系统:

  1. 全景球形坐标:yaw(偏航角,水平方向)和 pitch(俯仰角,垂直方向),单位弧度
  2. GPS 经纬度坐标:经度(lng)和纬度(lat),单位度

实现全景与地图联动,核心就是在这两套坐标之间进行换算。换算逻辑封装在 src/utils/geo.ts(共 94 行)。

8.2 辅助函数

// geo.ts:3-9
function round6(n: number): number {
  return Math.round(n * 1e6) / 1e6  // 6 位小数
}

function clamp(n: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, n))
}
  • round6:保留 6 位小数,用于坐标精度控制
  • clamp:数值钳制

8.3 方位角解析

// geo.ts:12-15
function parseBearing(bearing: string): number {
  return parseFloat(bearing)  // 解析 '120deg' 为 120
}

场景的 bearing 字段是字符串形式(如 '120deg'),需要解析为角度数。

8.4 米/度换算

// geo.ts:18-23
const M_PER_DEG_LAT = 110574  // 每度纬度米数

function mPerDegLng(lat: number): number {
  return 111320 * Math.cos(lat * Math.PI / 180)  // 每度经度米数随纬度收缩
}

关键概念

  • 每度纬度对应的地面距离基本恒定(约 110574 米)
  • 每度经度对应的地面距离随纬度收缩(111320 * cos(lat)

重要:经度/纬度每度地面距离不同(相差 cos(纬度) 因子),必须先在”米”空间按方位角分解再换算回度数,否则标记落在地图上的实际方向会偏转(中纬度可达 10°)。

8.5 距离与方位角计算

8.5.1 两点距离

// geo.ts:26-30
function distanceMeters(a: [number, number], b: [number, number]): number {
  const [lng1, lat1] = a
  const [lng2, lat2] = b
  const east = (lng2 - lng1) * mPerDegLng(lat1)
  const north = (lat2 - lat1) * M_PER_DEG_LAT
  return Math.hypot(east, north)  // 平面近似
}

将经纬度差换算为米,再用 Math.hypot 计算平面距离。

8.5.2 方位角

// geo.ts:33-37
function bearingBetween(a: [number, number], b: [number, number]): number {
  const [lng1, lat1] = a
  const [lng2, lat2] = b
  const east = (lng2 - lng1) * mPerDegLng(lat1)
  const north = (lat2 - lat1) * M_PER_DEG_LAT
  return (Math.atan2(east, north) * 180) / Math.PI  // 顺时针自北
}

a→b 地理方位角(度,顺时针自北),atan2(east, north)

8.5.3 目标点

// geo.ts:40-51
function destination(a: [number, number], bearingDeg: number, distM: number): [number, number] {
  const [lng, lat] = a
  const bearingRad = (bearingDeg * Math.PI) / 180
  const east = distM * Math.sin(bearingRad)
  const north = distM * Math.cos(bearingRad)
  return [
    round6(lng + east / mPerDegLng(lat)),
    round6(lat + north / M_PER_DEG_LAT),
  ]
}

从起点沿方位角移动指定距离后的目标点,先分解到米空间再换算回度数。

8.6 全景 yaw 与地理方位角

// geo.ts:57-59
function geoBearingOf(scene: Scene, yaw: number): number {
  return parseBearing(scene.bearing) + (yaw * 180) / Math.PI
}

全景 yaw 对应的地理方位角 = scene.bearing + yaw(与 PlanPlugin 镜头指向公式一致)。

8.7 全景点击 → 经纬度(estimateGps)

// geo.ts:70-79
export function estimateGps(scene: Scene, yaw: number, pitch: number): [number, number] {
  const bearingRad = (geoBearingOf(scene, yaw) * Math.PI) / 180
  const distM = clamp(450 + pitch * 800, 120, 1100)  // 典型 pitch -0.1~0.32 → 370m~710m
  const [lng, lat] = scene.coordinates
  return [
    round6(lng + (distM * Math.sin(bearingRad)) / mPerDegLng(lat)),
    round6(lat + (distM * Math.cos(bearingRad)) / M_PER_DEG_LAT),
  ]
}

算法流程

  1. 计算 yaw 对应的地理方位角
  2. 根据 pitch 估算距离(450 + pitch * 800,钳制到 [120, 1100] 米)
  3. 在米空间按方位角分解距离
  4. 换算回经纬度

距离估算:典型 pitch -0.1~0.32 → 370m~710m,模拟了”点击位置离拍摄点越远,pitch 越小”的透视关系。

8.8 地图坐标 → 全景 yaw(yawFromGps)

// geo.ts:85-94
export function yawFromGps(scene: Scene, coords: [number, number]): number {
  const [lng, lat] = scene.coordinates
  const east = (coords[0] - lng) * mPerDegLng(lat)
  const north = (coords[1] - lat) * M_PER_DEG_LAT
  const angleDeg = (Math.atan2(east, north) * 180) / Math.PI
  const yawDeg = (angleDeg - parseBearing(scene.bearing) + 360) % 360
  const yawRad = (yawDeg * Math.PI) / 180
  return yawRad > Math.PI ? yawRad - 2 * Math.PI : yawRad  // 归一到 (-π, π]
}

算法流程

  1. 换算米求真实方位角
  2. 扣除场景 bearing
  3. 归一到 PSV 的 yaw 习惯范围 (-π, π]

8.9 换算关系总结

全景球形坐标 (yaw, pitch)
    │
    ├── estimateGps(scene, yaw, pitch) ──→ GPS 经纬度
    │
    └── yawFromGps(scene, coords) ──→ 全景 yaw
方向 函数 关键逻辑
全景 → 地图 estimateGps 方位角 + 距离估算 + 米空间分解
地图 → 全景 yawFromGps 米空间方位角 - 场景 bearing + 归一化

核心要点

  1. 米空间分解:经度/纬度每度地面距离不同,必须先在米空间分解
  2. 场景 bearing 偏移:全景 yaw 与地理方位角相差场景的 bearing
  3. 归一化:yaw 归一到 PSV 习惯的 (-π, π] 范围

8.10 下一步

理解了坐标换算后,进入 第09章 样式与交互细节 了解样式与交互的实现细节。