全国省市区县geojson数据下载后的尺度断层:最大一块占掉全市一半怎么处理
打开一份市级 GeoJSON,最先被注意的是要素个数。13 个区县、18 个乡镇,数一眼就清楚。真正决定这份数据好不好用的指标藏在另一处:最大的那一块,占掉整个包围盒的多少。全国省市区县区划边界数据下载回来后若跳过这一步,后面无论做自动缩放到范围、还是算分块渲染的预算,都会撞上同一堵墙。
对本站 mapjson/市/ 下 375 个市级文件逐个算了整体包围盒面积与各要素包围盒面积,比例最高的是南昌市,10 个区县各占一条带状,各块包围盒面积之和是整体包围盒的 2.52 倍;最低的是新疆那批兵团市,石河子、五家渠、阿拉尔等 8 个市都落在 0.17——13 个要素彼此分离且互不交叠,整体包围盒被撑成一大片空区。全量中位数是 1.98。
倍数关系只说明块与块交不交叠。要判断缩放行为,还得看最大单块占整体包围盒的比重。
一块占一半,缩放就归它管
喀什地区是比较极端的一例。13 个要素里,最大的那块吃掉整体包围盒的 47.7%,往后依次 18.3%、12.5%,最小的只占 0.30%。最大块与最小块之间差了约 160 倍。
这个分布带来两个直接后果。
一是自动缩放。按整体包围盒套 fitBounds,视图范围由那个 47.7% 的大块和它的四至共同决定,几个小县在屏幕上被压成十几个像素,边界线糊成一团。用户想点开某个小县,得先手动放大三四级。
二是渲染批次划分。如果按要素包围盒切分任务或预生成缩略图,任务粒度会严重不均——最大块的工作量可以是某小块的百倍以上,按要素个数平均分配线程只会让任务队列在最后拖出长尾。
用同样的口径扫全量(数据取自本站 mapjson/市/),单要素占整体包围盒超过两成的文件有 63 个。这些文件的缩放行为都不能按均摊假设去写。
Web 墨卡托会把断层再放大一次
上面的面积是在球面上算的,经纬度直接按弧度乘算。一旦把数据投到 EPSG:3857 用,纬度方向会被拉伸,拉伸系数是纬度的正割。数据跨度越大,这个系数在包围盒上下缘之间的差值越大。
喀什地区纬度跨度约 2.3 度,在 37 度到 39 度之间,1/cos(37°)=1.252、1/cos(39°)=1.287,两端差约 2.8%。这个幅度对判断影响不大,但换成纬度跨度十几个度的省级文件——内蒙古、新疆——投影后的长方形会比实际范围"胖"出一个可观比例,高纬度一侧的空白区被额外放大。渲染时按投影后 bbox 分配画布,实际有内容的区域反而只占其中一部分,剩下全是白边。
处理办法是在投影之前先按经纬度算出真实四至,把投影只用在绘制环节,不要拿投影后的 bbox 反过来当作数据范围。
量级跨度比要素个数更该先算
把每个市级文件按"整体包围盒面积 ÷ 除去最大块之后剩余要素的包围盒面积"取以 10 为底的对数,得到一个数量级跨度值。375 个文件里,中位数接近 0——说明多数地级市各块尺寸相当。排名靠前的是仙桃、天门、潜江三个湖北省直管市,跨度约 8 个数量级;这三个文件里各含一个面积极小的要素,把剩余范围的包围盒一下子拉小。
这类"一个超大块 + 一堆小块"的结构在数据加工中最容易出问题,因为它同时触发两个相反的判断:
import json
from math import radians, sin, log10
R = 6371.0088
def ring_bbox(coords):
xs, ys = [], []
stack = [coords]
while stack:
node = stack.pop()
if node and isinstance(node[0], (int, float)):
xs.append(node[0]); ys.append(node[1])
else:
stack.extend(node)
return min(xs), min(ys), max(xs), max(ys)
def bbox_km2(bb):
"""球面近似:经度差按弧度乘 R²,纬度用正弦差消除高纬收缩"""
minx, miny, maxx, maxy = bb
return radians(maxx - minx) * R * R * (sin(radians(maxy)) - sin(radians(miny)))
def scale_report(path):
d = json.load(open(path, encoding="utf-8"))
boxes, areas = [], []
for f in d["features"]:
g = f.get("geometry")
if not g:
continue
bb = ring_bbox(g["coordinates"])
boxes.append(bb)
areas.append((bbox_km2(bb), bb))
whole = (min(b[0] for b in boxes), min(b[1] for b in boxes),
max(b[2] for b in boxes), max(b[3] for b in boxes))
w = bbox_km2(whole)
areas.sort(key=lambda x: -x[0])
top = areas[0][0]
rest = areas[1:]
rw = bbox_km2((min(x[1][0] for x in rest), min(x[1][1] for x in rest),
max(x[1][2] for x in rest), max(x[1][3] for x in rest))) if rest else 1.0
return {
"n": len(areas),
"top_share": top / w, # 最大块占整体包围盒
"magnitude_gap": log10(w / rw) if rw > 0 else float("inf"),
"overlap_index": sum(a for a, _ in areas) / w, # >1 说明各块包围盒互相交叠
}
三个返回值各管一件事:top_share 决定缩放锚点,magnitude_gap 决定任务划分要不要分档,overlap_index 判断这份文件的要素是紧密拼合还是彼此分离。石河子那批市的 0.17 就是这个指数在说话——小于 1,说明要素之间隔着大片空白。
用同一段逻辑跑完 375 个市级文件,分布大致能归成四类:
| 分组 | 代表文件 | top_share | magnitude_gap | overlap_index | 处理要点 |
|---|---|---|---|---|---|
| 单块主导型 | 喀什地区 | 47.7% | 2.7 | 2.36 | 缩放锚定大块,小块走局部视图 |
| 交叠松散型 | 南昌市 | 12.4% | 1.1 | 2.52 | 各块包围盒互压,勿按块切矩形 |
| 紧密拼合型 | 呼伦贝尔市 | 20.1% | 0.4 | 1.31 | 可整体渲染,按需下钻 |
| 分离兵团型 | 石河子市 | 8.6% | 8.0 | 0.17 | 先识别主体块,空白区不参与缩放 |
需要说明的是,overlap_index 用的是包围盒而非真实多边形,数值大于 1 只表示各块的矩形框互相交叠,不等于几何本身重叠——后者要看要素面之间的实际相交,判断口径与算法完全是另一回事。
取用时的先后次序
一套稳妥的处理次序是:先清单个文件的量级跨度,跨度大的走分档流程;再定缩放锚点,用最大要素的包围盒而不是整体包围盒;最后才谈抽稀与压缩这类体积优化。次序倒过来做,会出现为了压体积把最大块的精度削掉,结果缩放基准也跟着漂移的情况。
真要做透视变形校正或重新计算整体范围,就要回到原始经纬度做,别拿投影后的坐标回推。
以上判断都服务于"数据要进程序"这条路径。若目的只是取某一个市的轮廓放进汇报页,这些指标可以先放一边:ppt.html 上的模板按省市两级逐级排布,单份 ¥4.99,预览免费,页面里各块的大小比例在制作时已经人工调过,不必再依赖自动缩放的默认结果。全国乡镇街道边界数据的量级跨度比市级更陡,一个县里最大的乡镇与最小的街道差出两三个数量级是常态,这个规律在做分块渲染预算时同样成立。
本文的 375、47.7%、0.30% 取自本轮快照,换一版数据源会有浮动。可迁移的是口径本身:三个指标都只依赖要素的经纬度四至,与文件带不带属性字段无关,GeoJSON下载 得到的任何一份多要素集合都可以直接跑一遍再用。
要素四至算出来的结果与行政归属、名称写法都没有关系,换一批数据来源照样成立。