最近接了一個視覺化 demo 的活,要在地圖上展示火源點和周邊資源(取水點、消防站、監測雲台、機場)的關聯關係。產品給的參考圖是那種大屏風格——衛星底圖、彩色弧線連到火點、線上有光點流動、弧線還會呼吸閃爍。
一開始想的是 ECharts GL 或者 Mapbox,但客戶明確要 3D 地球視角,能轉、能俯衝那種。最後選了 Cesium,一個 HTML 檔就能跑,方便給甲方預覽。
本文記錄整個 demo 的實作思路,以及我踩過的兩個坑:弧線拱太高、初始相機把北側標註裁出螢幕。
核心視覺就四塊:
UI 層是一般的 HTML/CSS 疊在 Cesium 上面,右側態勢面板資料是 mock 的,隔幾秒隨機跳一下,營造「即時」的感覺。
沒有 Webpack,沒有 Vue,CDN 引入 Cesium 1.114,<script> 裡寫完邏輯,瀏覽器直接打開。
這樣做的好處是:改完重新整理就能看,跟甲方對需求的時候特別省事。後面如果要進 Vue/React 專案,把 <script> 裡的邏輯拆到元件裡就行,Cesium API 本身不變。
唯一前置條件:去 Cesium Ion 註冊拿 Token,替換程式碼裡的 CESIUM_ION_TOKEN。
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 肉眼幾乎看不出差別,還浪費頂點。
我第一版給每條線寫死了 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 是我調了幾版之後的值,不同場景可以微調。原則就一條:弧高跟水平距離成比例,別搞成固定大數。
用 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,
}))
上層用 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 的 PointPrimitive 或 Entity 做 80 個動畫點,每幀改 position,開銷不小。
我的做法是:單獨蓋一層 2D Canvas,用 scene.cartesianToCanvasCoordinates 把弧線上的世界座標投影到螢幕,然後在 Canvas 上畫圓點。
流程:
t(0~1 進度)和 speedt += 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。
站點圖示沒用 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 靜態圖形效能好一點。
第一版相機寫死:
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_TOKENmap.html(要連網,Cesium 資源走 CDN)Token 別提交到公開倉庫,正式環境走環境變數。
ScreenSpaceEventHandler)Cesium.createWorldTerrainAsync(),山區效果更立體onMounted 裡 init Viewer,onUnmounted 裡 viewer.destroy()Cesium 做這種「地圖 + 關係線 + 動效」的大屏,上手門檻比 ECharts 高,但 3D 視角確實唬人。幾個關鍵點:
ArcType.NONE完整程式碼在一個 HTML 檔裡,700 多行,註解寫得比較細,需要的話可以直接拿去改。
如果你也在做類似的視覺化,歡迎留言區交流踩坑經驗。
標籤: Cesium 視覺化 JavaScript GIS 大屏