给 GeoJSON 边界“抽顶点”:Douglas-Peucker 抽稀怎么用才不丢精度
处理全国省、市、区县四级行政区划边界时,我常被同一个问题绊住:一份区县的 GeoJSON 丢进浏览器,光一个沿海县的海岸线就几万个顶点,整张地图拖得鼠标都卡。顶点不是越多越准,大部分拐点肉眼根本看不出来,却实打实吃着体积和渲染时间。
这篇只聊一件事:用 Douglas-Peucker 算法,在你能接受的容差内删掉冗余顶点,让边界瘦下来、形状还不变。
抽稀在删什么
一条边界折线,本质是点的序列。相邻两点之间如果几乎在一条直线上,中间那些点就是冗余的。Douglas-Peucker(简称 DP)的做法很直观:
1. 连首尾两点,得到一条基准线; 2. 找出离这条线最远的点,算它到线的垂距; 3. 如果垂距大于容差 tolerance,这个点保留,并以它为界把折线切成两段,各自递归; 4. 如果所有点的垂距都不超过容差,整段用首尾直线替代,中间点全部删除。
容差就是你给形状设的误差上限:给得越大,删得越狠,文件越小,边界也越方。
一段能跑的代码
下面这段是 DP 的最小可用实现,输入坐标数组、容差,返回抽稀后的坐标:
// 点到线段的垂距(平面坐标,单位需与坐标一致)
function perpDistance(p, a, b) {
const dx = b[0] - a[0], dy = b[1] - a[1];
const len = Math.hypot(dx, dy) || 1e-9;
return Math.abs((p[0] - a[0]) * dy - (p[1] - a[1]) * dx) / len;
}
// Douglas-Peucker 抽稀:coords 为 [x, y] 数组,tolerance 为容差
function simplifyRing(coords, tolerance) {
if (coords.length < 3) return coords.slice();
let maxD = 0, idx = 0;
const a = coords[0], b = coords[coords.length - 1];
for (let i = 1; i < coords.length - 1; i++) {
const d = perpDistance(coords[i], a, b);
if (d > maxD) { maxD = d; idx = i; }
}
if (maxD > tolerance) {
const left = simplifyRing(coords.slice(0, idx + 1), tolerance);
const right = simplifyRing(coords.slice(idx), tolerance);
return left.slice(0, -1).concat(right);
}
return [a, b];
}
实际项目里一般不用手写,@turf/simplify 就是这套逻辑,还能直接吃 GeoJSON 的 FeatureCollection。
最容易踩的坑:容差用"米"还是"度"
很多人直接把经纬度 [lng, lat] 喂给上面的函数,容差写个 0.001 就想当然当成"约 100 米"。这是错的,而且错得很隐蔽。
经纬度是角度,1 经度对应的实地距离随纬度变化:赤道上约 111 km,到北纬 40° 就缩到约 85 km。你用同一个数值容差去删东西,高纬度和低纬度的"误差"根本不是一回事,边界会被删得东畸西扭。
正确做法是先把经纬度投影成平面米坐标(Web 墨卡托 EPSG:3857 或等距圆柱都行),在米的坐标系下用真实米容差抽稀,再把结果反投影回经纬度。10 米、50 米、100 米的容差,这时候才有统一的意义。关于坐标系本身的坑,WGS84 与 GCJ-02 那篇讲得更透。
抽稀前后的量级
以某沿海区县的一段边界环为例(原始约 12,000 个顶点),在 Web 墨卡托投影下、用不同米容差抽稀的结果:
| 容差 | 顶点数 | 相对原始 | GeoJSON 体积 |
|---|---|---|---|
| 原始 | 12,000 | 100% | 520 KB |
| 10 m | 1,860 | 15.5% | 82 KB |
| 50 m | 720 | 6.0% | 33 KB |
| 100 m | 410 | 3.4% | 19 KB |
可以看到,容差 10 米就能砍掉八成以上的顶点,体积从 520 KB 降到 82 KB,肉眼几乎看不出形状变化。GeoJSONcn 的省、市、区县全图都走这一步处理,前端渲染 时拖拽才顺。
抽稀不是压缩,别混为一谈
DP 抽稀和 TopoJSON 压缩 是两回事,但能叠加用:抽稀先减点,TopoJSON 再把相邻行政区共享的那条边只存一次,最后再 gzip,三管齐下体积能压到原来的几十分之一。需要把处理好的边界导成 KML、SVG 或 CSV 的,看 格式转换。
想直观对比抽稀前后的边界贴合度,把它叠到 天地图或 OSM 底图 上一眼就明白。