GIS数据GIS DATA

把村界地图搬上网页:geojson瘦身三板斧

技术专栏 · 数据处理 · 2026-10-25

村级边界数据上手第一课就是体量:合肥一个市 1,571 个村面,GeoJSON 原始串 46.3MB,上海更大到 61.5MB。直接 fetch 一个 46MB 的 JSON 给浏览器,等加载完用户已经走了。抽稀和小数位的取舍在《GeoJSON 太大?先抽点,再谈小数位》里讲过,本文往前三步——小数位量化、TopoJSON 拓扑编码、brotli 传输压缩——三板斧全上,合肥市包从 46.3MB 压到 1.0MB(46 倍),每一步的体积、误差、耗时都有实测账单。

合肥市村界包瘦身阶梯图:46.3MB 压至 1.01MB
合肥市村界包(1,571 面)三级瘦身实测,各级体积与面积误差见正文账单。

第一板斧:小数位量化,误差账算给业务看

第 5 位小数对应约 1.1 米、第 4 位约 11 米。把合肥包分别量化到 4 位和 5 位,逐面重算面积与原值对比:

量化档位序列化体积面积误差中位p95最大
原始(6 位小数)46.3MB———
5 位(≈1.1 米)42.3MB0.003%0.015%0.12%
4 位(≈11 米)38.2MB0.030%0.152%1.35%

单看合肥这张平原省份的表,4 位似乎也能接受。但第二份测试包立刻翻脸:崇明区 489 个面(长江口滩涂、围垦岸线),4 位量化的最大面积误差冲到 22.1%,p95 达 7.7%——复杂海岸线的小面在 11 米栅格上直接变形。结论干脆:统一保留 5 位小数,滩涂区最大误差也只 2.4%;4 位留给填色示意图,别进任何要算面积的流程。量化本身是一行递归:

function quantize(coords, d) {          // d = 1e5 保留 5 位小数
  const walk = a => typeof a[0] === 'number'
    ? [Math.round(a[0] * d) / d, Math.round(a[1] * d) / d]
    : a.map(walk);
  return walk(coords);
}
const slim = fc.features.map(f => ({ ...f, geometry: { ...f.geometry, coordinates: quantize(f.geometry.coordinates, 1e5) } }));

第二板斧:TopoJSON,公共边只存一次

GeoJSON 的浪费是结构性的:1,571 个村面互为邻居,同一条共享边被两个面各抄一遍。TopoJSON 把整份数据拆成弧段(arc),面只记弧段编号。用 topojson-server 编码合肥市包(内建 1e5 量化,等价 5 位小数):

import { topology } from 'topojson-server';
import { feature } from 'topojson-client';

const topo = topology({ villages: fc }, 1e5);          // 编码 1,756ms
const arcs = topo.arcs.length;                          // 5,152 条弧段
const back = feature(topo, topo.objects.villages);      // 解码 443ms,还原 GeoJSON

效果一目了然:46.3MB 的 GeoJSON 变成 6.70MB 的 TopoJSON,只剩 5,152 条弧段就撑起 1,571 个面。有人担心解码成本——实测合肥市包解码 443 毫秒,跟直接 JSON.parse 原文件的 516 毫秒同量级,浏览器端没有额外负担。解码后逐面再对一次面积:中位误差 0.004%、最大 0.17%,与直接 5 位量化持平。两笔账都干净。

要诚实说明的一点:TopoJSON 省的是传输和存储。ECharts、Leaflet 都不直读它,解码回 GeoJSON 之后,运行时内存里还是那份全坐标——别指望它省运行时内存,那要靠按需裁剪(见《村界数据怎么下钻:从全市到单县的加载实测》)。

第三板斧:brotli,静态预压与动态压缩分开选

最后是传输压缩。同一个合肥市包在不同阶段用 gzip(L6)和 brotli(q11,最高档)压一遍:

阶段裸文件gzip L6brotli q11
原始 GeoJSON46.3MB10.7MB5.4MB
量化 5 位42.3MB7.3MB3.8MB
TopoJSON(1e5)6.70MB1.24MB1.01MB

终点 1.01MB,是原始串的 46 分之一,也比市包现在交付的 gz 原包(10.86MB)再小一个数量级。但 brotli 的时间账必须摆出来:压 46MB 原始串,gzip 用 8.6 秒,brotli q11 用 281.6 秒——差 33 倍。把 TopoJSON 的 6.7MB 再按档位细测:

