GeoJSONcn
GeoJSONcn / 技术专栏 / 全国省市区县geojson数据下…

全国省市区县geojson数据下载后的尺度断层:最大一块占掉全市一半怎么处理

发布于 2026-09-25 · GeoJSONcn 技术团队 · 阅读约 13 分钟

打开一份市级 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_sharemagnitude_gapoverlap_index处理要点
单块主导型喀什地区47.7%2.72.36缩放锚定大块,小块走局部视图
交叠松散型南昌市12.4%1.12.52各块包围盒互压,勿按块切矩形
紧密拼合型呼伦贝尔市20.1%0.41.31可整体渲染,按需下钻
分离兵团型石河子市8.6%8.00.17先识别主体块,空白区不参与缩放

需要说明的是,overlap_index 用的是包围盒而非真实多边形,数值大于 1 只表示各块的矩形框互相交叠,不等于几何本身重叠——后者要看要素面之间的实际相交,判断口径与算法完全是另一回事。

取用时的先后次序

一套稳妥的处理次序是:先清单个文件的量级跨度,跨度大的走分档流程;再定缩放锚点,用最大要素的包围盒而不是整体包围盒;最后才谈抽稀与压缩这类体积优化。次序倒过来做,会出现为了压体积把最大块的精度削掉,结果缩放基准也跟着漂移的情况。

真要做透视变形校正或重新计算整体范围,就要回到原始经纬度做,别拿投影后的坐标回推。

以上判断都服务于"数据要进程序"这条路径。若目的只是取某一个市的轮廓放进汇报页,这些指标可以先放一边:ppt.html 上的模板按省市两级逐级排布,单份 ¥4.99,预览免费,页面里各块的大小比例在制作时已经人工调过,不必再依赖自动缩放的默认结果。全国乡镇街道边界数据的量级跨度比市级更陡,一个县里最大的乡镇与最小的街道差出两三个数量级是常态,这个规律在做分块渲染预算时同样成立。

本文的 375、47.7%、0.30% 取自本轮快照,换一版数据源会有浮动。可迁移的是口径本身:三个指标都只依赖要素的经纬度四至,与文件带不带属性字段无关,GeoJSON下载 得到的任何一份多要素集合都可以直接跑一遍再用。

要素四至算出来的结果与行政归属、名称写法都没有关系,换一批数据来源照样成立。