💡 前言

最近在做全球电网可视化需求,要在一个 Cesium 三维地球上把全世界的输电线路、变电站这些要素按图层管理起来,还支持点哪高亮哪。

刚好openinframap就有这个公开数据的mvt服务,于是问题就简单了,在Cesium中把数据渲染出来就行。但是Cesium没有直接的mvt数据源支持,需要借助社区的第三方工具,我这里选用的是:mvt-imagery-provider,它就是这次需求的主角,本文把我跑通的方案(基础加载、无缝切换、图层显隐、点击高亮)整理出来,希望能帮你少走弯路

全球电网


🔍 先搞懂:栅格瓦片 vs 矢量瓦片到底差在哪?

为了不让后面的代码变成”复制粘贴一把梭”,先花一分钟把概念捋清楚。

栅格瓦片(XYZ/TMS):服务器把地图提前渲染成一张张 PNG/JPEG 图片,前端拿到啥就是啥,跟看照片一样。

矢量瓦片(MVT):服务器只发”几何数据 + 属性”(线条、面、坐标、字段),渲染这件事交给前端,前端想要什么颜色、什么样式,自己说了算。

打个比方:栅格瓦片像外卖送来的成品盒饭,你能选的就是要不要辣椒;矢量瓦片像送来的一袋食材 + 菜谱,你想怎么炒、放多少盐,都是你自己掌控。

所以对于本文的需求(动态切样式、按图层开关、要素可点),MVT 是唯一正解


🛠️ 库的选择:为什么是 mvt-imagery-provider?

Cesium 原生不直接吃 MVT,常见的轮子有几个,我对比下来选了 mvt-imagery-provider(类型定义齐全、支持 Mapbox Style 规范、能直接挂在 viewer.imageryLayers 上)。

安装:

1
npm install mvt-imagery-provider

它最大的好处是:把矢量瓦片包装成一个 ImageryProvider,可以像普通影像图层一样加进 Cesium,这让我们后面的图层切换、显隐控制都能复用 Cesium 原生 API,省心


🚀 实战:从加载到点击高亮

我们的目标是这样一个场景:有一个 currentStyle(Mapbox Style 对象),它描述了所有图层的渲染规则;用户可以切换样式、开关图层、点击要素高亮,且所有操作都无缝不闪

1. 基础加载与图层重载

我们先定义一个全局的 currentLayer(当前图层引用)和 currentStyle(当前样式),然后写一个 reloadLayer 方法,后续所有样式变动都靠它来”换图层”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import MVTImageryProvider from 'mvt-imagery-provider'

// ==========================================
// 核心方法:重载图层(无缝切换)
// ==========================================
async function reloadLayer() {
const newProvider = new MVTImageryProvider({
style: currentStyle
})

// 等待 provider 准备就绪
await newProvider.readyPromise

// 先把新图层加进来,但透明度设为 0(用户暂时看不到)
const newLayer = viewer.imageryLayers.addImageryProvider(newProvider as any)
newLayer.alpha = 0

// 更新引用:新图层上岗,旧图层准备退休
const oldLayer = currentLayer
currentLayer = newLayer

// 关键:把"显隐切换"交给专门的函数处理
swapLayerWhenReady(newLayer, oldLayer)
}

这里有个关键设计:新图层进来时 alpha 是 0,旧图层先不动。等新图层瓦片全部加载好了,再在同一帧里把新图层变成 1、旧图层干掉——这就是”无缝”的核心思路。

2. 无缝切换:解决闪动和亮度叠加

直接 remove 旧图层再加新图层,会出现两个让人抓狂的问题:

  1. 闪一下:旧图层没了,新图层还没渲染完,地球上一片空白。
  2. 亮度叠加:新旧两层同时显示的瞬间,影像会”白一下”(两个半透明图层叠一起)。

