GeoJSONcn
GeoJSONcn / 技术专栏 / 给区县地图打标签:质心为什么会落…

给区县地图打标签:质心为什么会落在边界外,polylabel 怎么用

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

给全国区县底图标名称,第一步是为每个面找一个锚点。顺手就是 turf.centroid,拿返回的坐标当文字位置。渲染出来一看,坏了:一批名称压在相邻区县上,还有几个跑到了水面上。数据没错,是"质心"这个概念压根不承诺落在多边形内部。

三种"中心"都不保证在面内

Turf 有三个特别容易混淆的函数,算的是完全不同的东西。

turf.center 取 bbox 的几何中心,也就是西南角和东北角连线的中点。它只看外接矩形,形状怎么样一概不管。

turf.centroid 把所有顶点的坐标做算术平均。顶点密集的那一侧会把结果拽过去,一条边界抽稀过、另一条没抽,中心就偏了。

turf.centerOfMass 算的是面积加权质心,把多边形当成一块密度均匀的薄板求重心。三个里面它数学意义最正统。

麻烦在于,凹多边形的重心可以完全在图形外面。C 形、月牙形、狭长带状的辖区都是这一类,行政区划数据里这种形状真不少。这种情况下三个函数给出的都是面外的点,谁也救不了谁。

polylabel 求的是最大内接圆的圆心

Mapbox 开源的 polylabel 换了个思路:不找几何中心,找面内离边界最远的那个点,术语叫 pole of inaccessibility。它就是多边形内最大内接圆的圆心,天然在面内,而且周围空白最大,放文字正合适。

算法是网格细分加优先队列。先把 bbox 切成若干正方形 cell,每个 cell 用中心点到边界的距离作为估值、用 cell 半径作为该区域内可能达到的上界;再从优先队列里不断取出最有希望的 cell 做四等分细分,直到上界和当前最优解的差值小于 precision。迭代深度大致是 log2(bbox 短边 / precision),一个短边 5 度的省份配 precision 0.0001,大约细分 16 层就收敛了。

批量给区县数据生成标注点的脚本:

import fs from 'node:fs';
import polylabel from 'polylabel';
import { area } from '@turf/area';

const fc = JSON.parse(fs.readFileSync('china-district.geojson', 'utf8'));

for (const f of fc.features) {
  const g = f.geometry;
  let rings;

  if (g.type === 'Polygon') {
    rings = g.coordinates;
  } else {
    // MultiPolygon:挑面积最大的那一块,飞地和小岛不参与
    let max = -1;
    for (const poly of g.coordinates) {
      const a = area({ type: 'Polygon', coordinates: poly });
      if (a > max) { max = a; rings = poly; }
    }
  }

  const p = polylabel(rings, 0.0001);
  f.properties.labelLng = +p[0].toFixed(6);
  f.properties.labelLat = +p[1].toFixed(6);
  f.properties.labelR = +p.distance.toFixed(6);
}

fs.writeFileSync('china-district-labeled.geojson', JSON.stringify(fc));

签名是 polylabel(rings, precision)。rings 是坐标数组的数组,第一项外环,后面几项内环——它收的是裸坐标,不是 GeoJSON Feature,这里最容易传错。返回值 [x, y] 数组上还挂着一个 distance 属性,圆心到最近边界的距离,单位跟输入坐标一致。这个值后面要用来决定标不标。

坐标存 6 位小数够了,大约 0.1 米的精度,再多只是白撑文件体积。抽稀相关的取舍见 Douglas-Peucker 抽稀。

四种方案的差别:

方法算什么保证在面内相对开销适用场景
turf.centerbbox 几何中心否最低定位视野、算缩放级
turf.centroid顶点坐标算术平均否低顶点分布均匀的凸形
turf.centerOfMass面积加权质心否中需要物理重心的计算
polylabel最大内接圆圆心是高,随 precision 上升地图标注、文字避让

前端把标注渲染上去

拿到 labelLng / labelLat,Leaflet 这边用 divIcon 落文字,顺便用 labelR 把放不下的滤掉:

L.geoJSON(fc, {
  style: { color: '#3b6fb5', weight: 1, fillColor: '#e8f0fb', fillOpacity: 0.8 },
  onEachFeature(feature, layer) {
    const { labelLng, labelLat, labelR, name } = feature.properties;
    if (labelR < 0.05) return;          // 内接圆半径不足 0.05 度(约 5.5 公里),不标
    L.marker([labelLat, labelLng], {
      interactive: false,
      icon: L.divIcon({ className: 'district-label', html: name, iconSize: null })
    }).addTo(map);
  }
}).addTo(map);

labelR 说白了就是"这块地方放不放得下字"。市辖区面积普遍小,全国视野下密密麻麻堆一起,用半径阈值先筛一遍,比事后做碰撞检测便宜太多。渲染层面的整体取舍可以看 前端渲染三条路线,配色和分级在 分级设色地图 讲过。

三个坑

precision 的单位就是坐标单位。按米的直觉填个 1,在经纬度数据上等于 111 公里的容差,算出来的点跟 bbox 中心没什么两样。经纬度下填 0.0001,约合 11 米。

MultiPolygon 不能整个塞进去。GeoJSON 里 Polygon 的 coordinates 第一个环是外环、其余都是内环,把 MultiPolygon 的多个子多边形拼成一个数组传进去,第二块往后全被当成洞,点会飘到莫名其妙的位置。先按面积挑出主体那一块。

洞要一起传。有内环的辖区只传外环,polylabel 会以为洞的位置也是实心的,最大内接圆的圆心很可能正好落在洞里,标注就跑到别人家去了。

还有个容易忘的:抽稀之后边界形状变了,标注点必须重算。先算点后抽稀,点会挨到新边界上,甚至被挤出去。静态出图场景下这个顺序尤其要紧,见 印刷级静态全图。

什么时候不必上 polylabel

形状规整的面犯不上花这个成本。近似矩形或凸多边形的辖区,centerOfMass 的结果和 polylabel 差不了几百米,后者却要多跑十几轮细分。我的做法是先用 @turf/boolean-point-in-polygon 判一下 centerOfMass 在不在面内,在就直接用,不在才回退到 polylabel,2800 多个区县跑下来只有一部分需要走慢路径。

标注点算完就烘焙进数据文件,别丢给前端每次实时算。行政区划边界一年也变不了几次,这种结果属于典型的一次生成、长期复用。全国四级行政区划的 GeoJSON 可以在 geojsoncn.com 直接取。