给区县地图打标签:质心为什么会落在边界外,polylabel 怎么用
给全国区县底图标名称,第一步是为每个面找一个锚点。顺手就是 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.center | bbox 几何中心 | 否 | 最低 | 定位视野、算缩放级 |
| 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 直接取。