把 GeoJSON 边界转成网格统计:方格、蜂窝与点位网格怎么取舍
一份区县边界 GeoJSON 交到分析师手里,最常见的结局是被当成配图。要让面数据真正参与计算,往往得换个形态:先把轮廓切成规整的格子,再往格子里灌统计值。热力图、网点密度、物流覆盖、基站选址,底层都是这套动作。全国省市区县geojson数据下载回来之后该不该走这一步,取决于你要回答的问题是否落在"区域内总量"以外的维度上。
以浏阳市为例,32 个乡镇街道、112,344 个顶点、陆域面积 5005.23 平方千米。统计口径若只到乡镇,一份属性表就够了。可一旦要比"城区 3 公里半径内的门店铺数",乡镇单元的粒度就不够细,边界形状还会干扰结论——同样的点距,落在狭长乡镇与方正乡镇里,密度读数完全不同。
三种网格形态的差别,先看一张表
Turf.js 7.3.5 提供三个切网格函数,输出形态和体积差别明显。
| 网格形态 | 调用 | 单元形状 | 输出几何 | 适用场景 |
|---|---|---|---|---|
| 方格 | squareGrid | 正方形 | 面 | 与栅格数据对齐、等距聚合 |
| 蜂窝 | hexGrid | 正六边形 | 面 | 邻域统计、消除方向偏差 |
| 点位 | pointGrid | 无面,仅要素点 | 点 | 均匀采样、打点抽稀 |
蜂窝的价值在于单元中心到各边等距,六个邻接方向没有疏密之分。方格在角邻接与边邻接上不一致,做邻域扩散时会露出方向纹理。
实测拿浏阳市跑了一遍。蜂窝在 2 千米边长下产出 788 个单元,按质心判定命中 480 个;边长放到 5 千米剩 111 个单元,10 千米只剩 21 个。方格同边长下的单元数约为蜂窝的三倍——方格在经纬两个方向都按边长平铺,蜂窝的错行排布省掉了近一半冗余。
成本同比例放大。2 千米蜂窝全程 331.9 毫秒,同档方格 950.2 毫秒;边长 10 千米时两者分别回落到 6.0 毫秒和 28.5 毫秒。生成耗时基本与单元数线性相关,选档位就是选这个系数。点位网格最省,10 千米档只出 88 个要素点,2 千米才 2160 个,耗时不到 0.6 毫秒。
mask 参数在 7.3.5 下会直接抛错
按官方文档,网格函数支持 mask 参数,传入多边形只保留落在其中的单元。这是最省事的路子,但在 @turf/hex-grid@7.3.5 与 @turf/square-grid@7.3.5 上会抛异常:
TypeError: Cannot read properties of undefined (reading 'type')
at geomEach (@turf/meta/dist/cjs/index.cjs:240:24)
at intersect (@turf/intersect/dist/cjs/index.cjs:7:18)
at hexGrid (@turf/hex-grid/dist/cjs/index.cjs:74:36)
根因藏在依赖链里。hexGrid 把 mask 塞进临时数组再交给 featureCollection:
if (options.mask) {
if (intersect(featureCollection([options.mask, hex]))) {
results.push(hex);
}
}
@turf/intersect@7.3.5 却把入参当 FeatureCollection 处理:
function intersect(features, options = {}) {
const geoms = [];
geomEach(features, (geom) => { geoms.push(geom.coordinates); });
...
}
geomEach 碰到 FeatureCollection 时取的是 features[i].geometry,而这个临时数组的第一项本身就是个 FeatureCollection 或裸几何对象,没有 .geometry 这一层。回调拿到 undefined,读 .type 立刻崩。传 Feature、传裸几何、传 FeatureCollection 三种写法我都试过,全部抛同一个错。
绕过办法是不用 mask,改成先出全网格、再自己判归属。冗余度可以提前估:浏阳、麻城这类县级数据的全网格大约是命中数的 1.2 到 1.7 倍;重庆这种省级 bbox 会拉到 2.2 到 2.6 倍,因为矩形框里大片区域根本不落在地界内。
归属判定的核心就是射线法:
const cellOf = (pt, units) => {
for (const u of units) {
const g = u.geometry;
if (g.type === 'Polygon') {
if (inRing(pt, g.coordinates[0])) return u;
} else {
for (const part of g.coordinates) {
if (inRing(pt, part[0])) return u; // MultiPolygon 逐部件试
}
}
}
return null; // 落在飞地或空洞,判为未命中
};
命中率随区域形状剧烈变化
同一套网格参数,换一份数据,命中率能差出一倍。下面三组都是 10 千米蜂窝网格,判定口径一致(质心落点归属):
| 数据集 | 单元数 | 顶点数 | 全网格 | 命中 | 命中率 | 生成耗时 |
|---|---|---|---|---|---|---|
| 浏阳市(乡镇街道) | 32 | 112,344 | 21 | 17 | 81.0% | 6.0 ms |
| 麻城市(乡镇街道) | 38 | 5,507 | 14 | 12 | 85.7% | 0.3 ms |
| 重庆市(区县) | 38 | 11,078 | 760 | 311 | 40.9% | 25.0 ms |
浏阳、麻城是县级内部的乡镇街道,拼在一起接近实心块,命中率自然高。重庆是省级单位,38 个区县拼出的轮廓带大量外凸与内凹,bbox 面积远大于陆域面积,落在轮廓之外的网格全被判为未命中。
这提示一步容易漏掉的预处理:跑网格前先用实际面几何算面积,与 bbox 矩形面积比一比。重庆的陆域面积是 165,033.02 平方千米,比 bbox 反推的矩形小得多。差得越远,越该按区县分组各自跑网格,而不是拿全省 bbox 一次性铺满。
顶点数也不是决定性因素。浏阳 112,344 个顶点换来的命中率是 81.0%,麻城只有 5,507 个顶点却是 85.7%。决定命中率的是轮廓的紧致程度,不是数据精度。拿一份高精度数据就以为网格会铺得更准,是思路跑偏了。
网格归属的跨格问题
质心落在谁家,这个判定本身有边界情况。重庆那 311 个命中网格里,全部 311 个的质心同时落在多个区县内——跨格率 40.9%。市辖区彼此紧邻,网格边长 10 千米时,一个格的质心正好压在两个区的共享边上,射线法两次都判真。
宽松处理是取第一个命中,代价是统计量会被相邻区县重复计入。严谨一点的做法是保留命中列表,统计时按面积比例分摊。只做可视化,前者够用;结果要进报表或对外发布,建议把跨格单元单独打标,别让它悄悄混进归属清晰的样本里。
网格只是中间产物。要拿省、市、区县单独的边界做 PPT 底图,不必绕网格这条路,可以直接到 地图 PPT 模板选购页 按省市逐级下钻挑一份可编辑矢量地图,先看预览再决定是否下载,单份 ¥4.99。网格适合数值分析,矢量底图适合版面与汇报。
网格该建在哪一端
浏览器里现算网格可行,但不划算。2 千米蜂窝在浏阳这种量级上要 331.9 毫秒,重庆直接飙到 518.5 毫秒,叠加后续归属判定,首屏体验会被拖住。
更稳的分工是构建期生成一次。全国村级geojson数据下载或区县数据下载之后,在构建脚本里把网格跑出来,跟数据一起发布。边长按展示层级定档:省级视图 20 千米、区县视图 5 千米、乡镇视图 2 千米,各自生成后压缩成静态文件。前端只做两件事——读对应的网格文件,把统计值映射到颜色。单次请求体积可控,刷新缓存时也只需换那几个网格文件。
网格数据要不要保留坐标精度,与面数据是同一个取舍。网格顶点数远少于边界顶点,六位小数通常足够;只做密度统计的话,单元编号加统计值就够,坐标能降到四位。真正影响性能的是单元数而不是坐标位数——先把档位定对,比反复裁小数位更有效。这套生成步骤值得写进构建流程,别留在某个人的笔记本里靠手动执行。
本文数据仅用于技术讨论,区划口径以国家有关部门发布的为准。