把上万条点数据挂到全国省市区县面:GeoJSON 空间连接与报表落地
做数据分析时最常遇到的一个活:手里有一批带经纬度的点(门店、站点、楼盘、采集点),想按行政区划汇总成"每个省/市/区县各有多少个、各项指标合计多少"。手工数是数不过来的,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)); // 抽样看前三条是否正常
跑完先抽样打印前几条,肉眼确认命中结果没有整片偏到海边——这一步比任何断言都值钱。索引命中、精判、归一、落盘四段各自单独打印数量,方便定位到底哪一环丢点。
三个最容易让人深夜抓狂的坑
这套流程我踩过的坑,一条条说清楚,你拿去能少熬几晚:
- 边界自交:个别行政区的面在数据源里本身就带 self-intersection,
booleanPointInPolygon遇到这种面会给出怪异结果。连接前先用@turf/turf的makeValid或 mapshaper 的clean洗一遍面,比连完再回来查省时间。 - 多重面丢层数:
geometry.coordinates[0]只对 Polygon 有效,遇到 MultiPolygon 你的首环取错就直接空白。连接脚本统一用point判断"点是否在面内",不要手动解析环,让库去处理内外环。 - 属性没带全:很多「全国村级geojson数据下载」回来的数据只留了
name和adcode,其它字段被精简掉了。做一个@turf/bbox旋转九宫格定位校验,把点落在的面上关键字段回填,确保 CSV 不丢维度。
挂在边界上的点怎么办
"未命中"永远不会是零。真跑过一次全国订单,有 0.6% 的点落在区县 bbox 之外或正好压在线上。三个处理原则:
1. 先归因再丢:把"未命中"单独导出,看看是不是坐标系没转、是不是经纬度写反(把纬度当经度,点会跑到海里)。数据源写反经纬度是高频事故。 2. 海底的点要回头查:落海里的点九成是坐标反了或转换没做,别直接当垃圾清理。 3. 线上判定用容差:点要素精确落在两区县共享边界上时,用 booleanPointInPolygon(pt, feature, { ignoreBoundary: false }) 明确让线上点计入边界一侧,避免同一批点两侧各判一次造成计数漂移。
报表落地的最后两步
汇总时按 adcode 分组,COUNT(*) 算数量、SUM(指标) 算合计,输出 CSV 给表头加 adcode,name,cnt,total_amt,BI 里直接拿 adcode 当维度。两点注意:
- 列名写 ASCII:输出列名统一用英文小写,别用中文列名,否则不少 BI 的编码处理会踩坑。
- 补一列坐标系说明:CSV 里加一列注明 WGS84,避免下游拿去做示意图时再次叠偏。
进 CI 当定时任务
整套逻辑就一条 Node 脚本,配合 cron 或 CI 的每日定时就能自动跑:上游更新一份点数据,脚本自动重新挂接、重出 CSV、推送静态站。上次排查「全国村级geojson数据下载」回来的村级点归属时,用的就是这套挂了村边界的变体——把面从区县换成村一级就能复用。预览边界免费,导出完整带 adcode 的边界按次付费(¥1.99/次),适合先小批量确认口径再规模化。