1. 一句话结论
地图视口、瓦片格网、矢量瓦片内部几何,是三套不同的坐标语义。
任一环节"按名字像 EPSG、实则按另一套格网解释",就会出现整层平移式偏移。
本项目里 MBTiles(tippecanoe / Web Mercator)叠在 EPSG:4326 地图上时,偏移的根因正是:用 4326 的 z/x/y 直接去取、去画 3857 的 MVT。
2. 项目里实际存在哪些"空间参考"
不要把「地图 CRS」和「瓦片 schema」混成一件事。当前项目至少有四层语义:
| 层级 | 是什么 | 本项目典型选择 |
|---|---|---|
| 地图 CRS | Leaflet map.options.crs |
GWC/离线专题场景:EPSG:4326 ;默认在线场景:EPSG:3857 |
| 底图瓦片格网 | 天地图 *_w / *_c |
4326 图必须用 TianDiTu4326(DataServer *_c) |
| 专题矢量源 | GWC MVT vs tippecanoe MBTiles | GWC:EPSG:4326 格网 ;MBTiles:Web Mercator XYZ |
| 要素几何数值 | 经纬度 vs 投影米 / 瓦片局部坐标 | GeoJSON、属性坐标多为 WGS84;MVT 内多为瓦片局部整数 |
运行时策略集中在 buildRuntimeMapProfile:一旦出现 gwc_mvt(或配置/推断为 4326),整图切到 4326,并改用 baseLayersGwc,而不是继续挂 3857 天地图。
text
配置 / 数据源
│
▼
runtimeProfile(crs、底图列表、缩放策略)
│
▼
L.map({ crs: EPSG4326 | EPSG3857 }) ← 必须显式设置
│
├── 底图 TileLayer(与 crs 同源)
├── GWC VectorGrid(4326 格网 + 磁盘路径规则)
└── MBTiles VectorGrid(3857 取数 + 4326 几何纠偏)
原则:一张图只能有一个主角 CRS。 图层可以跨源,但不能假装自己和地图说同一种格网语言。
3. 两种瓦片格网:差在哪
3.1 Web Mercator(MBTiles / tippecanoe)
- 每级列数 = 行数 =
2^z - 覆盖近似全球(纬度裁到 ±85.0511°)
- 瓦片内几何:在墨卡托米上线性细分(extent 默认 4096)
- 规范 XYZ:
y从上往下;MBTiles SQLite 存的是 TMS (tile_row从下往上),服务端必须做:
text
tile_row_tms = (1 << z) - 1 - y_xyz
3.2 GeoServer / GWC 的 EPSG:4326 格网
- 每级:列
2^(z+1)× 行2^z(经度方向多一倍) - 分辨率(与 OpenLayers 默认 4326 不同):
text
resolution[z] = 360 / (2^(z+1) * 256)
- 磁盘路径:
EPSG_4326_ZZ/.../xxxx_yyyy.pbf,且 磁盘 Y = maxY − 视口 tileY - Leaflet
L.CRS.EPSG4326下 GridLayer 的coords与该格网可对齐;路径拼装见gwcTilePath.js
3.3 为什么不能"同号瓦片直接叠"
同一地理位置,在 4326 z 与 3857 z′ 上的 (x,y) 几乎从不相同:
- 列数差一倍 → 常见近似:
z_mercator ≈ z_4326 + 1 - 行索引含义不同(纬度线性 vs 墨卡托非线性)
- 即便中心点碰巧接近,瓦片四角与内部插值也早已分道扬镳
因此:共享 {z}/{x}/{y} 模板并不等于共享同一块地。
4. MBTiles 偏移:现象 → 成因 → 解法
4.1 现象
在已切换到 EPSG:4326 的地图上加载 tippecanoe MBTiles(mbtiles_mvt):
- 图层整体平移 / 错位(有时像"漂"了半个县)
- 缩放后错位模式改变(不是固定像素抖动)
- 同一数据在纯 3857 地图上可能正常
4.2 成因(拆成三刀)
-
索引错配
VectorGrid 按地图 CRS 生成 4326 的
coords,若原样填进 Mercator URL,请求到的是另一块瓦片,甚至 404 被 fallback 糊弄过去。 -
几何语义错配
tippecanoe 瓦片内坐标是「墨卡托平面上的局部坐标」。若渲染器按 4326「经纬度线性瓦片」去理解同一组
(px, py),多边形会被错误拉伸/偏移。 -
Y 轴再捅一刀(后端)
HTTP 侧常见 XYZ,MBTiles 库内是 TMS。只解决 CRS 不翻 Y,会出现上下镜像或南北错位。
一句话:不是数据坏了,是把 B 房间的门牌贴到了 A 房间的门上。
4.3 解法:动态纠偏(前端主路径)
实现:createMbtilesProtobufVectorGrid(mbtilesProtobufLayer.js)。
3857 地图: 标准 Protobuf 路径,不重投影。
4326 地图:
text
① 用 map.unproject 算出当前 4326 瓦片的地理 bounds
② z_m = z_4326 + 1,将 bounds 四角映射为若干 Mercator XYZ
③ 按 Mercator 索引拉取 1~N 片 PBF(上限 9,防高纬爆炸)
④ 每个顶点:
MVT 局部坐标
→ 墨卡托米(瓦片线性)
→ lon/lat
→ 当前 4326 瓦片局部坐标(经纬度线性)
⑤ 合并进当前 VectorGrid 瓦片,再交给样式渲染
关键换算(开发时务必保持与 tippecanoe 一致):
text
ORIGIN_SHIFT = 20037508.342789244
# MVT 局部 → 经纬度(墨卡托线性)
tileMeters = 2 * ORIGIN_SHIFT / 2^z
mx = -ORIGIN_SHIFT + tileX * tileMeters + (px/extent) * tileMeters
my = ORIGIN_SHIFT - tileY * tileMeters - (py/extent) * tileMeters
→ mercatorMetersToLonLat(mx, my)
# 经纬度 → 当前 4326 瓦片局部(与 Leaflet VectorGrid 一致)
lx = (lon - west) / (east - west) * extent
ly = (north - lat) / (north - south) * extent
业务侧入口:thematicLoader.js 的 mbtiles_mvt 分支注释已写明------tippecanoe = Web Mercator;4326 地图上必须走该定制加载器,而不是裸 L.vectorGrid.protobuf。
4.4 后端兜底(取瓦片索引)
mbtilesManager.readMbtilesTile 在直接命中失败时,会尝试:
- XYZ → TMS 翻 Y
- 简单半宽猜测(
x >> 1) guessMercatorXyzFromEpsg4326Xyz:用 4326 瓦片中心估 Mercator 索引
这能减少「错 z 访问」的空瓦片,但不能替代前端几何重写 。
偏移问题要在「画」这一层解决;后端只能帮「取对哪一块」。
5. 当前项目的空间参考「决策树」
适合做 Code Review / 新图层接入时的清单:
text
新图层接入?
├─ 是否强制依赖 GWC 4326 缓存?
│ └─ 是 → 整图 CRS=EPSG:4326
│ ├─ 底图:baseLayersGwc / TianDiTu4326(*_c)
│ ├─ 专题:gwc_mvt → createGwcProtobufVectorGrid
│ └─ 若还要叠 tippecanoe MBTiles → 必须 createMbtilesProtobufVectorGrid
│
├─ 仅在线 3857?
│ └─ CRS=EPSG:3857,底图 TianDiTu.*(*_w),MBTiles 可走标准路径
│
└─ GeoTIFF / GeoJSON?
└─ 数据建议 WGS84;4326 底图最稳;proj4 / georaster 注意全局挂载
MapView 初始化关键点:
- 始终显式
crs: resolveLeafletCrs(...),禁止依赖 Leaflet 默认 3857 - GWC 模式下限制部分 locate 的
allowZoom,降低 4326 底图与缩放链路抖动 - 天地图 4326:
zoomOffset: 1(DataServer 的 L 从 1 起)、maxNativeZoom: 17
6. 开发必踩 / 已踩过的坑
坑 A:一张图混用 3857 底图 + 4326 专题
症状:底图一块大陆,专题飞到海里。
对策:usesGwcMvt / buildRuntimeMapProfile 已做整图切换;新增数据源时同步声明 source.crs 或落在正确的 baseLayersByCrs。
坑 B:以为 {z}/{x}/{y} 全局通用
症状:MBTiles / GWC / 自研目录瓦片「有时候能出来、位置不对」。
对策:先问清 schema(3857 XYZ / 4326 GWC / TMS),再写 URL 或纠偏逻辑。
坑 C:只改 URL 不重投影几何
症状:请求已按 z+1 打到正确瓦片,形状仍歪。
对策:MVT 顶点必须做「墨卡托局部 → lon/lat → 4326 局部」。
坑 D:XYZ / TMS 的 Y 轴
症状:上下颠倒或南北对称错位。
对策:读 MBTiles 一律 xyzToTmsRow;前端对外 URL 明确约定 XYZ。
坑 E:GWC 磁盘 Y 与 Leaflet tileY
症状:本地 gwc 目录文件明明存在,却 404。
对策:yDisk = range.maxY - tileY,块目录 padding / 文件名位数随 z 变化,不要写死「128 块 + 六位文件名」。
坑 F:OpenLayers 默认 4326 格网 ≠ GeoServer GWC
症状:自算分辨率后路径全错。
对策:用本仓库的 buildGeoServer4326GwcResolutions(2^(z+1)×2^z)。
坑 G:天地图 *_w / *_c 搞反
症状:4326 图上底图空白或级别对不上。
对策:4326 → TianDiTu4326 + zoomOffset: 1;3857 → 常规 TianDiTu。
坑 H:切片前源数据 CRS 不统一
症状:导出 GeoJSON / tippecanoe 后整体漂(坐标系真漂)。
对策:入库前统一 ogr2ogr -t_srs EPSG:4326(或明确业务坐标系再转),不要把 CGCS2000 高斯投影当经纬度喂 tippecanoe。
坑 I:栅格导出 / canvas 合成错位
症状:出图比屏幕差几个像素。
对策:与 CRS 无关的另一类问题------canvas padding、transform、VectorGrid 图层栈;见 captureLeafletMapCanvas.js,勿与瓦片 CRS 偏移混治。
7. 接入新图层的最小自检
做完一个 mbtiles_mvt / gwc_mvt / 混叠需求,建议按表打勾:
- 地图 CRS :控制台或断点看
map.options.crs.code - 底图 :当前可见底图 URL 是否含
_c/_w,与 CRS 一致 - 取数索引 :Network 里 PBF 的
z/x/y是不是目标 schema 的索引 - 几何:抽样一个顶点是否从「墨卡托局部」走到了「当前瓦片局部」
- Y 轴:MBTiles 服务端是否 TMS 翻行;GWC 是否磁盘 Y 翻转
- 缩放:跨 2~3 个 zoom,错位是否「整洁地」消失(真纠偏后应贴合),而不是随机跳
快速心算:
text
湖南中低纬附近:
同一视口,4326 的 z=12 瓦片 ≈ 覆盖约 3857 的 z=13 数个瓦片(经向更宽)
若 URL 仍打 z=12 且 x 沿用 4326 列号 → 几乎必偏
8. 设计取舍(为什么不「全部改回 3857」)
| 方案 | 优点 | 成本 |
|---|---|---|
| 整图改 3857,重切 GWC | 生态兼容好,MBTiles 零纠偏 | 已有离线 EPSG_4326_* 缓存作废,运维重切 |
| 整图 4326,MBTiles 动态纠偏(现状) | 保住 GWC 资产;前端一处封装 | 4326 下 MBTiles 有 CPU/合并开销 |
| 双地图实例切换 | 隔离彻底 | UX / 状态同步复杂 |
现状选择的是:以 GWC 4326 为离线主战场,对 tippecanoe 做前端几何纠偏。
新需求优先复用 createMbtilesProtobufVectorGrid,不要再复制一套半成品换算。
9. 相关代码索引
| 主题 | 位置 |
|---|---|
| MBTiles@4326 纠偏 | frontend/src/utils/mbtilesProtobufLayer.js |
| 专题加载分支 | frontend/src/map-sdk/thematicLoader.js(mbtiles_mvt / gwc_mvt) |
| CRS / 底图策略 | frontend/src/map-sdk/runtimeProfile.js |
| Leaflet CRS 解析 | frontend/src/map-sdk/geoSdk.js |
| 地图初始化强制 CRS | frontend/src/components/MapView.vue |
| GWC 路径与分辨率 | frontend/src/utils/gwcTilePath.js |
| 底图 4326 zoomOffset | frontend/src/map-sdk/layerFactory.js |
| MBTiles 读库 + TMS + 索引猜测 | electronend/src/main/mbtilesManager.ts |
| 切片流水线 | electronend/scripts/tiles-pipeline.md |
10. 小结
空间数据工程里最贵的不是算法,而是同名不同义:都叫「第 12 级第 x 列」,却可能生活在两个星球。
记住三句话即可少踩坑:
- 先定地图 CRS,再选底图与专题源。
- 瓦片模板相同,不代表格网相同。
- 跨格网叠 MVT:取对瓦片只是上半场,重写几何才是终场哨。
下次再看到「图层偏了」,先别急着怀疑 SHP------先问:z/x/y 是谁的方言,顶点又是按哪张纸上的尺子画的。