Building a Forest Fire Monitoring Dashboard with Cesium: Arcs, Particles, and Glow Effects
Recently, I took on a visualization demo project that required displaying the relationship between fire source points and surrounding resources (water points, fire stations, monitoring platforms, airports) on a map. The reference image provided by the product team was in a large-screen style—satellite basemap, colored arcs connecting to the fire point, flowing light points on the lines, and arcs that breathe and flicker.
Initially, I considered ECharts GL or Mapbox, but the client explicitly wanted a 3D globe perspective that could rotate and dive. In the end, I chose Cesium, which can run in a single HTML file, making it convenient for client previews.
This article records the implementation approach for the entire demo and two pitfalls I encountered: arcs arching too high and the initial camera cropping out northern annotations from the screen.
What the Final Effect Looks Like
The core visuals consist of four parts:
- Fire source point + semi-transparent diffusion ellipse
- Resource station icons + text annotations
- Parabolic arcs from stations to the fire point (base color + glow layer)
- Canvas 2D particles flowing along the arcs
The UI layer is ordinary HTML/CSS overlaid on Cesium, and the data in the right-side situation panel is mocked, randomly jumping every few seconds to create a "real-time" feel.
Project Structure: Just One HTML File
No Webpack, no Vue. Cesium 1.114 is introduced via CDN, and all logic is written inside a <script> tag, opened directly in the browser.
The advantage of this approach is: you can refresh and see changes immediately, which is especially convenient when aligning requirements with the client. If you need to move it into a Vue/React project later, just split the logic from the <script> tag into components; the Cesium API itself remains unchanged.
The only prerequisite: go to Cesium Ion to register and get a Token, then replace CESIUM_ION_TOKEN in the code.
Step 1: Set Up the Viewer First
Cesium's default UI has a lot of elements that are useless for a large screen, so I turned them all off:
viewer = new Cesium.Viewer('cesiumContainer', {
baseLayer: Cesium.ImageryLayer.fromProviderAsync(
Cesium.IonImageryProvider.fromAssetId(3), // Satellite imagery
),
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, // There are animations, so cannot enable render-on-demand
})
I also turned off several performance-hungry effects that a large screen doesn't need:
scene.globe.showGroundAtmosphere = false
scene.skyAtmosphere.show = false
scene.fog.enabled = false
scene.globe.enableLighting = false
scene.globe.depthTestAgainstTerrain = false // Annotations should not be blocked by terrain
scene.backgroundColor = Cesium.Color.fromCssColorString('#0a0d12')
I didn't turn off depthTestAgainstTerrain = false initially, and as a result, labels flickered in and out in mountainous areas. That took me half a day to troubleshoot.
How to Make the Arcs: Don't Use Geodesic Lines, Interpolate Yourself
By default, Cesium draws lines using ArcType.GEODESIC (a great-circle arc hugging the Earth's surface). We want arching parabolic curves, so we must set arcType: Cesium.ArcType.NONE and calculate the longitude, latitude, and height for each point ourselves.
The core is just one line:
const h = arcHeight * Math.sin(Math.PI * t) // t goes from 0 to 1, height is 0 at both ends, highest in the middle
Full function:
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 points are smooth enough. Increasing to 100 makes almost no visible difference to the naked eye and wastes vertices.
Pitfall 1: Don't Hardcode Arc Height, or It Will Arch into the Sky
In my first version, I hardcoded values like arcH: 58000 (in meters) for each line. The horizontal distance was only five or six kilometers, but the arc apex was fifty to sixty kilometers—when I opened the page, the arcs shot straight out of the screen, like launching missiles.
Later, I changed it to dynamically calculate based on the distance between two points:
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% of distance, capped at 4.5km
}
The values 0.12 and 4500 are what I settled on after several rounds of tweaking; they can be fine-tuned for different scenarios. The principle is simple: arc height should be proportional to horizontal distance, not a fixed large number.
Arcs in Two Layers: Base Color + Glow
Base Color Layer: 4 Lines Merged into 1 Draw Call
Using Primitive + GeometryInstance for batch submission is more efficient than adding each line individually with entities.add:
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,
}))
Glow Layer: PolylineGlow + Sine Wave Breathing
The upper layer uses Entity + PolylineGlowMaterialProperty, with the color alpha driven by a sine wave via CallbackProperty. Each line's phase is offset by idx * (Math.PI * 0.5) to avoid them all flashing in unison.
A small optimization: inside CallbackProperty, use performance.now() to calculate time instead of interpolating with JulianDate—the latter creates a new object every frame, causing noticeable GC pressure when animations run for a long time.
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),
Not re-clone-ing when the alpha change is less than 0.005 is another detail-oriented optimization.
Particle Animation: Skip Cesium, Draw on Canvas Yourself
For the flowing light points on the arcs, using Cesium's PointPrimitive or Entity to animate 80 points, changing their position every frame, incurs significant overhead.
My approach: overlay a separate 2D Canvas layer, use scene.cartesianToCanvasCoordinates to project the world coordinates of the arc onto the screen, and then draw dots on the Canvas.
Process:
- Pre-calculate the 48 world coordinate points for all arcs at startup (static, unchanging)
- Maintain an object pool of 80 particles, each with a
t(0~1 progress) andspeed - Each frame,
t += speed, usefloor(t * 48)to get the current point, project it to screen coordinates, and draw a circle with a shadow - When
t >= 1, reset and loop
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()
The particles completely bypass the Cesium rendering pipeline and stay locked in no matter how the camera rotates. _scratchScreen reuses a single Cartesian2 to avoid creating new objects every frame.
Annotation Icons: Canvas-Drawn Emojis, No Image Resources
Station icons don't use PNGs; instead, a Canvas draws a circular background + emoji:
function makeIconDataURL(emoji, bg, size = 52) {
const key = `${emoji}_${bg}_${size}`
if (_iconCache[key]) return _iconCache[key] // Cache, draw only once per type
const c = document.createElement('canvas')
// ... draw outer glow ring, solid circle, stroke, emoji
return c.toDataURL()
}
Benefits: zero HTTP requests, change colors just by changing parameters, Billboard directly uses image: makeIconDataURL('💧', '#00d4ff').
Billboard and Label are bound to the same Entity, which halves the draw calls compared to adding them separately. disableDepthTestDistance: Number.POSITIVE_INFINITY ensures annotations are always on top.
The fire perimeter uses EllipseGeometry + Primitive to draw a semi-transparent ellipse, which performs slightly better than a static Entity graphic.
Pitfall 2: Don't Hardcode Camera Coordinates
The first version had a hardcoded camera:
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(119.82, 29.13, 52000),
orientation: { pitch: Cesium.Math.toRadians(-45) },
})
After lowering the arc height, the water point and airport to the north went straight off the screen—the depression angle was too steep, and the view center was biased south.
Changed to automatically frame the shot by calculating a bounding sphere from all annotation points:
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,
),
})
}
The depression angle was adjusted from -45° to -32°, and the viewing distance is calculated based on the bounding sphere radius. Adding new stations in the future won't require changing camera parameters; they'll be automatically framed.
Data Layer: A Single Array Drives Everything
All station configurations are centralized in a STATIONS array, from which arcs, icons, and particle colors are all read:
const STATIONS = [
{ id: 'water', lon: 119.78, lat: 29.18, label: 'XX Water Point', dist: '5.9km', color: '#00d4ff', icon: '💧' },
{ id: 'fire', lon: 119.76, lat: 29.13, label: 'XX Fire Station', dist: '6.1km', color: '#00ff88', icon: '🏠' },
{ id: 'monitor', lon: 119.82, lat: 29.07, label: 'XX Monitoring Platform', dist: '3.2km', color: '#ffcc00', icon: '📷' },
{ id: 'airport', lon: 119.88, lat: 29.19, label: 'XX Airport', dist: '2.8km', color: '#aa88ff', icon: '✈️' },
]
STATIONS.forEach(st => {
st.arcH = calcArcHeight(st.lon, st.lat, FIRE.lon, FIRE.lat)
})
When connecting to a real API, just replace STATIONS with the data returned by the API and re-run buildStaticArcs / buildGlowArcs / buildLabels / initCamera.
Performance Summary
| Aspect | Approach |
|---|---|
| Static Arcs | 4 lines merged into 1 Primitive |
| Glow Arcs | Entity + cached alpha, reducing clones |
| Particles | Canvas 2D, object pool, pre-calculated paths |
| Icons | Canvas generated + in-memory cache |
| Annotations | Billboard + Label on the same Entity |
| Camera | Adaptive bounding sphere, not hardcoded |
This demo only has a handful of annotation points, so it's hardly a stress test. But if you need hundreds of lines, the direction is correct: continue merging static layers into Primitives, keep the animated particles on Canvas, and use as few Entities as possible.
How to Run
- Register on Cesium Ion and copy your Access Token
- Replace
CESIUM_ION_TOKENin the code - Open
map.htmldirectly in a browser (requires internet, Cesium resources are loaded from CDN)
Don't commit the Token to a public repository; use environment variables in production.
Possible Future Extensions
- Change arcs to real-time dispatch paths pushed via WebSocket
- Click a station to pop up a detail panel (Cesium's
ScreenSpaceEventHandler) - Connect real terrain
Cesium.createWorldTerrainAsync()for a more three-dimensional effect in mountainous areas - Migrate to Vue 3: init Viewer in
onMounted,viewer.destroy()inonUnmounted
Final Thoughts
Cesium has a higher learning curve than ECharts for this kind of "map + relationship lines + dynamic effects" large screen, but the 3D perspective is indeed impressive. A few key points:
- Interpolate parabolic arcs yourself, use
ArcType.NONE - Tie arc height to distance, don't hardcode large numbers
- Put particle animations on a Canvas, don't force them into the Cesium pipeline
- Use a bounding sphere for the camera, don't guess coordinates by feel
The complete code is in a single HTML file, over 700 lines, with fairly detailed comments. Feel free to take it and modify it if needed.
If you are also working on similar visualizations, welcome to share your pitfall experiences in the comments.
Tags: Cesium Visualization JavaScript GIS Large Screen