mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
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:
1 parent
b778a43f1d
commit
de87aaf86a
10 files changed
+257
-92
No files matched your search
@@ -392,3 +392,78 @@ test('disable cancels a pending source and a late response cannot repopulate the
|
|||||||
assert.equal(h.layer.getStats().loading, false);
|
assert.equal(h.layer.getStats().loading, false);
|
||||||
} finally { h.layer.destroy(h.viewer); h.cleanup(); }
|
} 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -55,6 +55,10 @@ export function createHeatImagery({
|
|||||||
raster = next;
|
raster = next;
|
||||||
attach();
|
attach();
|
||||||
},
|
},
|
||||||
|
/** True once a raster was handed over (painted or waiting for a host). */
|
||||||
|
hasRaster() {
|
||||||
|
return raster !== null;
|
||||||
|
},
|
||||||
setShow(value) {
|
setShow(value) {
|
||||||
show = Boolean(value);
|
show = Boolean(value);
|
||||||
if (layer) layer.show = show;
|
if (layer) layer.show = show;
|
||||||
|
|||||||
@@ -89,14 +89,15 @@ export function createIngestion({
|
|||||||
/**
|
/**
|
||||||
* Run the intensity filter over the loaded payload. This is the ONE place
|
* Run the intensity filter over the loaded payload. This is the ONE place
|
||||||
* the shown set is derived: `_fires` / `_firesByFrp` feed markers, cards,
|
* the shown set is derived: `_fires` / `_firesByFrp` feed markers, cards,
|
||||||
* heat cells, the context store, the top-fires query and the analyst
|
* the heatmap, the context store, the top-fires query and the analyst
|
||||||
* records, so none of them can disagree about which fires exist. Cells are
|
* records, so none of them can disagree about which fires exist. Cells and
|
||||||
* aggregated per shown set, so the cache goes with it.
|
* the heat raster are built per shown set, so their caches go with it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function applyFilter() {
|
function applyFilter() {
|
||||||
layerState._fires = filterFires(layerState._allFires, layerState._filter);
|
layerState._fires = filterFires(layerState._allFires, layerState._filter);
|
||||||
layerState._cellCacheByGrid.clear();
|
layerState._cellCacheByGrid.clear();
|
||||||
|
layerState._heatRaster = null;
|
||||||
layerState._firesByFrp = filterFires(
|
layerState._firesByFrp = filterFires(
|
||||||
layerState._allFiresByFrp,
|
layerState._allFiresByFrp,
|
||||||
layerState._filter,
|
layerState._filter,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js';
|
import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js';
|
||||||
|
import { LOD_LEVELS } from './policy.js';
|
||||||
|
|
||||||
export function createLifecycle({
|
export function createLifecycle({
|
||||||
layerState,
|
layerState,
|
||||||
@@ -14,6 +15,20 @@ export function createLifecycle({
|
|||||||
const { id, overlayHost } = config;
|
const { id, overlayHost } = config;
|
||||||
|
|
||||||
const methods = {
|
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) {
|
init(viewer) {
|
||||||
if (layerState._destroyed) return;
|
if (layerState._destroyed) return;
|
||||||
layerState._viewer = viewer;
|
layerState._viewer = viewer;
|
||||||
@@ -40,6 +55,8 @@ export function createLifecycle({
|
|||||||
layerState._billboards.show = true;
|
layerState._billboards.show = true;
|
||||||
}
|
}
|
||||||
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true);
|
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true);
|
||||||
|
if (LOD_LEVELS[layerState._currentLodIndex]?.mode === 'heat')
|
||||||
|
layerState._heatImagery?.setShow(true);
|
||||||
components.viewport.installLodWatcher();
|
components.viewport.installLodWatcher();
|
||||||
components.viewport.installMoveEndWatcher();
|
components.viewport.installMoveEndWatcher();
|
||||||
components.selection.installClickHandler();
|
components.selection.installClickHandler();
|
||||||
@@ -64,6 +81,7 @@ export function createLifecycle({
|
|||||||
components.selection.clearFireSelection();
|
components.selection.clearFireSelection();
|
||||||
if (layerState._dataSource) layerState._dataSource.show = false;
|
if (layerState._dataSource) layerState._dataSource.show = false;
|
||||||
if (layerState._billboards) layerState._billboards.show = false;
|
if (layerState._billboards) layerState._billboards.show = false;
|
||||||
|
layerState._heatImagery?.setShow(false);
|
||||||
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
|
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
|
||||||
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
|
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
|
||||||
clearSelectedEntityContextForLayer(id);
|
clearSelectedEntityContextForLayer(id);
|
||||||
@@ -96,6 +114,12 @@ export function createLifecycle({
|
|||||||
layerState._fires = [];
|
layerState._fires = [];
|
||||||
layerState._firesByFrp = [];
|
layerState._firesByFrp = [];
|
||||||
layerState._cellCacheByGrid.clear();
|
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._count = 0;
|
||||||
layerState._totalCount = 0;
|
layerState._totalCount = 0;
|
||||||
layerState._rowControlsListener = null;
|
layerState._rowControlsListener = null;
|
||||||
|
|||||||
@@ -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.
|
* Pick a sprite color stop for one detection from its FRP on the shared ramp.
|
||||||
* @param {Object} fire - Detection record.
|
* @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
|
* 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
|
* "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets
|
||||||
* score is only known at renderCells time). Exported for unit tests.
|
* it with {@link cellCardAccent}). Exported for unit tests.
|
||||||
* @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate
|
* @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate
|
||||||
* @param {number} nowMs - Current epoch milliseconds.
|
* @param {number} nowMs - Current epoch milliseconds.
|
||||||
* @returns {Object} firmsLabels entry.
|
* @returns {Object} firmsLabels entry.
|
||||||
@@ -494,14 +464,14 @@ export function createModel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cell card accent: the warm severity stop of the cell's heat score,
|
* Cell card accent: the cell's strongest fire on the FRP ramp, the same
|
||||||
* mirroring heatColor.
|
* color its markers take once the camera is close enough to show them.
|
||||||
* @param {number} normalized - 0..1 heat score.
|
* @param {{maxFrp?: number}} cell - Aggregated cell.
|
||||||
* @returns {string} "r, g, b" accent string.
|
* @returns {string} "r, g, b" accent string.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function cellAccent(normalized) {
|
function cellCardAccent(cell) {
|
||||||
return accentForSeverity(cellColorStop(normalized).name);
|
return accentForSeverity(frpColorStop(cell?.maxFrp ?? 0).name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Coordinate line like "30.512°N 75.831°E". */
|
/** Coordinate line like "30.512°N 75.831°E". */
|
||||||
@@ -607,8 +577,6 @@ export function createModel({
|
|||||||
boundsContainPoint,
|
boundsContainPoint,
|
||||||
cellIntersectsBounds,
|
cellIntersectsBounds,
|
||||||
heatScore,
|
heatScore,
|
||||||
heatColor,
|
|
||||||
cellColorStop,
|
|
||||||
frpColorStop,
|
frpColorStop,
|
||||||
detectionColorStop,
|
detectionColorStop,
|
||||||
frpPixelSize,
|
frpPixelSize,
|
||||||
@@ -623,7 +591,7 @@ export function createModel({
|
|||||||
buildFireCard,
|
buildFireCard,
|
||||||
buildCellCard,
|
buildCellCard,
|
||||||
applyFirmsOverlayPolicy,
|
applyFirmsOverlayPolicy,
|
||||||
cellAccent,
|
cellCardAccent,
|
||||||
formatLatLon,
|
formatLatLon,
|
||||||
formatFrp,
|
formatFrp,
|
||||||
formatAge,
|
formatAge,
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import * as Cesium from 'cesium';
|
|||||||
export const REFRESH_INTERVAL_MS = 600_000;
|
export const REFRESH_INTERVAL_MS = 600_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LOD bands keyed by camera height. `cells` bands render aggregated
|
* LOD bands keyed by camera height. `heat` bands (above ~3,000 km) drape a
|
||||||
* ground-clamped heat rectangles; `detections` bands (< ~750km plus the
|
* Snap Map–style heatmap over the globe (firmsHeatRaster.js); their
|
||||||
* local band) render individual fire detections as glow-sprite billboards.
|
* `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
|
* Labels in every band go through the shared screen-space greedy declutter
|
||||||
* (hard cap MAX_AMBIENT_LABELS) — there are no per-band label knobs.
|
* (hard cap MAX_AMBIENT_LABELS) — there are no per-band label knobs.
|
||||||
*/
|
*/
|
||||||
@@ -18,7 +19,7 @@ export const LOD_LEVELS = [
|
|||||||
{
|
{
|
||||||
id: 'global',
|
id: 'global',
|
||||||
minHeight: 9000000,
|
minHeight: 9000000,
|
||||||
mode: 'cells',
|
mode: 'heat',
|
||||||
gridDegrees: 2.0,
|
gridDegrees: 2.0,
|
||||||
maxCells: 1800,
|
maxCells: 1800,
|
||||||
labelDistance: 12000000,
|
labelDistance: 12000000,
|
||||||
@@ -26,7 +27,7 @@ export const LOD_LEVELS = [
|
|||||||
{
|
{
|
||||||
id: 'regional',
|
id: 'regional',
|
||||||
minHeight: 3000000,
|
minHeight: 3000000,
|
||||||
mode: 'cells',
|
mode: 'heat',
|
||||||
gridDegrees: 1.0,
|
gridDegrees: 1.0,
|
||||||
maxCells: 3600,
|
maxCells: 3600,
|
||||||
labelDistance: 8500000,
|
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;
|
export const LOD_CHECK_MS = 650;
|
||||||
|
|
||||||
/** +/-10% hysteresis on LOD band edges so slow zooms don't thrash rebuilds. */
|
/** +/-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;
|
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
|
* six-stop cold → hot FRP ramp (firmsIntensity.js), coldest first, as Cesium
|
||||||
* colors. Stop names key the glow-sprite cache and the card accents.
|
* colors. Stop names key the glow-sprite cache and the card accents.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
sliderPositionForFrp,
|
sliderPositionForFrp,
|
||||||
} from '../../data/firmsIntensity.js';
|
} from '../../data/firmsIntensity.js';
|
||||||
import { formatNumber, t } from '../../i18n/index.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. */
|
/** Slider resolution: whole-percent positions along the log axis. */
|
||||||
const SLIDER_STEPS = 1000;
|
const SLIDER_STEPS = 1000;
|
||||||
@@ -309,6 +309,19 @@ export function createQueries({
|
|||||||
return result;
|
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. */
|
/** Test seam that binds the production click path and indexes. */
|
||||||
_bindInteractionForTest(viewer, fires = []) {
|
_bindInteractionForTest(viewer, fires = []) {
|
||||||
layerState._viewer = viewer;
|
layerState._viewer = viewer;
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js';
|
import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js';
|
||||||
import { horizonOccluder } from '../../data/iconOrientation.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({
|
export function createRendering({
|
||||||
layerState,
|
layerState,
|
||||||
@@ -29,8 +36,11 @@ export function createRendering({
|
|||||||
!layerState._dataSource ||
|
!layerState._dataSource ||
|
||||||
!layerState._fires.length ||
|
!layerState._fires.length ||
|
||||||
!layerState._viewer
|
!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;
|
return false;
|
||||||
|
}
|
||||||
const lodIndex = components.viewport.selectLodIndex(
|
const lodIndex = components.viewport.selectLodIndex(
|
||||||
components.viewport.cameraHeight(),
|
components.viewport.cameraHeight(),
|
||||||
);
|
);
|
||||||
@@ -54,7 +64,7 @@ export function createRendering({
|
|||||||
if (lod.mode === 'detections') {
|
if (lod.mode === 'detections') {
|
||||||
renderDetections(lod, bounds);
|
renderDetections(lod, bounds);
|
||||||
} else {
|
} else {
|
||||||
renderCells(aggregateFires(lod, bounds), lod, bounds);
|
renderHeat(lod, bounds);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -118,65 +128,89 @@ export function createRendering({
|
|||||||
return clipped;
|
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
|
* Make sure the draped heatmap matches the shown set. The first paint is
|
||||||
* rectangles. Cell labels do NOT live on the entities anymore — they go
|
* immediate; later rebuilds (filter drags, refreshes) trail by
|
||||||
* through the same unclamped, screen-space-decluttered label pipeline as
|
* HEAT_REBUILD_MS and keep the previous heatmap on screen meanwhile.
|
||||||
* detections (see {@link rebuildAmbientLabels}).
|
*/
|
||||||
* @param {Array<Object>} cells - Aggregated cells, heat-sorted descending.
|
|
||||||
|
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} lod - Active LOD descriptor.
|
||||||
* @param {?Object} bounds - Padded view bounds in degrees, or null.
|
* @param {?Object} bounds - Padded view bounds in degrees, or null.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function renderCells(cells, lod, bounds) {
|
function renderHeat(lod, bounds) {
|
||||||
ensureDetectionCollections();
|
ensureDetectionCollections();
|
||||||
layerState._dataSource.entities.removeAll();
|
layerState._dataSource.entities.removeAll();
|
||||||
if (layerState._billboards) layerState._billboards.removeAll();
|
if (layerState._billboards) layerState._billboards.removeAll();
|
||||||
layerState._pickIndexById.clear();
|
layerState._pickIndexById.clear();
|
||||||
layerState._cullPositions.length = 0;
|
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._labelCandidates = [];
|
||||||
layerState._labelLodDistance = lod.labelDistance;
|
layerState._labelLodDistance = lod.labelDistance;
|
||||||
|
|
||||||
|
// Cells arrive heat-sorted, so candidate order doubles as label priority.
|
||||||
for (const cell of cells) {
|
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 centerLon = cell.lonCell + lod.gridDegrees / 2;
|
||||||
const centerLat = cell.latCell + 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({
|
layerState._labelCandidates.push({
|
||||||
position,
|
position: Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0),
|
||||||
cullPosition: components.model.cellCullPosition(centerLon, centerLat),
|
cullPosition: components.model.cellCullPosition(centerLon, centerLat),
|
||||||
fire: null,
|
fire: null,
|
||||||
cell,
|
cell,
|
||||||
accent: components.model.cellAccent(normalized),
|
accent: components.model.cellCardAccent(cell),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +236,7 @@ export function createRendering({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function renderDetections(lod, bounds) {
|
function renderDetections(lod, bounds) {
|
||||||
|
layerState._heatImagery?.setShow(false);
|
||||||
ensureDetectionCollections();
|
ensureDetectionCollections();
|
||||||
layerState._dataSource.entities.removeAll();
|
layerState._dataSource.entities.removeAll();
|
||||||
if (!layerState._billboards) return;
|
if (!layerState._billboards) return;
|
||||||
@@ -345,7 +380,7 @@ export function createRendering({
|
|||||||
return {
|
return {
|
||||||
renderCurrentLod,
|
renderCurrentLod,
|
||||||
aggregateFires,
|
aggregateFires,
|
||||||
renderCells,
|
renderHeat,
|
||||||
renderDetections,
|
renderDetections,
|
||||||
ensureDetectionCollections,
|
ensureDetectionCollections,
|
||||||
removeDetectionCollections,
|
removeDetectionCollections,
|
||||||
|
|||||||
@@ -131,6 +131,25 @@ export function createFirmsState({ services, config }) {
|
|||||||
|
|
||||||
layerState._cellCacheByGrid = new Map();
|
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. */
|
/** Camera idle snapshot so the throttled LOD check is ~free when parked. */
|
||||||
|
|
||||||
layerState._camSnapValid = false;
|
layerState._camSnapValid = false;
|
||||||
|
|||||||
@@ -141,6 +141,12 @@ export class LayerBindings {
|
|||||||
});
|
});
|
||||||
this._weatherShellModules.push(layer);
|
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) {
|
_persistAwarenessSelection(event, cleared = false) {
|
||||||
|
|||||||
Reference in new issue
Block a user