下面这个 swapLayerWhenReady 就是这两个坑的标准解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 解决了图层重绘时的闪动、亮度叠加问题
function swapLayerWhenReady(newLayer: Cesium.ImageryLayer, oldLayer: Cesium.ImageryLayer | null) {
// 设置超时保护(假设 2 秒后强制切换,防止一直在加载中导致切不过去)
let timeoutId: any = null

// 监听器:每帧渲染完检查一次
const removeListener = viewer.scene.postRender.addEventListener(() => {
// ==========================================
// 关键步骤 B:判断瓦片是否加载完成
// ==========================================
// viewer.scene.globe.tilesLoaded 为 true 表示当前视野内所有瓦片都渲染好了
// 此时我们切换,用户感觉就是无缝的
const isReady = viewer.scene.globe.tilesLoaded

if (isReady) {
executeSwap()
}
})

// 定义切换动作
const executeSwap = () => {
// 1. 清除监听器和定时器
removeListener()
if (timeoutId) clearTimeout(timeoutId)

// 2. 【核心操作】同一帧内完成显隐切换
if (!viewer.isDestroyed()) {
// 瞬间显示新图层
newLayer.alpha = 1.0

// 瞬间移除旧图层
if (oldLayer && viewer.imageryLayers.contains(oldLayer)) {
viewer.imageryLayers.remove(oldLayer)
}
}
}

// 设置 200ms 超时强制切换
// 防止因为某个瓦片加载卡住,导致用户一直操作没反应
timeoutId = setTimeout(() => {
executeSwap()
}, 200)

// 触发一次渲染,确保 Cesium 开始调度瓦片
viewer.scene.requestRender()
}

为什么这样写就不闪、不白? 把这两段逻辑掰开讲:

  • 不闪:新图层(alpha=0)在旧图层底下悄悄加载,等 tilesLoaded === true(当前视野所有瓦片都好了)才动手换。用户视角里旧图层一直是完整的,直到换的瞬间才被新图层”接力”。
  • 不白:executeSwap同一帧里既把新图层 alpha 拉到 1,又 remove 掉旧图层,中间没有”两层都显示”的过渡帧。
  • 200ms 超时兜底:有些瓦片网络卡死,tilesLoaded 永远到不了 true,我们不能让用户等到天荒地老,所以加个超时强制切

这是这套方案里最值钱的代码,务必原样保留 postRender + tilesLoaded + 超时兜底这三件套,缺一不可。

3. 图层显隐:改 style 而不重发请求

矢量瓦片最大的爽点在这里:开关图层不用重新请求瓦片,只改 style 里的 visibility,然后调用 reloadLayer 让 provider 重新渲染就行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// ==========================================
// 功能 1: 图层显隐
// ==========================================
function toggleLayer(layerId: string, visible?: boolean) {
const layer = currentStyle.layers.find((l: any) => l.id === layerId)
if (layer) {
if (!layer.layout) {
layer.layout = {}
}

let newVisibility: string
if (visible !== undefined) {
// 显式指定:直接用传入值
newVisibility = visible ? 'visible' : 'none'
} else {
// 不指定:取反(切换)
const currentVisibility = layer.layout.visibility
newVisibility = currentVisibility === 'visible' || currentVisibility === undefined ? 'none' : 'visible'
}

layer.layout.visibility = newVisibility

// 特殊处理:power-line(输电线)图层绑定了一个透明的"点击热区"图层
// 两者必须同步显隐,否则线看不见了但还能点到,体验很怪
if (layerId === 'power-line') {
const hitAreaLayer = currentStyle.layers.find((l: any) => l.id === 'power-line-hit-area')
if (hitAreaLayer) {
if (!hitAreaLayer.layout) {
hitAreaLayer.layout = {}
}
hitAreaLayer.layout.visibility = newVisibility
}
}

console.log(`图层 ${layerId} 状态: ${layer.layout.visibility}`)
reloadLayer()
}
}

重点说一下那个 power-line-hit-area。输电线很细,用户鼠标基本点不中,所以业务上会偷偷叠一个更宽的透明线图层专门用来做点击热区。开关主图层时,这个热区必须跟着开/关,否则会出现”线看不见了,但你点空白还能选中”的灵异现象——这是日常业务里很容易漏掉的细节

4. 点击高亮:用 filter 实现精准高亮

