GeoJSONcn
GeoJSONcn / 技术专栏 / 把上万条点数据挂到全国省市区县面…

把上万条点数据挂到全国省市区县面:GeoJSON 空间连接与报表落地

发布于 2026-08-28 · GeoJSONcn 技术团队 · 阅读约 11 分钟

做数据分析时最常遇到的一个活:手里有一批带经纬度的点(门店、站点、楼盘、采集点),想按行政区划汇总成"每个省/市/区县各有多少个、各项指标合计多少"。手工数是数不过来的,GIS 里叫 空间连接(spatial join)——用面的边界把点划进去。这篇文章就讲怎么拿「全国省市区县geojson数据下载」回来的边界做这件事,并落成一张可以直接喂给 BI 的 CSV。

空间连接到底在算什么

一句话:对每个点,找到它落在哪个面(区县),把面的 adcode/名字挂到点上,再按 adcode 分组汇总。听起来简单,坑全在细节里,光坐标系一个就够翻车——数据标 WGS84、边界也标 WGS84 看着一致,可如果你的点来自某个 App 的 GCJ-02 火星坐标,不转直接叠,所有点会整体偏移几百米,落到隔壁区县。GeoJSON下载 回来第一件事永远是先确认两边坐标系统一,别急着连。

我用三种姿势都跑过一个 2 万点的真实集,给你一张选型表:

方案适用规模速度上手难度备注
Turf.js booleanPointInPolygon 暴力≤ 10 万点 × 几百面慢(O(N×M))低一次性小活够用
rbush 空间索引先筛再精判10 万~百万点快中本质是把 bbox 粗筛提前
DuckDB + spatial 扩展 SQL百万点以上最快中不用装 PostGIS,一条 SQL 干活

Turf 暴力版最省脑子,但要写好前导零:区县 adcode 是 6 位代码,很多数据源输出成数字会把「110101」存成整数 110101,对回 6 位时记得 padStart 前补零,别拿 parseInt 硬转丢信息。做「全国省市区县区划边界数据下载」后的属性挂接时,这一条能给后续 BI join 省掉一堆对不上的麻烦。

一套能跑的 join 脚本

这里给一个 Turf + rbush 的折中版本:先用 rbush 建区县面的 bbox 索引,每个点先取落在 bbox 范围里的候选面,再做精确的 booleanPointInPolygon,最后输出带 adcode 的 CSV。

import fs from 'node:fs';
import rbush from 'rbush';
import { booleanPointInPolygon, bbox } from '@turf/turf';

const counties = JSON.parse(fs.readFileSync('区县.geojson', 'utf8')).features;
const tree = new rbush();
const idx = new Map();

counties.forEach((f, i) => {
  const [minX, minY, maxX, maxY] = bbox(f);
  tree.insert({ minX, minY, maxX, maxY, id: i });
  idx.set(i, f);
});
const norm = (c) => String(c).padStart(6, '0'); // 前导零别丢

const points = JSON.parse(fs.readFileSync('points.json', 'utf8')).features;
const rows = [];
for (const pt of points) {
  const [x, y] = pt.geometry.coordinates;
  let hit = null;
  for (const c of tree.search({ minX: x, minY: y, maxX: x, maxY: y })) {
    if (booleanPointInPolygon(pt, idx.get(c.id))) { hit = idx.get(c.id); break; }
  }
  rows.push({
    lon: x, lat: y,
    adcode: hit ? norm(hit.properties.adcode) : '',
    name: hit ? hit.properties.name : '未命中',
  });
}
console.log(rows.slice(0, 3)); // 抽样看前三条是否正常

跑完先抽样打印前几条,肉眼确认命中结果没有整片偏到海边——这一步比任何断言都值钱。索引命中、精判、归一、落盘四段各自单独打印数量,方便定位到底哪一环丢点。

三个最容易让人深夜抓狂的坑

这套流程我踩过的坑,一条条说清楚,你拿去能少熬几晚:

挂在边界上的点怎么办

"未命中"永远不会是零。真跑过一次全国订单,有 0.6% 的点落在区县 bbox 之外或正好压在线上。三个处理原则:

1. 先归因再丢:把"未命中"单独导出,看看是不是坐标系没转、是不是经纬度写反(把纬度当经度,点会跑到海里)。数据源写反经纬度是高频事故。 2. 海底的点要回头查:落海里的点九成是坐标反了或转换没做,别直接当垃圾清理。 3. 线上判定用容差:点要素精确落在两区县共享边界上时,用 booleanPointInPolygon(pt, feature, { ignoreBoundary: false }) 明确让线上点计入边界一侧,避免同一批点两侧各判一次造成计数漂移。

报表落地的最后两步

汇总时按 adcode 分组,COUNT(*) 算数量、SUM(指标) 算合计,输出 CSV 给表头加 adcode,name,cnt,total_amt,BI 里直接拿 adcode 当维度。两点注意:

进 CI 当定时任务

整套逻辑就一条 Node 脚本,配合 cron 或 CI 的每日定时就能自动跑:上游更新一份点数据,脚本自动重新挂接、重出 CSV、推送静态站。上次排查「全国村级geojson数据下载」回来的村级点归属时,用的就是这套挂了村边界的变体——把面从区县换成村一级就能复用。预览边界免费,导出完整带 adcode 的边界按次付费(¥1.99/次),适合先小批量确认口径再规模化。