最近接了个可视化 demo 的活,要在地图上展示火源点和周边资源(取水点、消防站、监测云台、机场)的关联关系。产品给的参考图是那种大屏风格------卫星底图、彩色弧线连到火点、线上有光点流动、弧线还会呼吸闪烁。
一开始想的是 ECharts GL 或者 Mapbox,但客户明确要 3D 地球视角,能转、能俯冲那种。最后选了 Cesium,一个 HTML 文件就能跑,方便给甲方预览。
本文记录整个 demo 的实现思路,以及我踩过的两个坑:弧线拱太高 、初始相机把北侧标注裁出屏幕。
最终效果长什么样


核心视觉就四块:
- 火源点 + 半透明扩散椭圆
- 各资源站点图标 + 文字标注
- 站点到火点的抛物线弧线(底色 + 发光层)
- 弧线上** Canvas 2D 粒子**流动
UI 层是普通的 HTML/CSS 叠在 Cesium 上面,右侧态势面板数据是 mock 的,隔几秒随机跳一下,营造"实时"的感觉。
项目结构:就一个 HTML
没有 Webpack,没有 Vue,CDN 引入 Cesium 1.114,<script> 里写完逻辑,浏览器直接打开。
这样做的好处是:改完刷新就能看 ,跟甲方对需求的时候特别省事。后面如果要进 Vue/React 项目,把 <script> 里的逻辑拆到组件里就行,Cesium API 本身不变。
唯一前置条件:去 Cesium Ion 注册拿 Token,替换代码里的 CESIUM_ION_TOKEN。
第一步:先把 Viewer 搭起来
Cesium 默认 UI 一大堆,大屏用不上,全关掉:
javascript
viewer = new Cesium.Viewer('cesiumContainer', {
baseLayer: Cesium.ImageryLayer.fromProviderAsync(
Cesium.IonImageryProvider.fromAssetId(3), // 卫星影像
),
terrainProvider: new Cesium.EllipsoidTerrainProvider(),
animation: false,
timeline: false,
baseLayerPicker: false,
geocoder: false,
homeButton: false,
sceneModePicker: false,
navigationHelpButton: false,
fullscreenButton: false,
infoBox: false,
selectionIndicator: false,
requestRenderMode: false, // 有动画,不能开按需渲染
})
另外关了几个吃性能但大屏不需要的效果:
javascript
scene.globe.showGroundAtmosphere = false
scene.skyAtmosphere.show = false
scene.fog.enabled = false
scene.globe.enableLighting = false
scene.globe.depthTestAgainstTerrain = false // 标注不要被地形挡住
scene.backgroundColor = Cesium.Color.fromCssColorString('#0a0d12')
depthTestAgainstTerrain = false 这个我一开始没关,结果山多的地方 label 时隐时现,排查了半天。
弧线怎么做:别用大地线,自己插值
Cesium 画线默认会走 ArcType.GEODESIC(贴地球表面的大圆弧)。我们要的是拱起来的抛物线 ,所以必须设 arcType: Cesium.ArcType.NONE,然后自己算每个点的经纬度和高度。
核心就一行:
javascript
const h = arcHeight * Math.sin(Math.PI * t) // t 从 0 到 1,两端高度为 0,中间最高
完整函数:
javascript
function makeArcPositions(lon1, lat1, lon2, lat2, arcHeight, nPoints = 48) {
const out = new Array(nPoints + 1)
for (let i = 0; i <= nPoints; i++) {
const t = i / nPoints
const lon = lon1 + (lon2 - lon1) * t
const lat = lat1 + (lat2 - lat1) * t
const h = arcHeight * Math.sin(Math.PI * t)
out[i] = Cesium.Cartesian3.fromDegrees(lon, lat, h)
}
return out
}
48 个点够平滑了,加到 100 肉眼几乎看不出区别,还浪费顶点。
坑 1:弧高别写死,不然能拱到天上
我第一版给每条线写死了 arcH: 58000 这种值(单位米)。水平距离才五六公里,弧顶五六十公里------打开页面弧线直接戳出屏幕,跟发射导弹似的。
后来改成按两点距离动态算:
javascript
function calcArcHeight(lon1, lat1, lon2, lat2) {
Cesium.Cartesian3.fromDegrees(lon1, lat1, 0, undefined, _arcScratchA)
Cesium.Cartesian3.fromDegrees(lon2, lat2, 0, undefined, _arcScratchB)
const dist = Cesium.Cartesian3.distance(_arcScratchA, _arcScratchB)
return Math.min(dist * 0.12, 4500) // 距离的 12%,上限 4.5km
}
0.12 和 4500 是我调了几版之后的值,不同场景可以微调。原则就一条:弧高跟水平距离成比例,别搞成固定大数。
弧线分两层:底色 + 发光
底色层:4 条线合并成 1 次 draw call
用 Primitive + GeometryInstance 批量提交,比每条线单独 entities.add 省:
javascript
const instances = STATIONS.map(st => new Cesium.GeometryInstance({
geometry: new Cesium.PolylineGeometry({
positions: makeArcPositions(st.lon, st.lat, FIRE.lon, FIRE.lat, st.arcH),
width: 1.5,
arcType: Cesium.ArcType.NONE,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
Cesium.Color.fromCssColorString(st.color).withAlpha(0.25),
),
},
}))
scene.primitives.add(new Cesium.Primitive({
geometryInstances: instances,
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
releaseGeometryInstances: true,
allowPicking: false,
}))
发光层:PolylineGlow + 正弦呼吸
上层用 Entity + PolylineGlowMaterialProperty,颜色 alpha 用 CallbackProperty 驱动正弦波,每条线相位错开 idx * (Math.PI * 0.5),避免齐刷刷闪。
一个小优化:CallbackProperty 里用 performance.now() 算时间,别用 JulianDate 做差值------后者每帧会 new 对象,动画开久了 GC 压力明显。
javascript
color: new Cesium.CallbackProperty(() => {
const t = performance.now() / 1000
const alpha = 0.25 + 0.65 * (0.5 + 0.5 * Math.sin(t * 1.6 + phase))
if (Math.abs(alpha - _lastAlpha) > 0.005) {
_cachedColor = baseColor.withAlpha(alpha)
_lastAlpha = alpha
}
return _cachedColor
}, false),
alpha 变化小于 0.005 就不重新 clone,也是抠细节。
粒子动画:别走 Cesium,自己画 Canvas
弧线上的流动光点,如果用 Cesium 的 PointPrimitive 或 Entity 做 80 个动画点,每帧改 position,开销不小。
我的做法是:单独盖一层 2D Canvas ,用 scene.cartesianToCanvasCoordinates 把弧线上的世界坐标投影到屏幕,然后在 Canvas 上画圆点。
流程:
- 启动时预计算所有弧线的 48 个世界坐标点(静态,不变)
- 维护 80 个粒子的对象池,每个粒子有
t(0~1 进度)和speed - 每帧
t += speed,用floor(t * 48)取当前点,投影到屏幕,画带 shadow 的圆 t >= 1重置,循环播放
javascript
const idx = Math.min(Math.floor(p.t * N_POINTS), N_POINTS - 1)
const worldPt = ARC_POINTS[p.si][idx]
const screen = scene.cartesianToCanvasCoordinates(worldPt, _scratchScreen)
if (!screen) continue
pCtx.globalAlpha = Math.sin(Math.PI * p.t) * 0.85
pCtx.shadowColor = p.color
pCtx.shadowBlur = 7
pCtx.fillStyle = p.color
pCtx.beginPath()
pCtx.arc(screen.x, screen.y, p.size, 0, Math.PI * 2)
pCtx.fill()
粒子完全绕开了 Cesium 渲染管线,相机怎么转都能跟住。_scratchScreen 复用一个 Cartesian2,避免每帧 new。
标注图标:Canvas 画 emoji,不用图片资源
站点图标没用 PNG,直接用 Canvas 画圆底 + emoji:
javascript
function makeIconDataURL(emoji, bg, size = 52) {
const key = `${emoji}_${bg}_${size}`
if (_iconCache[key]) return _iconCache[key] // 缓存,同类型只画一次
const c = document.createElement('canvas')
// ... 画外圈光晕、实心圆、描边、emoji
return c.toDataURL()
}
好处:零 HTTP 请求,改颜色只改参数,Billboard 直接 image: makeIconDataURL('💧', '#00d4ff')。
Billboard 和 Label 绑在同一个 Entity 上,比分开 add 少一半 draw call。disableDepthTestDistance: Number.POSITIVE_INFINITY 保证标注永远在最前面。
火场范围用 EllipseGeometry + Primitive 画半透明椭圆,比 Entity 静态图形性能好一点。
坑 2:相机别写死坐标
第一版相机写死:
javascript
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(119.82, 29.13, 52000),
orientation: { pitch: Cesium.Math.toRadians(-45) },
})
弧线高度调低之后,北侧的取水点和机场直接出屏幕了------俯角太陡,视心偏南。
改成根据所有标注点算包围球自动取景:
javascript
function initCamera() {
const positions = STATIONS.map(st =>
Cesium.Cartesian3.fromDegrees(st.lon, st.lat),
)
positions.push(Cesium.Cartesian3.fromDegrees(FIRE.lon, FIRE.lat))
const boundingSphere = Cesium.BoundingSphere.fromPoints(positions)
boundingSphere.radius *= 1.6
viewer.camera.flyToBoundingSphere(boundingSphere, {
duration: 2.8,
offset: new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(10),
Cesium.Math.toRadians(-32),
boundingSphere.radius * 3.5,
),
})
}
俯角从 -45° 调到 -32°,视距按包围球半径算。以后加新站点不用改相机参数,自动框进去。
数据层:一个数组驱动全部
所有站点配置集中在一个 STATIONS 数组,弧线、图标、粒子颜色都从这里读:
javascript
const STATIONS = [
{ id: 'water', lon: 119.78, lat: 29.18, label: 'XX取水点', dist: '5.9km', color: '#00d4ff', icon: '💧' },
{ id: 'fire', lon: 119.76, lat: 29.13, label: 'XX消防站', dist: '6.1km', color: '#00ff88', icon: '🏠' },
{ id: 'monitor', lon: 119.82, lat: 29.07, label: 'XX监测点云台', dist: '3.2km', color: '#ffcc00', icon: '📷' },
{ id: 'airport', lon: 119.88, lat: 29.19, label: 'XX机场', dist: '2.8km', color: '#aa88ff', icon: '✈️' },
]
STATIONS.forEach(st => {
st.arcH = calcArcHeight(st.lon, st.lat, FIRE.lon, FIRE.lat)
})
接真实接口的时候,把 STATIONS 换成 API 返回的数据,重新跑一遍 buildStaticArcs / buildGlowArcs / buildLabels / initCamera 就行。
性能小结
| 点 | 做法 |
|---|---|
| 静态弧线 | 4 条合并 1 个 Primitive |
| 发光弧线 | Entity + 缓存 alpha,减少 clone |
| 粒子 | Canvas 2D,对象池,预计算路径 |
| 图标 | Canvas 生成 + 内存缓存 |
| 标注 | Billboard + Label 同 Entity |
| 相机 | 包围球自适应,不写死 |
这个 demo 标注点个位数,谈不上压力测试。但如果要上百条线,静态层继续合并 Primitive、动画粒子保持 Canvas 方案、Entity 能少则少,方向是对的。
怎么跑
- 去 Cesium Ion 注册,复制 Access Token
- 替换代码里的
CESIUM_ION_TOKEN - 浏览器直接打开
map.html(要联网,Cesium 资源走 CDN)
Token 别提交到公开仓库,生产环境走环境变量。
后续可以扩展的方向
- 弧线改为 WebSocket 推送的实时调度路径
- 点击站点弹详情面板(Cesium 的
ScreenSpaceEventHandler) - 接真实地形
Cesium.createWorldTerrainAsync(),山区效果更立体 - 迁到 Vue 3:
onMounted里 init Viewer,onUnmounted里viewer.destroy()
写在最后
Cesium 做这种"地图 + 关系线 + 动效"的大屏,上手门槛比 ECharts 高,但 3D 视角确实唬人。几个关键点:
- 抛物线弧线自己插值,
ArcType.NONE - 弧高跟距离挂钩,别写死大数字
- 粒子动画放 Canvas,别硬塞进 Cesium 管线
- 相机用包围球,别凭感觉写坐标
完整代码在一个 HTML 文件里,700 多行,注释写得比较细,需要的话可以直接拿去改。
如果你也在做类似的可视化,欢迎评论区交流踩坑经验。
标签: Cesium 可视化 JavaScript GIS 大屏