GeoJSONcn
GeoJSONcn / 技术专栏 / 把 GeoJSON 边界转成网格…

把 GeoJSON 边界转成网格统计:方格、蜂窝与点位网格怎么取舍

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

一份区县边界 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 千米蜂窝网格,判定口径一致(质心落点归属):

数据集单元数顶点数全网格命中命中率生成耗时
浏阳市(乡镇街道)32112,344211781.0%6.0 ms
麻城市(乡镇街道)385,507141285.7%0.3 ms
重庆市(区县)3811,07876031140.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 千米,各自生成后压缩成静态文件。前端只做两件事——读对应的网格文件,把统计值映射到颜色。单次请求体积可控,刷新缓存时也只需换那几个网格文件。

网格数据要不要保留坐标精度,与面数据是同一个取舍。网格顶点数远少于边界顶点,六位小数通常足够;只做密度统计的话,单元编号加统计值就够,坐标能降到四位。真正影响性能的是单元数而不是坐标位数——先把档位定对,比反复裁小数位更有效。这套生成步骤值得写进构建流程,别留在某个人的笔记本里靠手动执行。

本文数据仅用于技术讨论,区划口径以国家有关部门发布的为准。