适合已经会 Vue / 原生 JS,想在 Web 里做「可交互地图」,并且需要 平面底图 + 球面浏览 的同学。文中 API 基于 MapLibre GL
^5.x
写在前面:这里的「3D」是什么
做地图产品时,很多人一听到「3D」会想到:
- DEM 地形抬升(
setTerrain) - 建筑物拔高(
fill-extrusion) - 天空 / 大气雾效(sky / fog)
这些 MapLibre 都能做,但不是本文讨论的路径。
本文说的 2D / 3D 是:
| 模式 | 投影 | 观感 |
|---|---|---|
| 2D | mercator |
传统平面瓦片世界 |
| 3D | globe |
地球仪球面,可俯仰观察 |
同一张 Map 实例,只切投影,不销毁重建;覆盖物仍是普通 GeoJSON 的 fill / line / circle / symbol,贴在平面或球面上。
这种方案的优点很直接:
- 一套图层栈覆盖两种视图,不用维护两套引擎。
- 切换成本低 :
setProjection+ 少量布局属性同步。 - 几何语义统一:数据永远是 WGS84,命中检测也能共用。
一、最小可用的 Map 初始化
建议把 maplibre-gl 动态 import,避免首屏把 WebGL 库塞进主包:
ts
import type { Map as MapLibreMap } from 'maplibre-gl'
let map: MapLibreMap | null = null
async function createMap(container: HTMLElement) {
const maplibregl = await import('maplibre-gl')
await import('maplibre-gl/dist/maplibre-gl.css')
map = new maplibregl.Map({
container,
style: {
version: 8,
sources: {
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap',
},
},
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
// symbol 文字需要 glyphs;演示可先用官方 demo 字体
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
},
center: [116.4, 39.9],
zoom: 4,
minZoom: 3,
maxZoom: 18,
attributionControl: false,
fadeDuration: 0, // 业务交互多时可关掉淡入,减少「闪一层」的感觉
dragRotate: false, // 右键留给自己的上下文菜单时很有用
})
await new Promise<void>((resolve, reject) => {
map!.once('load', () => resolve())
map!.once('error', (e) => reject(e.error ?? new Error('MapLibre load failed')))
})
// 默认进球面;若只要平面,改成 mercator 即可
map.setProjection({ type: 'globe' })
map.setMaxPitch(85)
return map
}
几个实践细节:
- 等
load再setProjection/addSource,否则容易踩「style 还没好」的时序问题。 - 用单例或
pendingInitPromise 防抖,避免 VueonMounted+ 热更新导致重复new Map。 dragRotate: false很常见:球面浏览主要靠 pitch / pan,右键旋转和业务菜单容易冲突。
二、2D ↔ 3D:同一实例切投影
核心就几行:
ts
type ViewMode = '2d' | '3d'
function setViewMode(map: MapLibreMap, mode: ViewMode) {
if (mode === '3d') {
map.setProjection({ type: 'globe' })
} else {
map.setProjection({ type: 'mercator' })
// 回到平面时把俯仰、朝向掰正,避免留下「歪着看世界」的残态
map.easeTo({ pitch: 0, bearing: 0, duration: 0 })
}
}
配套建议:
- UI 状态单独存 (如
viewMode: '2d' | '3d'),地图只做执行层。 - 切投影后刷新依赖投影的表现 (标注 pitch 对齐、可见性 declutter、路径线宽手感等),但不必重装整份 GeoJSON------几何没变。
- 容器加 CSS class(
--2d/--3d)方便做光标、遮罩等样式差异。
架构可以简化成:
text
Toolbar 切换 viewMode
↓
Map 编排层 watch
↓
setProjection(globe | mercator)
+ easeTo 复位(仅回 2D)
+ 同步 symbol 的 pitch-alignment
+ 按需刷新标注/交互半径
三、底图用 Raster,业务层用 GeoJSON
很多业务地图并不需要完整矢量底图样式,更常见的是:
text
Style Spec v8
├── raster 底图(OSM / 卫星 / 地形影像瓦片)
└── 业务 overlay(GeoJSON → fill / line / circle / symbol)
叠加层初始化大致如下:
ts
function initOverlaySources(map: MapLibreMap) {
map.addSource('polygons', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
promoteId: 'id', // 配合 feature-state 做高亮
})
map.addLayer({
id: 'polygons-fill',
type: 'fill',
source: 'polygons',
paint: {
'fill-color': [
'case',
['boolean', ['feature-state', 'hover'], false],
'#3b82f6',
'#60a5fa',
],
'fill-opacity': 0.35,
},
})
map.addLayer({
id: 'polygons-outline',
type: 'line',
source: 'polygons',
paint: {
'line-color': '#1d4ed8',
'line-width': 1.5,
},
})
}
// 后续只更新数据
function setPolygons(map: MapLibreMap, fc: GeoJSON.FeatureCollection) {
const src = map.getSource('polygons') as maplibregl.GeoJSONSource
src.setData(fc)
}
高亮 优先用 setFeatureState,避免每次 hover 重写整份 setData:
ts
map.setFeatureState({ source: 'polygons', id: featureId }, { hover: true })
运行时换底图:删旧 raster layer/source → 在业务层 之前 addSource / addLayer → triggerRepaint(),保证业务图元始终叠在底图上面。
四、Globe 上最容易踩的坑:球外像素
平面模式下,画布内任意点 unproject 通常都合理;球面模式下,画布四角经常是「太空」,不是地表。
如果直接信 mousemove 事件里的 lngLat,或对球外点做查询,会出现:
- 悬停落在地平线外却「命中」某个要素
- 点击空白宇宙却触发选中
稳妥写法是:先判定像素是否在地球表面上,再反投影:
ts
import { Point } from 'maplibre-gl'
function isPixelOnMapSurface(map: MapLibreMap, x: number, y: number): boolean {
if (map.getProjection()?.type !== 'globe') return true
try {
// 内部 transform API:判断该像素是否落在球体可见面
return map.transform.isPointOnMapSurface(new Point(x, y))
} catch {
return false
}
}
function pickLonLat(map: MapLibreMap, x: number, y: number): { lon: number; lat: number } | null {
if (!isPixelOnMapSurface(map, x, y)) return null
const { lng, lat } = map.unproject([x, y])
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
return { lon: lng, lat }
}
交互流建议:
text
pointer → 是否在球面上?
→ unproject 得经纬度
→ 用 WGS84 几何做命中(Turf / 自研空间索引)
→ setFeatureState / 高亮 GeoJSON 反馈
对于大面积多边形 ,纯靠 queryRenderedFeatures 往往不够稳(堆叠顺序、半透明 fill、2D 世界拷贝等)。在经纬度里做 hit-test,2D / 3D 还能共用同一套几何判断。
点要素(航点、POI)可以反着来:先 project 到屏幕,再算像素距离;2D / 3D 拾取半径也可以不同(例如平面 28px、球面 22px),手感会更自然。
五、标注在球面与平面上要「两种对齐」
symbol 层在 globe 下若仍按 viewport 对齐,俯仰时文字会「立在屏幕上」;很多场景更希望文字贴着球面倾斜。
投影切换时同步布局属性即可:
ts
function syncLabelPitchAlignment(map: MapLibreMap, layerId: string) {
const globe = map.getProjection()?.type === 'globe'
const align = globe ? 'map' : 'viewport'
map.setLayoutProperty(layerId, 'text-pitch-alignment', align)
map.setLayoutProperty(layerId, 'text-rotation-alignment', align)
map.setLayoutProperty(layerId, 'icon-pitch-alignment', align)
map.setLayoutProperty(layerId, 'icon-rotation-alignment', align)
}
额外经验:
- 背景色尽量用 预生成的
addImage+icon-text-fit,比用经纬度fill多边形当「文字底板」更稳------后者缩放时要跟着屏幕尺寸重算,容易和文字不同步。 - 低缩放级别务必做 屏幕空间 declutter(投影到像素后做矩形碰撞),否则全球一缩,标注糊成一片。
- Globe 总览时瓦片 / 要素密度体感会变,可按纬度修正等效 zoom,再决定「本视口最多画多少要素」。
六、相机与布局:常用动作清单
| 需求 | API |
|---|---|
| 平滑飞到目标 | flyTo({ center, zoom, duration }) |
| 瞬时复位视角 | jumpTo({ center, zoom }) / easeTo({ pitch, bearing, duration: 0 }) |
| 框住某个 bbox | cameraForBounds → 再 flyTo,或直接 fitBounds |
| 工具条缩放 | zoomTo(map.getZoom() ± 1, { duration: 200 }) |
| 侧栏挤占可视区 | setPadding({ left/right/... }),让地球「视觉中心」落在未遮挡区域 |
| 布局变化后 | resize()(v-show、分栏拖拽后必做) |
| 绘制工具进行中 | 临时 dragPan.disable() / scrollZoom.disable(),避免和画多边形抢事件 |
configure 阶段常见组合:
ts
map.setMaxPitch(85)
map.setMinZoom(3)
map.setMaxZoom(18)
map.dragRotate.disable()
注意:若产品状态只持久化 center + zoom,切回 3D 时 pitch 不会自动恢复------这通常是预期行为;需要「记住俯仰角」要另存字段。
七、性能:别让建索引抢走首帧
MapLibre 自己已经用 Worker 拉瓦片,但业务侧如果还要:
- 解析大批量覆盖面
- 建空间网格
- 预烘焙 Feature / 锚点
请放到 应用自己的 Web Worker ,主线程优先把地图 load 出来。
推荐顺序:
- 动态加载 MapLibre,创建 Map,跑通底图。
- Worker 里烘焙 GeoJSON / 索引。
setData增量喂给 overlay。- 视口变化时用空间索引取「当前该画的子集」,控制
setData频率(可挂在moveend/idle,移动中可跳过重计算)。
Globe 低缩放时尤其要做 LOD / 数量上限,否则一球要素把 GPU 和主线程一起打满。
八、一张图串起来
text
┌─────────────┐
│ UI:2D/3D │
└──────┬──────┘
▼
┌──────────────────────────────┐
│ MapLibre Map(单例) │
│ style: raster 底图 │
│ projection: mercator|globe │
│ overlays: GeoJSON 图层栈 │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ 交互:像素 → 球面校验 → 经纬度 │
│ → WGS84 命中 → 视觉反馈 │
└──────────────────────────────┘
▲
┌──────────────┴───────────────┐
│ Worker:重数据预处理 / 索引 │
└──────────────────────────────┘
设计原则可以压成五句:
- 单引擎双投影,不要为了 3D 再挂一个引擎。
- 3D = globe,先把球面交互做对,再考虑 terrain / extrusion。
- 数据永远是 WGS84,渲染跟投影走,命中逻辑尽量共用。
- 球外像素一律视为无效 ,再
unproject。 - 重计算进 Worker,轻
setData进 MapLibre。
九、和「真 · 三维 GIS」怎么选
| 目标 | 更合适的方向 |
|---|---|
| 地球仪感、全球浏览、2D↔3D 切换 | globe 投影(本文) |
| 山地起伏、等高、剖面 | terrain + DEM |
| 城市白模、楼块高度 | fill-extrusion / 矢量建筑数据 |
| 复杂三维场景、模型编辑 | Three.js / Cesium 等(成本完全另一档) |
很多产品其实只需要「能把世界看成球,并在上面点选面要素」------这时 MapLibre Globe 往往够用,也更贴近 2D Web 地图的工程习惯。
小结
MapLibre 5.x 把 Mercator / Globe 收敛到同一套 Style 与图层模型里,让「2D + 3D」不必拆成两套技术栈。落地时真正拉开差距的,往往不是 new Map 那几行,而是:
- 投影切换后的 姿态复位与标注对齐
- Globe 下的 球外拾取防护
- 大批量 overlay 的 Worker + 视口裁剪
setPadding/resize/ 交互锁这些「工程边角」
如果你正从 Mapbox / Leaflet 迁到 MapLibre,优先把 投影切换 + 统一经纬度命中 跑通;地形抬升和建筑拔高,可以放进下一迭代。