dotted-map 连线弧偏移:默认投影是椭球墨卡托不是等经纬
做一个「世界地图 + 城市间飞弧动画」组件,点阵背景用 dotted-map 渲染,飞弧用 SVG 叠加。布局完成,视觉效果很酷——但仔细看,每一条弧的端点都和底图上的城市点微微错开,纬度越高偏得越厉害。
我第一反应是"坐标系搞反了",把 lat/lng 互调了一遍,反而更糟。
现象
- 赤道附近城市基本对齐,误差 1-2 像素
- 北京(纬度 40°N)偏移约 8 像素,向下
- 斯德哥尔摩(纬度 59°N)偏移超过 15 像素
- 越靠近极地,偏移越大、越有规律——不是随机噪声,是系统性南移
这种"越高纬越偏"的特征直接指向投影不匹配:我的弧端坐标用的是某种投影,底图点阵用的是另一种,两者在极地方向拉伸系数不同。
根因
打开 dotted-map 的 dist/index.mjs(README 没有文档化投影细节,必须读源码),找到:
DEFAULT_PROJECTION = { name: "mercator" }
// proj4 string: +proj=merc +lon_0=0 +datum=WGS84 +units=m关键是 +datum=WGS84——这是椭球墨卡托,地球被当成 WGS84 椭球体(第一离心率 e ≈ 0.0818),不是球体。
而我写弧端映射用的是最直觉的等经纬公式:
// 错误:等经纬投影
x = (lng + 180) * W / 360
y = (90 - lat) * H / 180等经纬是"什么投影都不做",经纬度直接当 XY 用。但 dotted-map 的点阵坐标是先做了椭球墨卡托再做仿射变换的——两种坐标系完全不同,高纬度发散。
很多人(包括我)第一次用 dotted-map 覆盖自定义图层时,直觉选等经纬公式 x=(lng+180)*W/360。
这在赤道附近误差小(墨卡托在低纬接近等经纬),但向高纬迅速发散——北纬 40° 已经肉眼可见,60° 以上会让连线完全落在错误的陆地上。
更隐蔽的是:dotted-map 的 README 没有记录任何投影细节,只有 getPin(lat, lng) 这个 API 返回"对齐点阵的坐标",但 getPin 内部做了网格对齐(snapping),不能直接用来画连续弧线。必须读 dist/index.mjs 才能知道真正的投影是 +proj=merc +datum=WGS84。
正解
用 WGS84 椭球墨卡托的正向公式替换弧端坐标计算,同时从 dotted-map 的初始化结果中提取仿射参数(X_MIN, X_RANGE, Y_MAX, Y_RANGE, width, height):
// WGS84 参数
const A = 6378137; // 半长轴(米)
const E = 0.08181919084262149; // 第一离心率
function mercForward(lng, lat) {
const d = Math.PI / 180;
const lr = lat * d;
const x = A * (lng * d);
const es = E * Math.sin(lr);
// 椭球修正项:(1-es)/(1+es) 的 E/2 次方
const y = A * Math.log(
Math.tan(Math.PI / 4 + lr / 2) * Math.pow((1 - es) / (1 + es), E / 2)
);
return [x, y];
}
// 仿射变换,对齐到 dotted-map viewBox
function projectPoint(lat, lng, { X_MIN, X_RANGE, Y_MAX, Y_RANGE, W, H }) {
const [px, py] = mercForward(lng, lat);
return {
x: W * (px - X_MIN) / X_RANGE,
y: H * (Y_MAX - py) / Y_RANGE,
};
}与球面墨卡托公式(y = R·ln(tan(π/4 + φ/2)))的区别只有那一项椭球修正 * Math.pow((1-es)/(1+es), E/2)。漏掉它,低纬误差可忽略,高纬偏移明显。
零运行时依赖的「烘焙」模式
如果只是在构建时用 dotted-map 生成点阵,运行时不想带这个库,可以在 author-time 脚本里一次性跑 getMap(),把点坐标和仿射参数(X_MIN, X_RANGE, Y_MAX, Y_RANGE)都提取出来写成静态数据模块,然后 dotted-map 移入 devDependencies。运行时只引入静态数据 + 上面的 projectPoint 函数,两者完全对齐。
烘焙脚本里,用 proj4 库对同一批经纬度点跑一遍官方椭球墨卡托,再用你复现的 mercForward 跑一遍,断言最大误差 < 1 米(正确实现可达 1e-9 m)。验证失败就 process.exit(1)——绝不静默输出偏移的烘焙数据,否则问题在运行时才暴露,且难以复现。
排查过程记录
读 minified 的 dist/index.mjs 是唯一可靠手段——Prettier 格式化后全文搜 proj,找到 DEFAULT_PROJECTION 和 getPin 实现,一共花了大约 20 分钟,省去了猜测式调参的几小时。
地图类库最容易踩的坑就是"投影假设":库的默认投影是什么、你的覆盖图层用的是什么、两者是否匹配——每次都要显式确认,不能靠直觉。
一句话外卖
在第三方点阵地图上叠加自定义图层,永远先读库源码确认它用的是哪种投影,不要假设等经纬——地图库几乎都默认墨卡托,但球面还是椭球是个坑。