GeoJSONcn
GeoJSONcn / 技术专栏 / 把 GeoJSON 多边形画成 …

把 GeoJSON 多边形画成 SVG:手写地图 path 与逆向解析

发布于 2026-08-09 · GeoJSONcn 技术团队 · 阅读约 13 分钟

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。坐标系与渲染细节另见 前端渲染性能 和 主站。