返回博客

d3-geo 渲染中国地图两个无声的坑:多边形内外翻转 + SVG 默认 150px

做可视化大屏时,我需要把中国某省的市/县级地图渲染成 SVG。数据源选了阿里 DataV GeoAtlas(geo.datav.aliyun.com/areas_v3/bound/<adcode>_full.json),投影用的是 d3-geo 的 geoMercator。代码跑通,没有报错,GeoJSON 也拉下来了——但屏幕上是一整块实心的深色矩形,把整个 viewBox 盖得死死的。

改了投影参数、换了 fitSize,还是一样。折腾半小时后发现,这里埋了两个完全独立的坑,叠在一起更难排查。

现象

  1. 渲染结果是一块实心矩形,覆盖整个 SVG 区域(不是想要的省份轮廓)。
  2. geoPath().bounds(featureCollection) 打出来的 bounds 恰好是 [[0,0],[W,H]]——完美的 viewBox 四角。
  3. 每个 feature 的 geoPath().centroid(f) 几乎都是同一个点,比如 [484, 484]——全挤在投影中心。
  4. 换小一点的 SVG 尺寸,或者把 height 属性去掉后,地图"消失了",只剩底部一条窄带。

根因

坑一:DataV GeoJSON 是顺时针环绕,d3 球面投影把它读成"补集"

d3-geo 的球面投影(geoMercatorgeoConicConformal 等)对多边形环绕方向(winding order)有严格要求:外环必须是逆时针(CCW)。不符合时,d3 不报错,而是静默地把这个多边形解释为"整个地球 减去 这块区域"——即渲染出整个地球的补集,表现为覆盖全屏的实心块。

DataV / 阿里 geoatlas 的 GeoJSON 坐标是顺时针缠绕的,与 d3 球面投影的期望相反,触发了这个隐式翻转。

诊断指纹(三条同时出现基本可锁定):

  • bounds 撑满整个 fit 范围([[0,0],[W,H]]
  • 所有 feature 的 centroid 都塌到同一点(投影 translate 位置)
  • 单个 feature 的 path(f).d 里坐标分布其实是正常的(矛盾感 = winding 翻转铁证)
坑:DataV GeoJSON 顺时针环绕,d3 球面投影静默翻转为全屏实心块

geoMercator 等球面投影不会抛错,只会把顺时针外环视为"地球补集",渲染结果是覆盖整个 viewBox 的实心矩形。bounds 撑满、centroid 全塌到中心是确诊信号。

坑二:内联 SVG 不写 height,默认只有 150px

HTML 内联 <svg width="600" viewBox="0 0 1000 1030"> 如果不写 height,替换元素的默认高度是 150px(CSS 规范)。viewBox 被 preserveAspectRatio 塞进 600×150 的容器后,只能看到中间一条窄带,或被裁成只剩底部一小段——很容易被误判为"投影还是有问题"。

坑:内联 SVG 不写 height 默认 150px,viewBox 内容被裁

HTML 规范中,内联 SVG 是替换元素,默认固有尺寸 300×150px。只设 width 不设 height,省级地图大概率只剩一条窄带,与投影错误的表现容易混淆。

正解

修法一:换用 geoIdentity 平面投影,绕开环绕方向问题

省级范围小,不需要球面精度。geoIdentity() 把坐标当普通平面 x/y 处理,对环绕方向不敏感,不会发生内外翻转。直接喂经纬度会横向拉伸,乘 cos(平均纬度) 修正即可:

import { geoIdentity, geoPath } from 'd3-geo'
 
// 1. 计算 GeoJSON 内所有坐标的平均纬度
const k = Math.cos((latSum / latN) * Math.PI / 180)
 
// 2. 对每个坐标做平面化:经度 * k,纬度不动
const flat = {
  type: 'FeatureCollection',
  features: fc.features.map(f => ({
    ...f,
    geometry: {
      type: f.geometry.type,
      coordinates: mapDeep(f.geometry.coordinates, ([lng, lat]) => [lng * k, lat])
    }
  }))
}
 
// 3. geoIdentity reflectY 翻转屏幕 y,fitWidth 自动算 scale/translate
const proj = geoIdentity().reflectY(true).fitWidth(W - 2 * PAD, flat)
const path = geoPath(proj)

另一条路是用 geojson-rewind / @turf/rewind 修正环绕方向后再接 geoMercator,但对单省场景 geoIdentity 更省事、零额外依赖。

修法二:给 SVG 显式高度

// 方式 A:直接写 height 属性
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
 
// 方式 B:React 里用 Tailwind 撑满父容器
<svg className="w-full h-full" viewBox="0 0 1000 1030">

验证方法:同一份已经正确的 path 数据,只加 height 就从"一条"变成完整省形,即确认是高度问题而不是投影问题。

正解:geoIdentity 平面投影 + 显式 SVG 高度

geoIdentity().reflectY(true).fitWidth(W, flat) 代替球面投影,彻底规避 winding 陷阱;同时给 SVG 元素显式设 height,防止替换元素 150px 默认值裁切内容。两个坑独立修,缺一不可。

排查顺序建议

先写一段自包含的 HTML 文件(数据内联或同源 JSON,不依赖打包工具)截图验形,确认 path 数据没问题再接进真实组件。在框架环境里排查投影问题很容易被 JSX 转义、模块 import 等干扰因素带偏。

一句话外卖

d3 球面投影对 winding order 静默翻转、内联 SVG 默认 150px 都不报错——遇到"实心块"或"窄带"先查这两个,不要无头绪地调投影参数。