压缩档位体积耗时
brotli q11.53MB0.1s
brotli q51.25MB1.0s
brotli q91.16MB2.4s
brotli q111.00MB45.5s
gzip L91.18MB5.5s

选型就此清晰:q5 是实用档,1 秒压完、体积与 gzip 最高档相当;q11 只配离线预压静态文件(服务端慢一点无所谓,浏览器解压都是毫秒级)。预压脚本两行:

import { brotliCompressSync } from 'node:zlib';
import { writeFileSync } from 'node:fs';
writeFileSync('hefei.topojson.br',
  brotliCompressSync(readFileSync('hefei.topojson'), { params: { 1: 11 } }));  // 1 = 质量参数位

组合拳成绩单与两条边界

三板斧的完整账单:合肥市包 46.3MB → 量化 42.3MB → TopoJSON 6.70MB → brotli q11 1.01MB,传输缩到 46 分之一;崇明区包同管线 6.6MB → 0.15MB(44 倍)。前端三行接上:fetch 拿 .br 文件 → topojson-client 的 feature() 解码 → registerMap 注册,合肥全量解码加注册不到一秒,首屏加载从「转圈等半分钟」变成正常网页体验。

两条边界要记牢。其一,滩涂海岸线的小面扛不住粗量化,本文的面积误差表就是量化档位的底线依据;其二,TopoJSON 的收益止步于传输层,解码后内存不变——想让运行时也轻,把市拆成区县按需取,1 个区县几百 KB,加载次序与防竞态的做法在《村界数据怎么下钻:从全市到单县的加载实测》里已经验证过。想动手跑这套管线,合肥、崇明这些包在「村界数据 · 按市」和「按区县」页都能单取。

常见问题

TopoJSON 和 GeoJSON 有什么区别?
GeoJSON 把每个面的坐标完整抄一遍,相邻面的公共边存两遍;TopoJSON 把整份数据拆成共享的弧段(arc),每个面只记弧段编号,配合坐标量化。实测合肥市包:GeoJSON 46.3MB,TopoJSON 6.70MB,省掉 85%——公共边越多,省得越狠,村界这种互邻面数据正好是 TopoJSON 的主场。
ECharts 能直接用 TopoJSON 吗?
不能直读。ECharts 的 registerMap、Leaflet、Mapbox 都吃 GeoJSON,需要先用 topojson-client(压缩后约 1KB)的 feature() 解码回 GeoJSON 再注册。解码开销可控:合肥市包解码 443 毫秒,与直接 JSON.parse 的 516 毫秒同量级。
坐标量化到 4 位小数(约 11 米)会不会丢形状?
看地形。平原农区的村界 4 位量化面积误差中位 0.030%,基本无感;但海岸线、滩涂、围垦区的小面在 4 位下实测最大误差 22.1%——复杂边界的小单元经不起粗量化。稳妥做法是统一保留 5 位小数(约 1.1 米),实测最大误差 0.12%(平原)到 2.4%(滩涂)。
brotli 比 gzip 压得小,为什么不直接全用 brotli?
压缩时间差了两个数量级:46MB 的村界 GeoJSON,gzip 6 级压 8.6 秒,brotli 最高档(q11)压 281.6 秒。q11 只适合服务端离线预压缩静态文件;接口动态压缩用 brotli q5(TopoJSON 上 1 秒压完、体积与 gzip 9 相当)或直接 gzip,客户端解压两者都是毫秒级。
瘦身之后村子的面积还算得准吗?
准。合肥市包量化到 5 位小数后,逐面面积对比:中位误差 0.003%、95 分位 0.015%、最大 0.12%;TopoJSON 内建 1e5 量化解码后中位 0.004%。做村级面积统计、填色图分档完全够用;要测绘级精度就不该在浏览器里算,回到桌面 GIS 用原始包。
服务器要怎么配置才能用上这 1MB?
把 TopoJSON 文件用 brotli q11 离线压成 .br 静态文件,服务器对 Accept-Encoding 含 br 的请求回 Content-Encoding: br 即可,Nginx、各类静态托管和 CDN 都支持。注意别只发 TopoJSON 裸文件——裸文件 6.70MB,压不压传输量差 6 倍;前端拿到后用 topojson-client 解码注册。