GeoJSONcn
GeoJSONcn / 技术专栏 / 地图不动画别先怪引擎:给全国行政…

地图不动画别先怪引擎:给全国行政区划GeoJSON做分级LOD,远景聚合近景下钻

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

地图页面不动画,第一反应多半是"该换渲染引擎了"。可我做地图可视化的这两年,被同一个问题磨到没脾气:用户拖到全国视角,三千多个区县面一下全扑上来,缩放一卡就是一个呼吸;可退一步想——人在全国范围,真需要看清每个县城的精确边界吗?

并不需要。卡顿的根源往往不是引擎快不快,而是同一时刻塞进去了多少本不该画的要素。 这一篇讲我落地在 GeoJSON 站点上的做法:给数据按层级建 feature 级 LOD,远景发轻量聚合、拉近再换精确边界,加载量和解码成本都实打实降下来。

先给自己算一笔账:远景和近景各要多少数据

拿常见的场景举例:你从「全国省市区县geojson数据下载」拿一份县级面,假设每个县边界平均几千个坐标点,3000 多个要素就是百万级点位。浏览器就算用 WebGL 画得动,JSON 解析、内存、与地图底图的叠加也都在消耗真金白银的 CPU。

所以我做了一份对照表,按视角层级决定该加载哪份数据:

视角你真正要看到建议数据层级一次加载要素量级
全国(zoom ≤ 4)省界 + 重点城市位置省级面或市级简化版几十 ~ 几百
跨省/大区域(zoom 5–7)市界、读得出城市名市级面上百
单城市(zoom 8–10)区县边界、做设色统计区县级面几十
单区县(zoom ≥ 11)乡镇、街道、村级边界乡镇/村面(按 adcode 分片)几十 ~ 上百

道理很简单:放到"该看清省"的尺度,你硬塞村庄边界,除了拖慢没人领情。

数据侧先排好:一份原数据,拆出多份"视图"

这一步不写一行前端逻辑,纯粹在生成期把数据归好类,避免把几十 MB 原始边界一次性丢给浏览器。我平时把它做成两级:

1. 远景聚合层:用坐标点按固定网格抽稀,或者只保留要素质心做符号点,生成一份"全国一张图秒开"的精简版; 2. 近景精确层:原样保留边界,但按行政编码 adcode 前缀拆成独立文件,只有把镜头推到某个城市才按需拉取对应那份。

生成期做到单一真相源:矢量和属性都从同一份原始 GeoJSON 派生,远景聚合永远指向它的"父"adcode,这样两级画面在行政区划代码上天然对齐,不会出现"点归属 A 县、面却画在 B 县"的尴尬。同时我会把"该拆几层、文件名怎么起"写死在发布脚本里,避免每次手动拖文件、最后连自己都分不清线上那份是哪一档。

一级难堪的数据:村级汇总怎么进远景

有人会问:那村级数据怎么办,总不能远景也整村画吧?我的答案是村级数据只在 zoom ≥ 11 才准入,远景一律只画乡镇或区县两级面。换句话说,把全国村级geojson数据下载来之后,先按乡镇 adcode 前缀归并成"村→乡镇"的聚合,分片命名照旧用乡镇 prefix,前端到了近景按乡镇目录去取。这样同一套 VI 思路能一路套到第四级,不会因为数据加细就把 LOD 推倒重来。

远景层的坐标降位,能再省一截带宽

聚合层的精度本来就不是给人量城墙的,所以我会顺手做一次坐标降位:把小数位数从经纬度 6~7 位压到 4 位左右,边界整体误差在小几十米内,肉眼完全分不出来,文本体积却能再缩到六成上下。做的时候保留 properties 结构别动——远景和近景两份的字段(adcode、name、center)保持一模一样,前端切档才不会因为字段对不上而报 undefined。

前端怎么切:按 zoom 换数据,而不是全量预加载

切层级最忌讳"提前把所有层都 fetch 回来硬塞进状态"。我的做法是按当前 zoom 算该用哪份,配置写成映射,缺的才去取。拿一份全国省市区县区划边界数据下载来之后到底拆几层,取决于你项目最细要画到哪一级——只到区县就拆三层,还要乡镇就在区县下面再挂一层。下面是我常写的一个切档小函数:

const VIEW = {
  LE: { min: 0, max: 4,  src: () => load('/views/national-light.geojson') },   // 远景聚合
  PROV: { min: 5, max: 7, src: (ad) => load(`/views/prov-${ad.slice(0,2)}.geojson`) },
  CITY: { min: 8, max: 10, src: (ad) => load(`/views/city-${ad.slice(0,4)}.geojson`) },
  CNTY: { min: 11, src: (ad) => load(`/views/cnty-${ad.slice(0,6)}.geojson`) }
};

function layerFor(zoom, adcode) {
  const v = zoom < 5 ? VIEW.LE : zoom < 8 ? VIEW.PROV
         : zoom < 11 ? VIEW.CITY : VIEW.CNTY;
  const key = v.min >= 11 ? adcode : v === VIEW.LE ? '' : adcode.slice(0, 4);
  if (cache.has(key)) return cache.get(key);
  const p = v.src(adcode);
  cache.set(key, p);
  return p;
}

代码里的要点有三:zoom 切档用尽量少的 fetch、同一份结果用 Map 缓存避免反复请求、以及远景层全局只有一份、近景层才按地域细分。切档时旧数据按 zoom 阈值卸载,而不是靠 setData 无限堆叠。

真正吞配置的暗坑:切档的边界值要留缓冲

第一版我栽在这上面:zoom 6.9 和 7.0 正好压线,来回拖动时组件在两个层级间反复互换,屏幕闪得像幻灯片。后来我把阈值做成半开区间再加切换缓冲——小于 6 用省级、进到 6.5 以上才预热市级,等真正过 7 时市级早就 ready 了。经验就是:横跨区划边界的接缝要多给 0.5 的余量,宁可多占一点预览,别让用户在界面上看到白屏。

说回选型:真到几十万要素连续拖拽都不掉帧的极端,再考虑矢量瓦片,而八成项目先做分级 LOD 就够了——数据侧拆两三层、前端按 zoom 换档,加载量往往能少一个数量级,近景细节还不受影响。这套法子对乡级、村级这类多层级源也通用,只是帮你把文件分得更贴近"当前视野该看清的粒度"。

先把"该画多少"算明白,再去纠结引擎参数,这条路我用下来最省钱。