feat(fires): far zoom shows a Snap Map-style heatmap instead of heat squares

The global and regional LOD bands drape the heat raster on the active
imagery host and draw no rectangle entities; the 2/1 degree aggregation now
only feeds the "N FIRES" cards, whose accent is the strongest fire's FRP
color. Markers below ~750 km hide the heatmap, as do disable and destroy.
A stale raster is rebuilt immediately on first paint and with a 150 ms
trailing debounce afterwards, so a slider drag does not re-encode on every
step. The shell hands the layer its imagery host, so the heatmap follows
globe and 3D-tiles map stacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-29 17:11:32 +04:00
1 parent b778a43f1d
commit de87aaf86a
10 files changed
+257 -92

No files matched your search

+75
View File
@@ -392,3 +392,78 @@ test('disable cancels a pending source and a late response cannot repopulate the
assert.equal(h.layer.getStats().loading, false);
} finally { h.layer.destroy(h.viewer); h.cleanup(); }
});
test('far zoom drapes one heatmap and draws no rectangles; markers and disable hide it', () => {
// A canvas stub for the heat drape and the detection glow sprites.
const priorDocument = globalThis.document;
const restoreDocument = () => {
if (priorDocument === undefined) delete globalThis.document;
else globalThis.document = priorDocument;
};
globalThis.document = {
createElement: () => ({
getContext: () => ({
createImageData: (w, hh) => ({
data: new Uint8ClampedArray(w * hh * 4),
}),
putImageData() {},
createRadialGradient: () => ({ addColorStop() {} }),
fillRect() {},
}),
toDataURL: () => 'data:image/png;base64,AAAA',
}),
};
const h = harness({
fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })],
withDataSource: true,
cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000),
});
const added = [];
const removed = [];
h.viewer.scene.primitives = {
add() {},
remove() {},
raiseToTop() {},
contains: () => true,
};
h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D };
try {
const collection = {
addImageryProvider: (provider) => {
const layer = { provider, show: true };
added.push(layer);
return layer;
},
remove: (layer, destroy) => removed.push({ layer, destroy }),
raiseToTop() {},
};
h.layer.attachShellServices({
imageryHost: () => ({ kind: 'globe', collection }),
});
h.layer._renderLodForTest('global');
assert.equal(added.length, 1, 'one heat imagery layer');
assert.equal(added[0].show, true);
assert.equal(h.layer._entityCountForTest(), 0, 'no rectangle entities');
assert.ok(h.published.length > 0, 'the far band still paints cards');
h.layer._renderLodForTest('regional');
assert.equal(added.length, 1, 'switching far bands reuses the raster');
h.layer._renderLodForTest('close');
assert.equal(added[0].show, false, 'the markers band hides the heatmap');
h.layer._renderLodForTest('global');
assert.equal(added[0].show, true);
h.layer.disable();
assert.equal(added[0].show, false, 'disable hides the heatmap');
h.layer.destroy(h.viewer);
assert.deepEqual(
removed.map((entry) => [entry.layer, entry.destroy]),
[[added[0], true]],
);
} finally {
h.cleanup();
restoreDocument();
}
});
+4
View File
@@ -55,6 +55,10 @@ export function createHeatImagery({
raster = next;
attach();
},
/** True once a raster was handed over (painted or waiting for a host). */
hasRaster() {
return raster !== null;
},
setShow(value) {
show = Boolean(value);
if (layer) layer.show = show;
+4 -3
View File
@@ -89,14 +89,15 @@ export function createIngestion({
/**
* Run the intensity filter over the loaded payload. This is the ONE place
* the shown set is derived: `_fires` / `_firesByFrp` feed markers, cards,
* heat cells, the context store, the top-fires query and the analyst
* records, so none of them can disagree about which fires exist. Cells are
* aggregated per shown set, so the cache goes with it.
* the heatmap, the context store, the top-fires query and the analyst
* records, so none of them can disagree about which fires exist. Cells and
* the heat raster are built per shown set, so their caches go with it.
*/
function applyFilter() {
layerState._fires = filterFires(layerState._allFires, layerState._filter);
layerState._cellCacheByGrid.clear();
layerState._heatRaster = null;
layerState._firesByFrp = filterFires(
layerState._allFiresByFrp,
layerState._filter,
+24
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js';
import { LOD_LEVELS } from './policy.js';
export function createLifecycle({
layerState,
@@ -14,6 +15,20 @@ export function createLifecycle({
const { id, overlayHost } = config;
const methods = {
/**
* Shell services: `imageryHost()` resolves where draped imagery goes on
* the active map stack (globe or 3D tiles, src/maps/imageryHost.js). The
* heatmap moves with it; without a host it drapes on viewer.imageryLayers.
* @param {{imageryHost?: () => {collection: ?Object, kind: string}}|null} services
*/
attachShellServices(services) {
layerState._imageryHost =
typeof services?.imageryHost === 'function'
? services.imageryHost
: null;
layerState._heatImagery?.rehome();
},
init(viewer) {
if (layerState._destroyed) return;
layerState._viewer = viewer;
@@ -40,6 +55,8 @@ export function createLifecycle({
layerState._billboards.show = true;
}
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true);
if (LOD_LEVELS[layerState._currentLodIndex]?.mode === 'heat')
layerState._heatImagery?.setShow(true);
components.viewport.installLodWatcher();
components.viewport.installMoveEndWatcher();
components.selection.installClickHandler();
@@ -64,6 +81,7 @@ export function createLifecycle({
components.selection.clearFireSelection();
if (layerState._dataSource) layerState._dataSource.show = false;
if (layerState._billboards) layerState._billboards.show = false;
layerState._heatImagery?.setShow(false);
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
clearSelectedEntityContextForLayer(id);
@@ -96,6 +114,12 @@ export function createLifecycle({
layerState._fires = [];
layerState._firesByFrp = [];
layerState._cellCacheByGrid.clear();
clearTimeout(layerState._heatRebuildTimer);
layerState._heatRebuildTimer = null;
layerState._heatImagery?.destroy();
layerState._heatImagery = null;
layerState._heatRaster = null;
layerState._imageryHost = null;
layerState._count = 0;
layerState._totalCount = 0;
layerState._rowControlsListener = null;
+8 -40
View File
@@ -136,36 +136,6 @@ export function createModel({
);
}
/**
* Severity stop for an aggregated heat cell from its heat-normalized score
* (density, night share and strongest FRP together). Cells say *where* it
* burns, so they stay on the warm end of the ramp — lime, amber, red — and
* read as one field instead of a per-cell mosaic; the cold stops belong to
* single detections, which say *how hard* each one burns.
* @param {number} normalized - 0..1 heat score (sqrt-normalized to the view).
* @returns {{name: string, minFrp: number, color: Cesium.Color}}
*/
function cellColorStop(normalized) {
const name =
normalized > 0.72 ? 'red' : normalized > 0.42 ? 'amber' : 'lime';
return (
DETECTION_COLOR_STOPS.find((stop) => stop.name === name) ||
DETECTION_COLOR_STOPS[0]
);
}
/**
* Fill color for an aggregated heat cell.
* @param {number} normalized - 0..1 heat score (see {@link cellColorStop}).
* @param {number} alpha - Fill alpha (heat-normalized by the caller).
* @returns {Cesium.Color}
*/
function heatColor(normalized, alpha) {
return cellColorStop(normalized).color.withAlpha(alpha);
}
/**
* Pick a sprite color stop for one detection from its FRP on the shared ramp.
* @param {Object} fire - Detection record.
@@ -432,8 +402,8 @@ export function createModel({
/**
* Card model for an aggregated heat cell, e.g. title "14 FIRES", detail
* "max 210 MW · new 3h". Accent comes from the candidate (heat-normalized
* score is only known at renderCells time). Exported for unit tests.
* "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets
* it with {@link cellCardAccent}). Exported for unit tests.
* @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate
* @param {number} nowMs - Current epoch milliseconds.
* @returns {Object} firmsLabels entry.
@@ -494,14 +464,14 @@ export function createModel({
}
/**
* Cell card accent: the warm severity stop of the cell's heat score,
* mirroring heatColor.
* @param {number} normalized - 0..1 heat score.
* Cell card accent: the cell's strongest fire on the FRP ramp, the same
* color its markers take once the camera is close enough to show them.
* @param {{maxFrp?: number}} cell - Aggregated cell.
* @returns {string} "r, g, b" accent string.
*/
function cellAccent(normalized) {
return accentForSeverity(cellColorStop(normalized).name);
function cellCardAccent(cell) {
return accentForSeverity(frpColorStop(cell?.maxFrp ?? 0).name);
}
/** Coordinate line like "30.512°N 75.831°E". */
@@ -607,8 +577,6 @@ export function createModel({
boundsContainPoint,
cellIntersectsBounds,
heatScore,
heatColor,
cellColorStop,
frpColorStop,
detectionColorStop,
frpPixelSize,
@@ -623,7 +591,7 @@ export function createModel({
buildFireCard,
buildCellCard,
applyFirmsOverlayPolicy,
cellAccent,
cellCardAccent,
formatLatLon,
formatFrp,
formatAge,
+26 -6
View File
@@ -7,9 +7,10 @@ import * as Cesium from 'cesium';
export const REFRESH_INTERVAL_MS = 600_000;
/**
* LOD bands keyed by camera height. `cells` bands render aggregated
* ground-clamped heat rectangles; `detections` bands (< ~750km plus the
* local band) render individual fire detections as glow-sprite billboards.
* LOD bands keyed by camera height. `heat` bands (above ~3,000 km) drape a
* Snap Map–style heatmap over the globe (firmsHeatRaster.js); their
* `gridDegrees` aggregation only feeds the "N FIRES" cards. `detections`
* bands render individual fire detections as glow-sprite billboards.
* Labels in every band go through the shared screen-space greedy declutter
* (hard cap MAX_AMBIENT_LABELS) — there are no per-band label knobs.
*/
@@ -18,7 +19,7 @@ export const LOD_LEVELS = [
{
id: 'global',
minHeight: 9000000,
mode: 'cells',
mode: 'heat',
gridDegrees: 2.0,
maxCells: 1800,
labelDistance: 12000000,
@@ -26,7 +27,7 @@ export const LOD_LEVELS = [
{
id: 'regional',
minHeight: 3000000,
mode: 'cells',
mode: 'heat',
gridDegrees: 1.0,
maxCells: 3600,
labelDistance: 8500000,
@@ -47,6 +48,25 @@ export const LOD_LEVELS = [
},
];
/**
* Global heat raster for the `heat` bands: ≈0.18° per pixel, Gaussian σ in
* pixels. Built once per data refresh or filter change, never per camera move.
*/
export const HEAT_RASTER = Object.freeze({
width: 2048,
height: 1024,
sigmaPx: 3,
});
/**
* Trailing delay before a stale heatmap is rebuilt while one is already on
* screen: a slider drag fires many filter changes, and a build plus PNG
* encode costs a few hundred milliseconds. The first paint is immediate.
*/
export const HEAT_REBUILD_MS = 150;
export const LOD_CHECK_MS = 650;
/** +/-10% hysteresis on LOD band edges so slow zooms don't thrash rebuilds. */
@@ -90,7 +110,7 @@ export const CULL_LIFT_THRESHOLD_M = 10;
export const CULL_LIFT_M = 12;
/**
* Color stops shared by cell heat fills and detection glow sprites: the
* Color stops for detection glow sprites and card accents: the
* six-stop cold → hot FRP ramp (firmsIntensity.js), coldest first, as Cesium
* colors. Stop names key the glow-sprite cache and the card accents.
*/
+14 -1
View File
@@ -9,7 +9,7 @@ import {
sliderPositionForFrp,
} from '../../data/firmsIntensity.js';
import { formatNumber, t } from '../../i18n/index.js';
import { REFRESH_INTERVAL_MS } from './policy.js';
import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js';
/** Slider resolution: whole-percent positions along the log axis. */
const SLIDER_STEPS = 1000;
@@ -309,6 +309,19 @@ export function createQueries({
return result;
},
/** Test seam: render one named LOD band against a near-global view. */
_renderLodForTest(lodId) {
const lod = LOD_LEVELS.find((level) => level.id === lodId);
if (lod.mode === 'detections')
components.rendering.renderDetections(lod, null);
else components.rendering.renderHeat(lod, null);
},
/** Test seam: entities drawn by the layer's data source. */
_entityCountForTest() {
return layerState._dataSource?.entities?.values?.length ?? 0;
},
/** Test seam that binds the production click path and indexes. */
_bindInteractionForTest(viewer, fires = []) {
layerState._viewer = viewer;
+77 -42
View File
@@ -1,7 +1,14 @@
import * as Cesium from 'cesium';
import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js';
import { horizonOccluder } from '../../data/iconOrientation.js';
import { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js';
import { buildHeatRaster } from '../../data/firmsHeatRaster.js';
import {
LOD_LEVELS,
CONTEXT_TOP_N,
HEAT_RASTER,
HEAT_REBUILD_MS,
} from './policy.js';
import { createHeatImagery } from './heatImagery.js';
export function createRendering({
layerState,
@@ -29,8 +36,11 @@ export function createRendering({
!layerState._dataSource ||
!layerState._fires.length ||
!layerState._viewer
)
) {
// A filter that leaves nothing shown must not leave the old heat behind.
if (!layerState._fires.length) layerState._heatImagery?.setShow(false);
return false;
}
const lodIndex = components.viewport.selectLodIndex(
components.viewport.cameraHeight(),
);
@@ -54,7 +64,7 @@ export function createRendering({
if (lod.mode === 'detections') {
renderDetections(lod, bounds);
} else {
renderCells(aggregateFires(lod, bounds), lod, bounds);
renderHeat(lod, bounds);
}
return true;
}
@@ -118,65 +128,89 @@ export function createRendering({
return clipped;
}
/** The heat imagery adapter, created on first use. */
function heatImagery() {
if (!layerState._heatImagery) {
layerState._heatImagery = createHeatImagery({
cesium: Cesium,
getHost: () =>
layerState._imageryHost?.() ?? {
collection: layerState._viewer?.imageryLayers ?? null,
kind: 'globe',
},
});
}
return layerState._heatImagery;
}
/** Build the heat raster from the shown set and drape it. */
function rebuildHeatRaster() {
clearTimeout(layerState._heatRebuildTimer);
layerState._heatRebuildTimer = null;
if (layerState._destroyed) return;
layerState._heatRaster = buildHeatRaster(layerState._fires, HEAT_RASTER);
heatImagery().setRaster(layerState._heatRaster);
}
/**
* Render aggregated heat cells (global/regional bands) as ground-clamped
* rectangles. Cell labels do NOT live on the entities anymore — they go
* through the same unclamped, screen-space-decluttered label pipeline as
* detections (see {@link rebuildAmbientLabels}).
* @param {Array<Object>} cells - Aggregated cells, heat-sorted descending.
* Make sure the draped heatmap matches the shown set. The first paint is
* immediate; later rebuilds (filter drags, refreshes) trail by
* HEAT_REBUILD_MS and keep the previous heatmap on screen meanwhile.
*/
function ensureHeatRaster() {
if (layerState._heatRaster) return;
if (!heatImagery().hasRaster()) {
rebuildHeatRaster();
return;
}
clearTimeout(layerState._heatRebuildTimer);
layerState._heatRebuildTimer = setTimeout(
rebuildHeatRaster,
HEAT_REBUILD_MS,
);
}
/**
* Far bands (global/regional): a Snap Map–style heatmap draped over the
* globe, transparent where nothing burns, plus the decluttered "N FIRES"
* cards from the coarse aggregation. No entities and no sprites are drawn;
* cards go through the same screen-space label pipeline as detections
* (see {@link rebuildAmbientLabels}).
* @param {Object} lod - Active LOD descriptor.
* @param {?Object} bounds - Padded view bounds in degrees, or null.
*/
function renderCells(cells, lod, bounds) {
function renderHeat(lod, bounds) {
ensureDetectionCollections();
layerState._dataSource.entities.removeAll();
if (layerState._billboards) layerState._billboards.removeAll();
layerState._pickIndexById.clear();
layerState._cullPositions.length = 0;
layerState._cellCount = cells.length;
const maxScore = Math.max(1, ...cells.map(components.model.heatScore));
const imagery = heatImagery();
// The map stack may have changed since the last far render.
imagery.rehome();
ensureHeatRaster();
imagery.setShow(layerState._enabled);
const cells = aggregateFires(lod, bounds);
layerState._cellCount = cells.length;
layerState._labelCandidates = [];
layerState._labelLodDistance = lod.labelDistance;
// Cells arrive heat-sorted, so candidate order doubles as label priority.
for (const cell of cells) {
const score = components.model.heatScore(cell);
const normalized = Math.min(1, Math.sqrt(score / maxScore));
const alpha = 0.16 + normalized * 0.5;
const color = components.model.heatColor(normalized, alpha);
const centerLon = cell.lonCell + lod.gridDegrees / 2;
const centerLat = cell.latCell + lod.gridDegrees / 2;
const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0);
layerState._dataSource.entities.add({
rectangle: {
coordinates: Cesium.Rectangle.fromDegrees(
cell.lonCell,
cell.latCell,
cell.lonCell + lod.gridDegrees,
cell.latCell + lod.gridDegrees,
),
material: new Cesium.ColorMaterialProperty(color),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
},
position,
properties: {
count: cell.count,
intensity: score,
maxFrp: cell.maxFrp,
night: cell.night,
},
});
// Cells arrive heat-sorted, so candidate order doubles as label priority.
// Accent is resolved here because `normalized` only exists in this walk.
layerState._labelCandidates.push({
position,
position: Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0),
cullPosition: components.model.cellCullPosition(centerLon, centerLat),
fire: null,
cell,
accent: components.model.cellAccent(normalized),
accent: components.model.cellCardAccent(cell),
});
}
@@ -202,6 +236,7 @@ export function createRendering({
*/
function renderDetections(lod, bounds) {
layerState._heatImagery?.setShow(false);
ensureDetectionCollections();
layerState._dataSource.entities.removeAll();
if (!layerState._billboards) return;
@@ -345,7 +380,7 @@ export function createRendering({
return {
renderCurrentLod,
aggregateFires,
renderCells,
renderHeat,
renderDetections,
ensureDetectionCollections,
removeDetectionCollections,
+19
View File
@@ -131,6 +131,25 @@ export function createFirmsState({ services, config }) {
layerState._cellCacheByGrid = new Map();
/**
* Heat raster for the far bands (firmsHeatRaster.js); null = stale, rebuilt
* on the next far render. Cleared with the cell cache on every new shown set.
*/
layerState._heatRaster = null;
/** Pending debounced heat rebuild (see HEAT_REBUILD_MS). */
layerState._heatRebuildTimer = null;
/** Imagery adapter (heatImagery.js), created on the first far render. */
layerState._heatImagery = null;
/** Shell-provided imagery host resolver; falls back to viewer.imageryLayers. */
layerState._imageryHost = null;
/** Camera idle snapshot so the throttled LOD check is ~free when parked. */
layerState._camSnapValid = false;
+6
View File
@@ -141,6 +141,12 @@ export class LayerBindings {
});
this._weatherShellModules.push(layer);
}
// Active Fires drapes its far-zoom heatmap on the same imagery host.
const fires = this._dataManager?.layers?.get('local-firms')?.module;
if (typeof fires?.attachShellServices === 'function') {
fires.attachShellServices({ imageryHost: this.services.imageryHost });
this._weatherShellModules.push(fires);
}
}
_persistAwarenessSelection(event, cleared = false) {