GeoJSONcn
GeoJSONcn / 技术专栏 / 用 GeoJSON 把区县边界拉…

用 GeoJSON 把区县边界拉成 3D 统计柱:fill-extrusion 踩坑,全国省市区县数据处理先行

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

一般二维 choropleth 看够了,总想玩玩把行政区划"立起来"的效果——让每个省份或区县的高度代表人口、GDP、或者某个统计值,拉成一座座 3D 柱子,视觉冲击力比扁平着色强太多。我这阵子在给一批全国省市区县geojson数据下载回来后的属性做可视化,试了把市级边界直接扔给 MapLibre 的 fill-extrusion,第一版几乎是立刻翻车:地面穿模、柱子歪在海上、边界的"檐"翘得老高。折腾了两天才摸清门道,这篇就把从 GeoJSON 面到 3D 柱状地形的完整链路和踩坑记一遍。

先说结论:fill-extrusion 入口不难,难的是你喂进去的那份 GeoJSON 对不对、数值档怎么切、坐标基准对不对齐。把它当作"二维行政区划的另一种渲染高层封装"看,前面系列-22 讲渲染选型、系列-17 讲按 adcode 关联数值的底子,到这里全都能复用。

一、面向 3D 的 GeoJSON 预处理,跟 2D 差在哪

2D 时代我们只要面、保证环合法、坐标是经纬度就够了。但一上 extrusion,就有三件事必须提前干,缺一件都会画得很难看:

1. 坐标必须是可投影的经纬度(WGS84/GCJ-02 的度值即可),fill-extrusion 走的是 Web 墨卡托内部换算,乱填平面坐标会让柱子接到奇怪的位置——这和 series-48 里说的 CGCS2000 与 WGS84 叠图同理,可视化场景不用转,度值直接喂。 2. 每个 feature 要带一个数值属性,比如 value。没有数值,引擎没法决定柱高。 3. 数值别直接用原始统计值,跨县人口从几十万到上千万差几十倍,一根顶天一根贴地,观感全毁。通常要先做分级或归一化。

我建议的预处理脚本大概长这样,输入是"区县面 + 一张统计表",用 adcode 配 key 合并(复用 series-17 的 join 思路),再把 value 做归一化到 1~40 的柱高区间:

// prep3d.mjs —— 给区县面加 height/color 属性
import { readFileSync } from 'node:fs';
const geojson = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const stats = JSON.parse(readFileSync(process.argv[3], 'utf8')); // [{adcode, val}]
const min = Math.min(...stats.map(s => s.val));
const max = Math.max(...stats.map(s => s.val));
// 线性归一化到 [1, 40],保底 1 避免零高柱子看不见
const toHeight = v => 1 + ((v - min) / (max - min)) * 39;

const out = { type: 'FeatureCollection', features: [] };
const map = new Map(stats.map(s => [String(s.adcode), s.val]));
for (const f of geojson.features) {
  const code = String(f.properties?.adcode ?? '');
  const val = map.get(code);
  if (val === undefined) continue;          // 找不到统计值的县级面直接跳过,避免悬空柱
  out.features.push({
    ...f,
    properties: { ...f.properties, height: toHeight(val), value: val }
  });
}
console.log(JSON.stringify(out));

三步里最容易出问题的其实不是高度,而是第 2 步的 join 失败。统计表里 adcode 若是从 Excel 拷出来的数字,很容易丢前导零——series-37 讲过这事,610102 变 610102 剩不下问题,可一旦某县是 0 打头(比如 110000 市辖区思路里的区级码)就会错配。所以 prep 之前一定要先 String(adcode).padStart(6, '0') 对齐,不然好端端的柱子可能插到邻居县的头顶上。

二、MapLibre 上把 fill-extrusion 接起来:图层与关键参数

预处理完,接一层 fill-extrusion 就很容易了。以全国省为单位看人口分布,核心在数据驱动样式里把 fill-extrusion-height 绑定到 height,再给个不透明的顶色让柱子看着有"体积感":

map.addLayer({
  id: 'prov-3d',
  type: 'fill-extrusion',
  source: 'prov',
  paint: {
    'fill-extrusion-height': ['get', 'height'],
    'fill-extrusion-base': 0,
    'fill-extrusion-color': ['interpolate', ['linear'], ['get', 'value'], min, '#f7fbff', mid, '#2171b5', max, '#08306b'],
    'fill-extrusion-opacity': 0.9
  }
});

几个我踩过的参数坑,列成一张表方便以后自查:

参数作用容易踩的坑
fill-extrusion-height决定柱高读不到数值会整面平铺,先 console.log 看属性名对不对
fill-extrusion-base柱子底面离地高度一般设 0;设了非 0 会做出"悬浮空层"效果
fill-extrusion-color柱体颜色用 interpolate 表达数值、比直接定一色更能看出梯度
fill-extrusion-opacity透明度设 1 顶面看着实、低于 1 能透光但边缘易糊
相机 pitch俯视角小于 40° 柱子几乎看不见"高",建议 45~70°

对格最关键的一条:数据里 height 一定得解析成数字。很多从 CSV 合并进来的列是字符串 "120.5",['get','height'] 拿到字符串时引擎会把它当 0 或直接忽略,表现出来就是一片没高度的平地,排查了半天还怪到 API 头上。稳妥做法是在 prep 脚本里 Number(...) 一次,或者在 map 前遍历把数字列统一 + 一下。

三、做出来容易,做"好看"再看这几处细节

柱状地形最怕的不是柱子拉不起来,而是柱子之间互相挡得看不出谁高谁低,以及没做高度/配色分级显得很"平"。我现在的做法是数值先切 4~6 档再做归一化——把连续值离散化,柱高梯度清晰,配 interpolate 的颜色过渡,视觉层次一下子出来了,这也是 series-17 提过"四种分级方法"的应用,只是从 2D 搬到了 3D。

另外被低估的一步是性能。全国一起拉成 296 个地市柱还行,可你要是把 2800 多个区县全 ext 起来,单 FeatureCollection 一次喂给前台是有压力的——series-22 讲"一次画 2800 个面就卡",到 3D 更明显,因为多了按柱高高度生成的几何体。我的替换方案是:先按省级切片,用户点到哪一级再拉对应子集(series-33 的分块加载思路复用到 3D 同样成立),而不是把全国边界一股脑塞进一个 3D 图层。

回看这套做法,真正决定成败的其实就两件事:一是喂给引擎的 GeoJSON 是否干净、每面都有数值(复用全国省市区县区划边界数据下载后的常规校验),二是数值是否做了合理的分级与归一化。它不算玄学,只是把二维那套思路平移到第三个维度后,多了坐标对齐、数值分层这两道必须提前处理的工序。地图面不"错位"、柱子不"穿模",一座座统计柱立起来展示人口与产值,预览仍免费,想拿全国省市区县geojson数据下载的基础面数据、再叠加你自己的统计去渲染,走按次付费(¥1.99/次)就行。也有人先下个区县或市级的 GeoJSON下载回来当轮廓打底,再在上面叠加自己的点数据一起立起来看,效果也挺直观。数据还是那批静态 GeoJSON,变的只是渲染层怎么把它讲得立体一点罢了。