GeoJSONcn
GeoJSONcn / 技术专栏 / 只有区县边界,想要地市边界?拓扑…

只有区县边界,想要地市边界?拓扑合并(dissolve)的正确做法

发布于 2026-07-31 · GeoJSONcn 技术团队 · 阅读约 10 分钟

--- title: 只有区县边界,想要地市边界?拓扑合并(dissolve)的正确做法 ---

手里一份区县级 GeoJSON,需要的是地市级或者省级边界。按行政区划代码分组,把同一个地市下的区县面拼起来——思路没问题,麻烦出在实现上。拼不好,原本相邻的两个区县之间会留下一条头发丝粗细的缝,缩放到 z14 就看得见,做分级设色时那条缝还会漏出底图的颜色。

细缝是怎么来的

问题出在共享边。理论上甲区的东边界和乙区的西边界是同一条线,可数据经过生产、投影、导出这一路折腾,两条线上的顶点坐标很少能逐位相等,差异常常落在小数点后第七、第八位。纯几何的布尔并集看到的是两条不重合的线,中间自然留下一个极窄的多边形,也就是碎屑(sliver)。

拓扑型工具走的是另一条路:导入阶段先把被多个面共享的弧段识别出来,合并时丢掉内部弧、留下外部弧,全程不做浮点比较。mapshaper 就是这么干的。

方案一:mapshaper 的 dissolve2

先算分组字段。国标 6 位代码里前 2 位是省、前 4 位是地市,地市代码就是区县代码取前 4 位补两个零:

mapshaper -i quxian.geojson snap \
  -each 'city_code = adcode.substring(0, 4) + "00"' \
  -clean \
  -dissolve2 city_code copy-fields=province \
  -o precision=0.000001 shi.geojson

导入时那个 snap 会把距离极近的顶点吸附到一起,是为后面构建拓扑铺路。-clean 清理重叠和微小缝隙,判定阈值可以用 gap-fill-area 参数调。-dissolve2 和 -dissolve 别搞混:前者做真正的几何溶解、去掉内部边界,后者只是把要素并成多部件(multipart),内部边界原封不动还在里面。输出用 precision=0.000001 控制坐标精度,六位小数对行政区划边界绰绰有余,关于量化可以看Shapefile 转 GeoJSON 的 ETL 链路那篇。

省级边界同理,前 4 位换成前 2 位、补四个零。

代码前缀有三个例外

按前缀分组这招,碰上直辖市和省直辖县级行政区就失灵了。

北京东城区代码 110101,取前 4 位补零得到 110100,这个代码指的是"市辖区"那一层,不是北京市。直辖市只有两级,想拿北京市的完整边界,按前 2 位分组。

河南济源代码 419001,前 4 位补零成 419000,对应的是"省直辖县级行政区"这个虚拟层级。湖北仙桃 429004 同理。这类代码全国范围内不多,可只要数据里有,按 4 位前缀跑完就会多出几个名字奇怪的"地市"。

所以别指望字符串截取包打天下,老老实实维护一张代码映射表。这张表跟做分级设色地图时用来 join 统计数据的那张是同一份,可以复用。

方案二:浏览器里用 turf.union

临时合并几个面,犯不上装工具链。@turf/union 从 v7 起接受 FeatureCollection 作参数,v6 是传两个 Feature:

import { union } from '@turf/union';
import { featureCollection } from '@turf/helpers';

const byCity = new Map();
for (const f of quxian.features) {
  const code = f.properties.adcode.slice(0, 4) + '00';
  if (!byCity.has(code)) byCity.set(code, []);
  byCity.get(code).push(f);
}

const cities = [];
for (const [code, feats] of byCity) {
  const merged = feats.length === 1 ? feats[0] : union(featureCollection(feats));
  merged.properties = { adcode: code };
  cities.push(merged);
}

turf 底层是浮点布尔运算,没有拓扑信息,细缝风险高,面一多速度掉得也快。合并前最好先做一次坐标吸附。

方案三:数据已经在 PostGIS 里

SELECT left(adcode, 4) || '00' AS city_code,
       ST_Multi(ST_Union(ST_MakeValid(geom))) AS geom
FROM quxian
GROUP BY 1;

ST_MakeValid 不能省,自相交的输入会让 ST_Union 直接报错。结果里还有细缝的话,联合前套一层 ST_SnapToGrid 把坐标对齐到网格。

三种方案对比

方案拓扑感知上千个面的耗时细缝风险适合场景
mapshaper -dissolve2是,导入时构建共享弧秒级低,-clean 可兜底批量离线生成各级边界
@turf/union否,纯几何布尔运算分钟级,随面数增长高,需先吸附坐标浏览器内临时合并少量面
PostGIS ST_Union否,但可配 ST_SnapToGrid秒到分钟中数据已入库,要与其他 SQL 拼接

顺序上的两个坑

先合并,后抽稀。反过来做,相邻面的共享边会被简化成不同形状,缝隙等于自己造出来的。Douglas-Peucker 抽稀放在拿到各级边界之后,每一级单独跑一遍。

合并前确认坐标系一致。WGS84 的区县面和 GCJ-02 的区县面混着做 dissolve,出来只能是一团乱麻。dissolve 完还建议检查环的绕向,外环逆时针、内环顺时针,这是 RFC 7946 规范的要求,有些工具合并后并不会自动纠正。

全国省、市、区县三级边界可以在 GeoJSON 中国按层级浏览下载,属性带 name 和 adcode 字段,上卷这一步能省则省。真正需要自己动手的是自定义分组的场景,比如按经济区、都市圈重新划片,那时候上面这套 dissolve 流程还是最省事的。