把 GeoJSON 多边形画成 SVG:手写地图 path 与逆向解析
title: 把 GeoJSON 多边形画成 SVG:手写地图 path 与逆向解析
做数据产品常会遇到一个需求:要一张不依赖地图库的静态地图,能直接塞进 PPT、README、邮件或离线文档。Leaflet 和 d3-geo 都能用,但它们要么拖一个 JS 运行时,要么跑一整套投影管线。GeoJSON 多边形到 SVG 其实只差一层坐标变换,几十行代码就能搞定。
核心:经纬度映射到屏幕坐标
最省事的办法是等距矩形(equirectangular)投影,把经纬度线性塞进 viewBox。有两个坑要先说:y 轴得翻转——纬度越大越靠北,可 SVG 里 y 越小越靠上;再留点 padding,图形才不会被画布边缘切掉。
function lonLatToXY(lon, lat, bbox, width, height, pad = 8) {
const [lonMin, latMin, lonMax, latMax] = bbox;
const innerW = width - pad * 2, innerH = height - pad * 2;
const x = pad + (lon - lonMin) / (lonMax - lonMin) * innerW;
const y = pad + (latMax - lat) / (latMax - latMin) * innerH;
return [Math.round(x * 100) / 100, Math.round(y * 100) / 100];
}
坐标四舍五入到 2 位小数,文件能小一圈,肉眼看不出区别。
环到 path:M / L / Z
一个 Polygon 可以有多个环,第一个是外环,剩下的是洞。用 M 起笔、L 连直线、Z 收尾,把所有环拼进同一条 d:
function ringToD(ring, bbox, width, height, pad = 8) {
const d = ring.map(([lon, lat], i) => {
const [x, y] = lonLatToXY(lon, lat, bbox, width, height, pad);
return (i === 0 ? 'M' : 'L') + x + ' ' + y;
}).join(' ');
return d + ' Z';
}
function polygonToD(polygon, bbox, width, height, pad = 8) {
return polygon.coordinates
.map(ring => ringToD(ring, bbox, width, height, pad))
.join(' ');
}
外环和洞塞进同一个 d 后,给 加 fill-rule="evenodd",洞就镂空了。evenodd 不挑绕向,比 nonzero 省心。
完整示例(含洞)
天安门周边 0.02° 见方外环,中间再挖一个小洞:
const example = {
type: 'Polygon',
coordinates: [
[[116.377428, 39.88923], [116.417428, 39.88923], [116.417428, 39.92923], [116.377428, 39.92923], [116.377428, 39.88923]],
[[116.392428, 39.89923], [116.402428, 39.89923], [116.402428, 39.90923], [116.392428, 39.90923], [116.392428, 39.89923]],
],
};
转换后真实的 d(viewBox 400×300,padding 8):
M8.00 292.00 L392.00 292.00 L392.00 8.00 L8.00 8.00 L8.00 292.00 Z M152.00 221.00 L248.00 221.00 L248.00 150.00 L152.00 150.00 L152.00 221.00 Z
套进 就能渲染出带镂空方块的地图。MultiPolygon 同理:每个多边形各生成一条 d,丢多个 。
逆向:d 字符串变回 GeoJSON
拿到别人导出的 SVG 地图想还原成 GeoJSON,用正则把 M/L 后的坐标对抠出来,再按 viewBox 反算回经纬度:
function parseDToRings(d, bbox, width = 400, height = 300, pad = 8) {
const [lonMin, latMin, lonMax, latMax] = bbox;
const innerW = width - pad * 2, innerH = height - pad * 2;
const subs = d.match(/M[^M]*Z?/gi) || [];
return subs.map(sub => {
const nums = (sub.match(/-?\d+\.?\d*/g) || []).map(Number);
const ring = [];
for (let i = 0; i + 1 < nums.length; i += 2) {
const x = nums[i] - pad, y = nums[i + 1] - pad;
const lon = +(lonMin + x / innerW * (lonMax - lonMin)).toFixed(6);
const lat = +(latMax - y / innerH * (latMax - latMin)).toFixed(6);
ring.push([lon, lat]);
}
return ring;
});
}
上面那段 d 反解回来,坐标精确到 6 位小数,和原值逐位一致。
三种方案怎么选
| 方案 | 依赖 | 适合场景 | 缺点 |
|---|---|---|---|
| 手搓 SVG path | 零 | 静态图、离线文档、PPT | 不支持交互、大批量慢 |
| d3-geo 投影 | d3 | 精确投影、出版级出图 | 要引库、有投影学习成本 |
| Leaflet SVG 层 | Leaflet | 在线交互地图 | 必须联网跑 JS |
五个坑
1. y 轴翻转:忘了 (latMax - lat),地图上下颠倒。 2. evenodd 还是 nonzero:要镂空就用 fill-rule="evenodd",绕向写错也不怕;换 nonzero 得保证外环和洞绕向相反(见 RFC 7946 的右手定则)。 3. 洞要闭合:内环首末点必须相同,否则渲染多出一条斜线。 4. 坐标系一致:输入得是 WGS84 经纬度;GCJ-02 火星坐标直接画,整图偏移几百米(见 坐标系系列)。 5. 精度别太狠:屏幕显示留 2 位小数够用,硬留 6 位只会让 SVG 虚胖——这也是 TopoJSON 压缩 的核心思路。
手搓 SVG 不神秘,这层坐标变换正是 GeoJSON 格式转换全景 里 SVG 出图环节的底层逻辑。数据量上到万级面,就该用 矢量瓦片切片 替代巨型 SVG。坐标系与渲染细节另见 前端渲染性能 和 主站。