矢量瓦片做点击高亮的标准套路:点击 → 拿到要素 ID → 用 style 的 filter 过滤出这个 ID → 让”高亮图层”只显示它

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// ==========================================
// 功能 2: 点击高亮
// ==========================================
function bindClickEvent() {
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
handler.setInputAction(async (movement: any) => {
const pickRay = viewer.camera.getPickRay(movement.position)
if (!pickRay) return

// 使用 Cesium 原生拾取
const featuresPromise = viewer.imageryLayers.pickImageryLayerFeatures(pickRay, viewer.scene)

if (!featuresPromise) return

const features = await featuresPromise
if (features && features.length > 0) {
// 获取第一个要素
const feature = features[0]

// 这里的 properties 包含 MVT 的属性
// 注意:不同数据源 ID 字段可能不同,有的是 feature.data.id,有的是 properties.id
// OpenInfraMap 或者是 Mapbox 生成的切片通常会有唯一的 id

// 假设我们要高亮选中的线
// 我们需要找到这个要素的 ID。如果没有 ID,MVT 很难做精准高亮。
// 很多 MVT 数据源在生成时会带上 $id 属性

console.log('选中要素:', feature)

// 尝试获取 ID(根据实际数据结构调整)
// 这是一个假设逻辑,你需要看 console.log(feature) 确认 ID 在哪
const {
data: {
power_line: [{ osm_id: featureId }]
}
} = feature as any

if (featureId !== undefined) {
highlightFeature(featureId)
} else {
console.warn('该要素没有ID,无法进行样式过滤高亮')
}
} else {
clearHighlight()
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
}

function highlightFeature(id: number | string) {
// 找到高亮图层
const highlightLayer = currentStyle.layers.find((l: any) => l.id === 'highlight-layer')
if (highlightLayer) {
// 修改过滤器:只显示 osm_id 等于选中 ID 的要素
highlightLayer.filter = ['==', 'osm_id', id]

reloadLayer()
}
}

function clearHighlight() {
const highlightLayer = currentStyle.layers.find((l: any) => l.id === 'highlight-layer')
if (highlightLayer) {
// 恢复为匹配不到任何 ID(用 $id = -1 这种永远不成立的条件)
highlightLayer.filter = ['==', '$id', -1]
reloadLayer()
}
}

这套高亮方案的核心在于 style 里要预先准备一个 highlight-layer——它平时 filter 是个永远不成立的条件(啥也不显示),点击之后把 filter 改成”只显示选中 ID”,再 reloadLayer 重新渲染。整个流程没有任何额外网络请求,完全在前端跑,丝滑。

注意 feature 的数据结构,不同切片源的字段路径可能不同。本文用 OpenInfraMap 的数据,ID 藏在 feature.data.power_line[0].osm_id,你的数据八成不一样,所以console.log 看清楚结构再写解构,别照抄。

点击高亮实际效果:

点击高亮

最终全球电网效果(把所有图层叠起来的样子):

全球电网


💣 避坑指南(血泪经验总结)

1. 坑一:图层切换闪一下 + 白一下

这是本文反复强调的:不要 remove 旧图层再加新图层!正确姿势见上面 swapLayerWhenReady:

  • 新图层 alpha=0 先悄悄加载
  • 监听 postRender,等 tilesLoaded 为 true
  • 同一帧内完成”显示新 + 移除旧”
  • 兜底 200ms 超时强制切

2. 坑二:MVT 要素拿不到 ID,没法高亮

pickImageryLayerFeatures 拿到的 feature,id 在哪、叫什么名字,完全取决于切片时怎么生成的。排查思路:

  1. console.log(feature) 把整个对象打出来。
  2. feature.data 下哪个字段是唯一标识(常见有 osm_idid$idgid)。
  3. 切片时如果没带 ID,就只能靠属性字段过滤(比如名称),但精准度会下降。

3. 坑三:reloadLayer 频繁触发导致瓦片风暴

用户连续点击图层开关或者连续点击高亮,reloadLayer 会疯狂触发,瞬间创建一堆 provider。建议加个节流(debounce),200~300ms 内只执行最后一次,既不影响体验又能省机器:

1
2
// 伪代码:节流版 reloadLayer
const debouncedReload = debounce(reloadLayer, 250)

4. 坑四:同步显隐热区图层

业务上常有些”看不见但能点”的辅助图层(点击热区、辅助选择层),开关主图层时一定记得同步它们的 visibility,否则会出现”图看不见,但点空白还能选中”的诡异 bug。


总结

把这套方案拎出来就是三件套:

  • reloadLayer:用新 style 创建 provider,旧图层暂时保留。
  • swapLayerWhenReady:postRender + tilesLoaded + 超时兜底,同一帧切换,无缝不闪
  • highlightFeature / toggleLayer:全靠改 style 的 filterlayout.visibility,不发额外请求

掌握了这套套路,MVT 在 Cesium 里的玩法基本就被你拿捏了。后续如果要做多源混合瓦片、动态样式主题切换,思路都是一样的:永远在 style 这一层玩,不要去碰瓦片请求

后面如果有机会,再聊一聊怎么自己用 Mapbox GL / Martin / tegola 切 MVT,把链路从前端打通到后端。