GeoJSONcn
GeoJSONcn / 技术专栏 / 把全国区县边界切成矢量瓦片:ti…

把全国区县边界切成矢量瓦片:tippecanoe 参数与 PMTiles 单文件部署

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

--- title: 把全国区县边界切成矢量瓦片:tippecanoe 参数与 PMTiles 单文件部署 ---

整包 GeoJSON 丢给浏览器,数据量一大就撑不住。全国 2800 多个区县面,压缩后仍有几十 MB,首屏白屏十几秒。这时候再去调渲染没用,卡住的是传输和解析,不是绘制。得把数据切成矢量瓦片,按视野、按缩放级取。

为什么是切片,不是继续压

抽稀能把顶点砍掉八成,TopoJSON 能让共享边只存一份。可它们改不掉一件事:客户端拿到的还是全国所有要素。只看广州一个市,新疆和黑龙江照样得先下载完。

矢量瓦片是另一套思路。数据在服务端按 z/x/y 网格预先切开,每块单独编码成 Protobuf,也就是 MVT。浏览器只请求当前视野盖到的那几块,一块通常几十 KB。低缩放级的瓦片里,要素已经被简化甚至丢掉了一部分,放大到高级别才给完整精度。体积优化的其他路子,可以看 TopoJSON 压缩 和 Douglas-Peucker 抽稀。

MVT 内部存的是相对坐标,每个瓦片划成 4096×4096 的整数网格,经纬度编码时映射进去。瓦片里没有浮点数,体积自然就下来了。

tippecanoe 的几个关键参数

切片工具首选 tippecanoe。最小可用的命令长这样:

tippecanoe -o china-district.mbtiles \
  -l district \
  -zg \
  --drop-densest-as-needed \
  --extend-zooms-if-still-dropping \
  china-district.geojson

-l district 定图层名,前端配 source-layer 得用同一个。-zg 让它自己推最大缩放级,依据是要素密集程度。

--drop-densest-as-needed 是保命参数。tippecanoe 单块瓦片默认上限 500KB,超了直接报错退出。加上它之后,密集区域会主动丢掉一部分要素压进限额,而不是整个任务失败。配套的 --extend-zooms-if-still-dropping 管的是另一头:到了最大缩放级还在丢,就自动往上加级数。

属性字段也要瘦身:

tippecanoe -o china-district.mbtiles \
  -l district \
  -zg \
  -y adcode -y name \
  -S 4 \
  --drop-densest-as-needed \
  --read-parallel \
  china-district.geojsonl

-y 是属性白名单,只留 adcode 和 name。行政区划数据经常带着面积、周长、拼音、上级代码一长串字段,每个要素重复一份,切完能占掉瓦片体积的一半。-S 4 调简化强度,默认值是 1,调到 4 之后低缩放级的边界明显平滑,体积还能再降。

--read-parallel(简写 -P)走多核并行读入,前提是输入为按行分隔的 GeoJSON 序列,也就是 .geojsonl。普通 GeoJSON 怎么转过去,流式处理超大 GeoJSON 里讲过。

MBTiles 转成 PMTiles

tippecanoe 吐出来的是 MBTiles,说白了是个 SQLite 数据库。想用它就得跑一个瓦片服务器,接 z/x/y 请求、查库、返回 Protobuf。多一个常驻服务,多一份运维。

PMTiles 把这一层省了。它是单个文件,内部自带目录索引,客户端先用 HTTP Range 请求取索引,再按偏移量直接抓某一块瓦片的字节区间。全程只要一个支持 Range 的静态文件服务,对象存储和 CDN 都现成支持。

pmtiles convert china-district.mbtiles china-district.pmtiles
pmtiles show china-district.pmtiles

pmtiles show 会打元数据,缩放范围、图层名、要素数量都在里面。切完先看一眼,确认没切歪。

三种分发方式的差别:

方式文件形态服务端要求缓存适合场景
散装 z/x/y.pbf几万到几十万个小文件静态服务即可CDN 按瓦片缓存,命中率高瓦片数量可控、追求极致首字节
MBTiles + 服务单个 SQLite需常驻瓦片服务要自己加缓存层已有后端、需动态过滤
PMTiles单个文件静态服务 + 支持 RangeCDN 按字节区间缓存纯静态托管、想省运维

散装瓦片最烦人的是文件数量。全国区县切到 z12,文件数轻松过十万,传对象存储要跑很久,后续删除和更新一样痛苦。PMTiles 只有一个文件,替换就是更新。

前端接上去

MapLibre GL 不认识 pmtiles://,得先注册协议处理器:

import maplibregl from 'maplibre-gl';
import { Protocol } from 'pmtiles';

const protocol = new Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);

const map = new maplibregl.Map({
  container: 'map',
  center: [104.1, 35.8],
  zoom: 3,
  style: {
    version: 8,
    sources: {
      district: {
        type: 'vector',
        url: 'pmtiles://https://geojsoncn.com/tiles/china-district.pmtiles',
        promoteId: 'adcode'
      }
    },
    layers: [
      {
        id: 'district-fill',
        type: 'fill',
        source: 'district',
        'source-layer': 'district',
        paint: {
          'fill-color': '#e8f0fb',
          'fill-outline-color': '#3b6fb5'
        }
      }
    ]
  }
});

source-layer 必须等于切片时 -l 给的图层名。这两个对不上是最常见的翻车点,地图正常加载,控制台一声不吭,页面上什么都没有。

promoteId: 'adcode' 把属性里的 adcode 提升成要素 id。矢量瓦片和整包 GeoJSON 的渲染取舍不太一样,可以对照 前端渲染三条路线 看。

三个容易踩的坑

跨瓦片的要素会被切开。一个区县横跨两块瓦片,它在两块里各占一半,是两个独立几何。做 hover 高亮时不设 promoteId,这两半就是两个要素,鼠标移上去只亮一半。设了之后共享同一个 id,setFeatureState 一次生效。

低缩放级的要素可能不全。--drop-densest-as-needed 丢的是整个要素,不是简化它。z3 的全国视野下,密集区域的某些区县压根不在瓦片里,这时候用 queryRenderedFeatures 数数量会偏少。要统计就回原始数据算,别信渲染结果。

坐标系必须是 WGS84。tippecanoe 只吃 EPSG:4326 的经纬度,切片时按 Web 墨卡托规则映射到瓦片网格。源数据要是 GCJ-02 或者 CGCS2000 投影坐标,转好了再切;切完才发现偏移,只能整个重来。行政区划数据可以在 geojsoncn.com 找。

什么时候别切

数据量不大就别折腾。单个省的区县边界压缩后一两 MB,直接加载体验够用,切片只会多出构建环节和更新成本。我自己的分界线是:整包压缩后超过 5MB,或者要素数超过 5000 个,切片才划算。