refactor(fires): separate source, rendering and interaction components

This commit is contained in:
Sameh Khamis committed 2026-09-13 12:48:26 -07:00
1 parent e99cc8cf68
commit 6a9f13eab8
28 files changed
+2540 -1731

No files matched your search

+7
View File
@@ -1,5 +1,12 @@
# Changelog
## Fire layer components
- Split fire source loading, state, rendering, cards, selection and viewport work
into reusable components with application-owned scene services.
- Cancel late refreshes, retain good data after malformed responses, and preserve
selection identity without repeating a user-selection notification on refresh.
## Earthquake components
- Separate earthquake snapshot loading, record validation, and display ownership.
+6
View File
@@ -231,3 +231,9 @@ Software runs validate their assertions but do not establish real-GPU visual
correctness. Floor-hold retains both mesh and DEM checks: an unavailable mesh
oracle fails the run even when the DEM check passes. Record the backend with
any screenshots and run GPU visual checks separately when needed.
FIRMS refactoring can be checked without a configured server key using
`node scripts/qa-firms.mjs --url http://localhost:4173 --fixtures`. This explicit
fixture mode exercises populated display, aggregation, cards, keyless and stale
responses, and selection/camera handoff. It does not establish live-source
acceptance; omit `--fixtures` with a configured FIRMS key for that check.
+8
View File
@@ -1,5 +1,13 @@
# God's Eye View Current State
The fire layer now exposes `./layers/firms`: an instance factory with separate
snapshot requests, records, rendering, cards, selection, viewport scheduling and
terrain-anchor batching. The standalone entry supplies the existing FIRMS source
and scene services. Disable and teardown cancel pending refreshes; malformed
snapshots preserve the last good display. Refresh restoration keeps selection
identity without announcing a new user selection. Source/label, LOD thresholds,
card limits, fire identities, altitude placement and analyst records are retained.
Earthquake rendering is exposed through `./layers/earthquakes`. The layer owns
its entities and request lifecycle; the application supplies the overlay host
and snapshot source. The standalone adapter keeps the existing USGS daily feed,
+2 -1
View File
@@ -147,6 +147,7 @@
"./layers/military": "./src/layers/military/index.js",
"./aircraft": "./src/layers/aircraft/index.js",
"./layers/vessels": "./src/layers/vessels/index.js",
"./layers/earthquakes": "./src/layers/earthquakes/index.js"
"./layers/earthquakes": "./src/layers/earthquakes/index.js",
"./layers/firms": "./src/layers/firms/index.js"
}
}
+17 -1
View File
@@ -307,5 +307,21 @@
"src/layers/earthquakes/index.js",
"src/layers/earthquakes/model.js",
"src/layers/earthquakes/source.js",
"src/layers/earthquakes/ownership.test.mjs"
"src/layers/earthquakes/ownership.test.mjs",
"src/data/firmsHeatmap.js",
"src/data/fireAnchors.js",
"src/layers/firms/anchors.js",
"src/layers/firms/cards.js",
"src/layers/firms/index.js",
"src/layers/firms/ingestion.js",
"src/layers/firms/lifecycle.js",
"src/layers/firms/model.js",
"src/layers/firms/policy.js",
"src/layers/firms/queries.js",
"src/layers/firms/rendering.js",
"src/layers/firms/selection.js",
"src/layers/firms/source.js",
"src/layers/firms/state.js",
"src/layers/firms/viewport.js",
"src/layers/firms/source.test.mjs"
]
+22
View File
@@ -694,5 +694,27 @@
"src/layers/earthquakes/source.js"
],
"external": ["cesium"]
},
"fire-layer": {
"exports": ["./layers/firms"],
"modules": [
"src/data/firmsAdapt.js",
"src/data/firmsLabels.js",
"src/data/iconOrientation.js",
"src/layers/firms/anchors.js",
"src/layers/firms/cards.js",
"src/layers/firms/index.js",
"src/layers/firms/ingestion.js",
"src/layers/firms/lifecycle.js",
"src/layers/firms/model.js",
"src/layers/firms/policy.js",
"src/layers/firms/queries.js",
"src/layers/firms/rendering.js",
"src/layers/firms/selection.js",
"src/layers/firms/source.js",
"src/layers/firms/state.js",
"src/layers/firms/viewport.js"
],
"external": ["cesium"]
}
}
+69 -29
View File
@@ -24,6 +24,8 @@
* Visual proof saved to qa-shots/ (gitignored).
*
* Run: node scripts/qa-firms.mjs --url http://localhost:4420
* Add --fixtures for deterministic source/rendering proof without a key;
* that mode does not establish live-source acceptance.
* Exits non-zero on any FAIL. Does not commit anything.
*/
@@ -43,6 +45,7 @@ const getOpt = (name, dflt) => {
};
const APP_URL = getOpt('--url', 'http://localhost:4420');
const HEADFUL = argv.includes('--headful');
const FIXTURES = argv.includes('--fixtures');
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
@@ -157,7 +160,8 @@ async function bootAndEnable(page, { timeoutS = 45 } = {}) {
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: 60000 },
);
await sleep(1500);
await sleep(5000);
await page.keyboard.press('Escape');
return page.evaluate(async (tS) => {
const dm = window.__godsEyeView.dataManager;
await dm.setEnabled('local-firms', true);
@@ -208,8 +212,20 @@ function stalePayload() {
};
}
/** Deterministic high-density source records for refactoring without a server key. */
function freshFixturePayload() {
const template = stalePayload();
const fires = Array.from({ length: 1600 }, (_, i) => ({
...template.fires[i % template.fires.length],
lat: 30.1 + (i % 40) * 0.008,
lon: -97.9 + Math.floor(i / 40) * 0.008,
frp: 5 + (i % 137),
}));
return { ...template, fetchedAt: Date.now(), stale: false, count: fires.length, fires };
}
async function main() {
console.log('\nLive NASA FIRMS Proof (qa-firms)');
console.log(FIXTURES ? '\nFIRMS fixture proof (no live-source acceptance)' : '\nLive NASA FIRMS Proof (qa-firms)');
console.log(` App URL : ${APP_URL}\n`);
try {
@@ -221,7 +237,7 @@ async function main() {
}
const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null);
if (!status?.hasKey) {
if (!FIXTURES && !status?.hasKey) {
console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m');
process.exit(2);
}
@@ -243,14 +259,40 @@ async function main() {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
await page.setRequestInterception(true);
let interceptMode = FIXTURES ? 'fixture' : 'live';
const stableStalePayload = stalePayload();
page.on('request', (req) => {
const url = req.url();
if (interceptMode === 'live') { req.continue(); return; }
if (url.includes('/api/firms/status')) {
req.respond({
status: 200, contentType: 'application/json',
body: JSON.stringify(interceptMode === 'keyless'
? { hasKey: false }
: { hasKey: true, lastFetch: Date.now() - 7200000, count: 3, stale: true, ttlMs: 1800000, transactions: null }),
});
return;
}
if (url.includes('/api/firms')) {
if (interceptMode === 'keyless') {
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
} else {
req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify(interceptMode === 'fixture' ? freshFixturePayload() : stableStalePayload) });
}
return;
}
try { req.continue(); } catch { /* already handled */ }
});
// ── (i) LIVE feed ────────────────────────────────────────────────────────
console.log('(i) LIVE — loading the fires layer through the cached proxy...');
console.log(FIXTURES ? '(i) FIXTURE — loading synthetic fires through the source adapter...' : '(i) LIVE — loading the fires layer through the cached proxy...');
const live = await bootAndEnable(page);
{
const ok = live.count > 1000 && !live.error && String(live.loadingLabel || '').startsWith('LIVE');
record('LIVE: >1000 detections, no error, LIVE label', ok,
record(`${FIXTURES ? 'FIXTURE' : 'LIVE'}: >1000 detections, no error, LIVE label`, ok,
`count=${live.count} error=${JSON.stringify(live.error)} label=${JSON.stringify(live.loadingLabel)}`);
record('LIVE: /api/firms/status has key + transaction telemetry',
if (!FIXTURES) record('LIVE: /api/firms/status has key + quota statistics',
status.hasKey === true && (status.transactions === null || Number.isFinite(status.transactions?.used)),
`transactions=${JSON.stringify(status.transactions)} count=${status.count}`);
if (!ok) exitCode = 1;
@@ -299,29 +341,7 @@ async function main() {
// ── (iii) KEYLESS (intercepted) ──────────────────────────────────────────
console.log('\n(iii) KEYLESS — intercepted 503 no_key...');
await page.setRequestInterception(true);
let interceptMode = 'keyless';
page.on('request', (req) => {
const url = req.url();
if (url.includes('/api/firms/status')) {
req.respond({
status: 200, contentType: 'application/json',
body: JSON.stringify(interceptMode === 'keyless'
? { hasKey: false }
: { hasKey: true, lastFetch: Date.now() - 7200000, count: 3, stale: true, ttlMs: 1800000, transactions: null }),
});
return;
}
if (url.includes('/api/firms')) {
if (interceptMode === 'keyless') {
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
} else {
req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify(stalePayload()) });
}
return;
}
try { req.continue(); } catch { /* already handled */ }
});
interceptMode = 'keyless';
const keyless = await bootAndEnable(page, { timeoutS: 20 });
{
@@ -459,6 +479,26 @@ async function main() {
+ `request=${JSON.stringify(proof.request)} selected=${JSON.stringify(proof.selectedEntityId)} `
+ `selectedActions=${proof.selectedActionCount}`);
if (!actionOk) exitCode = 1;
const refresh = await page.evaluate(async () => {
const layer = window.__godsEyeView.dataManager.layers.get('local-firms').module;
const before = layer.getSelectedInfo();
let selections = 0;
const count = () => { selections += 1; };
window.addEventListener('gev:entity-selected', count);
try {
await layer.update();
return { before, after: layer.getSelectedInfo(), selections,
contextId: window.__gevContextStore.selectedEntityId };
} finally {
window.removeEventListener('gev:entity-selected', count);
}
});
const refreshOk = Boolean(refresh.before?.id)
&& refresh.after?.id === refresh.before.id
&& refresh.contextId === refresh.before.id && refresh.selections === 0;
record('REFRESH: selected detection survives without another selection event', refreshOk,
JSON.stringify(refresh));
if (!refreshOk) exitCode = 1;
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-synthetic-fire-action.png') });
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
const firms = fs.readFileSync(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'), 'utf8');
const firms = readLayerSource(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'));
const vessels = readLayerSource(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'));
const voice = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8');
const cameraVerbs = fs.readFileSync(path.join(ROOT, 'src', 'cameraVerbs.js'), 'utf8');
+9 -87
View File
@@ -1,87 +1,9 @@
// src/data/fireAnchors.js — DEM ground anchors for rendered FIRMS detections
// (owner field finding 2026-07-21: at close/oblique zoom over high country,
// fire dots anchored at ellipsoid height 0 read as buried inside the terrain
// ~1-2 km below the visible surface).
//
// The fix rides the height-datum ship's machinery end to end: anchors read
// the SAME shared ground floor every other ground-adjacent consumer uses
// (groundFloor.cachedGroundFloor — rendered-mesh cell ?? real Re:Earth DEM
// cell), and cold cells warm through the same batched, chunked, cached
// resolver. No 3D-Tiles sampling happens here (zero steady-state raycasts);
// fires are static, and floor cells latch warm for the session, so each
// rendered detection costs at most one DEM lookup EVER — and detections
// sharing a ~111 m coarse cell share that lookup.
import { cachedGroundFloor, resolveGroundFloorCells } from './groundFloor.js';
/** @constant {number} Metres above the resolved floor for a fire anchor.
* The DEM is bare earth while the fire glow represents a 375 m VIIRS pixel;
* a few metres of lift biases "slightly above the visible surface" (owner
* principle) without a visible hover on the screen-sized sprite. */
export const FIRE_ANCHOR_LIFT_M = 5;
/**
* Synchronous anchor height for one detection: shared ground floor + lift
* when the floor is warm, else 0 (the pre-fix ellipsoid anchor — dots render
* exactly as before until their floor lands). Warm-cache read only; never
* triggers network or sampling.
* @param {number} lat
* @param {number} lon
* @returns {number} Ellipsoidal anchor height in metres.
*/
export function fireAnchorHeight(lat, lon) {
const floor = cachedGroundFloor(lat, lon);
return floor != null ? floor + FIRE_ANCHOR_LIFT_M : 0;
}
/** @type {?Promise<boolean>} Tail of the batch chain — batches run strictly
* sequentially so overlapping renders can't stack concurrent requests on
* the single dev-server proxy (same courtesy as terrainHeights' sequential
* chunking). Each queued batch re-filters against the warm cache when it
* actually runs, so cells resolved by an earlier batch are never refetched. */
let _chain = null;
/**
* Batched warm of the ground-floor cells behind a rendered detection set.
* Fire-and-forget safe (never rejects). Resolves `true` only when at least
* one requested point actually gained a warm floor — a failed resolve (proxy
* down caches only geoid fallbacks, which do NOT count as a floor) resolves
* `false`, so a render → warm → re-render chain terminates instead of
* looping; the next natural rebuild retries and the cache self-heals.
* @param {Array<{lat: number, lon: number}>} points - Rendered detections.
* @returns {Promise<boolean>} Whether any requested point warmed.
*/
export function warmFireAnchorFloors(points) {
const cold = collectCold(points);
if (!cold.length) return Promise.resolve(false);
const prev = _chain;
const run = prev ? prev.then(() => resolveBatch(cold)) : resolveBatch(cold);
_chain = run.then(() => true, () => false);
return run;
}
/** @returns {Array<{lat: number, lon: number}>} Points with no warm floor. */
function collectCold(points) {
if (!Array.isArray(points)) return [];
const cold = [];
for (const p of points) {
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
if (cachedGroundFloor(p.lat, p.lon) != null) continue;
cold.push({ lat: p.lat, lon: p.lon });
}
return cold;
}
/** Resolves one batch (re-filtered at run time) and reports whether it warmed anything. */
async function resolveBatch(points) {
const cold = points.filter((p) => cachedGroundFloor(p.lat, p.lon) == null);
if (!cold.length) return false;
try {
await resolveGroundFloorCells(cold);
} catch { /* resolver is best-effort and never throws; belt and braces */ }
return cold.some((p) => cachedGroundFloor(p.lat, p.lon) != null);
}
/** Test hook: drops the batch chain (module caches live in groundFloor/terrainHeights). */
export function _resetFireAnchorsForTest() {
_chain = null;
}
import { createFireAnchors } from '../layers/firms/anchors.js';
import * as groundFloor from './groundFloor.js';
export { FIRE_ANCHOR_LIFT_M } from '../layers/firms/anchors.js';
const anchors = createFireAnchors(groundFloor);
export const {
fireAnchorHeight,
warmFireAnchorFloors,
_resetFireAnchorsForTest,
} = anchors;
+45 -1602
View File
File diff suppressed because it is too large. Load diff
+22 -3
View File
@@ -9,11 +9,11 @@ import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from
import { fireDetectionKey } from './firmsLabels.js';
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
import fs from 'node:fs';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const LAYER_SOURCE = fs.readFileSync(
const LAYER_SOURCE = readLayerSource(
path.join(path.dirname(fileURLToPath(import.meta.url)), 'firmsHeatmap.js'),
'utf8',
);
@@ -40,7 +40,7 @@ function makeFire(overrides = {}) {
*/
function harness({
fires = [makeFire()], picked = null, cardHit = null, cameraPosition = null,
withDataSource = false,
withDataSource = false, feed = null,
} = {}) {
const hadWindow = Object.hasOwn(globalThis, 'window');
const priorWindow = globalThis.window;
@@ -58,6 +58,7 @@ function harness({
const published = [];
let handler = null;
const layer = createFirmsHeatmapLayer({
...(feed ? { feed } : {}),
id: 'firms-test',
name: 'Fires',
overlayHost: {
@@ -349,3 +350,21 @@ test('a refresh that drops the selected fire emits an eviction the readout can a
h.cleanup();
}
});
test('disable cancels a pending source and a late response cannot repopulate the layer', async () => {
let resolve, signal;
const h = harness({ withDataSource: true, feed: { getSnapshot(options) {
signal = options.signal; return new Promise(done => { resolve = done; });
} } });
try {
const before = h.layer.getStats();
const pending = h.layer.update();
h.layer.disable();
assert.equal(signal.aborted, true);
resolve({ fires: [], fetchedAt: Date.now() });
await pending;
assert.equal(h.layer.getStats().count, before.count);
assert.equal(h.layer.getStats().lastUpdate, before.lastUpdate);
assert.equal(h.layer.getStats().loading, false);
} finally { h.layer.destroy(h.viewer); h.cleanup(); }
});
+1 -1
View File
@@ -1556,7 +1556,7 @@ test('production eviction sites actually tag their clears', () => {
// whose registration sweep deletes the record the clear needs to see.
const firmsSource = readLayerSource(new URL('./firmsHeatmap.js', import.meta.url));
const evictedClear = firmsSource.indexOf('clearSelectedEntityContextForLayer(id, { evicted: true });');
const lodRebuild = firmsSource.indexOf('renderCurrentLod(true);\n if (reselected) selectFire(reselected);');
const lodRebuild = firmsSource.indexOf('components.rendering.renderCurrentLod(true);\n if (reselected) components.selection.selectFire(reselected, false);');
assert.ok(evictedClear > 0, 'FIRMS must mark a refresh-vanished selection as an eviction');
assert.ok(lodRebuild > 0, 'the FIRMS refresh must settle its selection before rebuilding');
assert.ok(
+3 -2
View File
@@ -1,3 +1,4 @@
import { createRendering as createFirmsRendering } from '../layers/firms/rendering.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
@@ -113,8 +114,8 @@ test('flights, AIS, and FIRMS enable paths are wired through the shared sprite r
assert.match(aisLiveVesselsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('ais', activeViewer\)/);
assert.match(firmsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('firms', viewer\)/);
assert.match(
createFirmsHeatmapLayer.toString(),
/registerSpriteCollection\('firms', _billboards\);\s*restoreSpriteOrder\(_viewer\);/,
createFirmsRendering.toString(),
/registerSpriteCollection\('firms', layerState\._billboards\);\s*restoreSpriteOrder\(layerState\._viewer\);/,
'lazy FIRMS registration must restore order immediately',
);
});
+98
View File
@@ -0,0 +1,98 @@
// src/data/fireAnchors.js — DEM ground anchors for rendered FIRMS detections
// (owner field finding 2026-07-21: at close/oblique zoom over high country,
// fire dots anchored at ellipsoid height 0 read as buried inside the terrain
// ~1-2 km below the visible surface).
//
// The fix rides the height-datum ship's machinery end to end: anchors read
// the SAME shared ground floor every other ground-adjacent consumer uses
// (groundFloor.cachedGroundFloor — rendered-mesh cell ?? real Re:Earth DEM
// cell), and cold cells warm through the same batched, chunked, cached
// resolver. No 3D-Tiles sampling happens here (zero steady-state raycasts);
// fires are static, and floor cells latch warm for the session, so each
// rendered detection costs at most one DEM lookup EVER — and detections
// sharing a ~111 m coarse cell share that lookup.
/** @constant {number} Metres above the resolved floor for a fire anchor.
* The DEM is bare earth while the fire glow represents a 375 m VIIRS pixel;
* a few metres of lift biases "slightly above the visible surface" (owner
* principle) without a visible hover on the screen-sized sprite. */
export const FIRE_ANCHOR_LIFT_M = 5;
/**
* Synchronous anchor height for one detection: shared ground floor + lift
* when the floor is warm, else 0 (the pre-fix ellipsoid anchor — dots render
* exactly as before until their floor lands). Warm-cache read only; never
* triggers network or sampling.
* @param {number} lat
* @param {number} lon
* @returns {number} Ellipsoidal anchor height in metres.
*/
export function createFireAnchors({
cachedGroundFloor,
resolveGroundFloorCells,
}) {
function fireAnchorHeight(lat, lon) {
const floor = cachedGroundFloor(lat, lon);
return floor != null ? floor + FIRE_ANCHOR_LIFT_M : 0;
}
/** @type {?Promise<boolean>} Tail of the batch chain — batches run strictly
* sequentially so overlapping renders can't stack concurrent requests on
* the single dev-server proxy (same courtesy as terrainHeights' sequential
* chunking). Each queued batch re-filters against the warm cache when it
* actually runs, so cells resolved by an earlier batch are never refetched. */
let _chain = null;
/**
* Batched warm of the ground-floor cells behind a rendered detection set.
* Fire-and-forget safe (never rejects). Resolves `true` only when at least
* one requested point actually gained a warm floor — a failed resolve (proxy
* down caches only geoid fallbacks, which do NOT count as a floor) resolves
* `false`, so a render → warm → re-render chain terminates instead of
* looping; the next natural rebuild retries and the cache self-heals.
* @param {Array<{lat: number, lon: number}>} points - Rendered detections.
* @returns {Promise<boolean>} Whether any requested point warmed.
*/
function warmFireAnchorFloors(points) {
const cold = collectCold(points);
if (!cold.length) return Promise.resolve(false);
const prev = _chain;
const run = prev ? prev.then(() => resolveBatch(cold)) : resolveBatch(cold);
_chain = run.then(
() => true,
() => false,
);
return run;
}
/** @returns {Array<{lat: number, lon: number}>} Points with no warm floor. */
function collectCold(points) {
if (!Array.isArray(points)) return [];
const cold = [];
for (const p of points) {
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
if (cachedGroundFloor(p.lat, p.lon) != null) continue;
cold.push({ lat: p.lat, lon: p.lon });
}
return cold;
}
/** Resolves one batch (re-filtered at run time) and reports whether it warmed anything. */
async function resolveBatch(points) {
const cold = points.filter((p) => cachedGroundFloor(p.lat, p.lon) == null);
if (!cold.length) return false;
try {
await resolveGroundFloorCells(cold);
} catch {
/* resolver is best-effort and never throws; belt and braces */
}
return cold.some((p) => cachedGroundFloor(p.lat, p.lon) != null);
}
/** Test hook: drops the batch chain (module caches live in groundFloor/terrainHeights). */
function _resetFireAnchorsForTest() {
_chain = null;
}
return { fireAnchorHeight, warmFireAnchorFloors, _resetFireAnchorsForTest };
}
+135
View File
@@ -0,0 +1,135 @@
import * as Cesium from 'cesium';
import {
FIRMS_OVERLAY_SOURCE_ID,
FIRMS_AMBIENT_COHORT_LIMIT,
} from '../../data/firmsLabels.js';
import { MAX_AMBIENT_LABELS, LABEL_VIEW_MARGIN_PX } from './policy.js';
export function createCards({
layerState,
services,
components,
config,
feed,
}) {
const { overlayHost } = config;
/**
* Rebuild the card entries for the shared world-overlay host: the
* selected-fire detail card (if any) plus at most MAX_AMBIENT_LABELS
* ambient cards picked by a greedy screen-space declutter — walk the
* priority-ranked candidates, project each with
* SceneTransforms.worldToWindowCoordinates, and accept only candidates
* ≥ LABEL_MIN_SEP_PX from every already-accepted card. Candidates whose
* projection fails are dropped silently. SELECTION runs here, on LOD/
* viewport rebuilds and camera moveEnd — never per frame; RENDERING is
* the host, which re-projects the accepted entries every frame so
* cards track the camera smoothly.
*/
function rebuildAmbientLabels() {
if (!layerState._viewer) return;
const scene = layerState._viewer.scene;
const now = Date.now();
const entries = [];
/** @type {Array<{x: number, y: number}>} Screen positions of accepted labels. */
const accepted = [];
// worldToWindowCoordinates happily projects points on the FAR side of the
// planet, so without this the ≤18 ambient slots get spent on fires the
// overlay host will then horizon-cull at paint time (`horizonCull: true`
// in applyFirmsOverlayPolicy) — near-side fires silently lose their cards.
// Shared with the sprite pass (fireHorizonOccluder) so cards and sprites
// never disagree about which hemisphere a detection is on.
const occluder = components.rendering.fireHorizonOccluder();
const beyondHorizon = (position) =>
occluder ? occluder.isPointVisible(position) !== true : false;
layerState._fireByCardId.clear();
if (layerState._selectedFire) {
const selectedCard = components.model.buildSelectedFireCard(
layerState._selectedFire,
now,
);
layerState._fireByCardId.set(selectedCard.id, layerState._selectedFire);
entries.push(selectedCard);
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
components.model.firePosition(layerState._selectedFire),
layerState.scratchWindowCoord,
);
// Seed the accepted list so ambient cards keep clear of the detail card.
// A selected fire behind the limb is not painted, so it must not reserve
// screen space either.
if (
screen &&
!beyondHorizon(
components.model.fireCullPosition(layerState._selectedFire),
)
) {
accepted.push({ x: screen.x, y: screen.y });
}
}
const width = scene.canvas.clientWidth;
const height = scene.canvas.clientHeight;
let ambientCount = 0;
for (const candidate of layerState._labelCandidates) {
if (ambientCount >= MAX_AMBIENT_LABELS) break;
if (candidate.fire && candidate.fire === layerState._selectedFire)
continue;
// Spend the bounded cohort only on the visible hemisphere. At global
// LOD, far-side cells can still project on-canvas; accepting those first
// starves front-side cards before the host applies its authoritative cull.
if (beyondHorizon(candidate.cullPosition || candidate.position)) continue;
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
candidate.position,
layerState.scratchWindowCoord,
);
if (!screen) continue; // projection failed (e.g. behind camera) — drop silently
if (
screen.x < -LABEL_VIEW_MARGIN_PX ||
screen.x > width + LABEL_VIEW_MARGIN_PX ||
screen.y < -LABEL_VIEW_MARGIN_PX ||
screen.y > height + LABEL_VIEW_MARGIN_PX
)
continue;
if (!components.model.screenSeparated(accepted, screen)) continue;
accepted.push({ x: screen.x, y: screen.y });
ambientCount += 1;
const card = candidate.fire
? components.model.buildFireCard(candidate, now)
: components.model.buildCellCard(candidate, now);
if (candidate.fire) layerState._fireByCardId.set(card.id, candidate.fire);
entries.push(card);
}
overlayHost.setEntries(
FIRMS_OVERLAY_SOURCE_ID,
entries.map((entry) => {
const card = components.model.applyFirmsOverlayPolicy(
entry,
layerState._labelLodDistance,
);
if (!card.interactive) return card;
return {
...card,
accessibilityLabel: `Focus fire detection ${card.title}, ${card.details.join(', ')}`,
activate: () => {
const fire = layerState._fireByCardId.get(card.id);
if (!fire) return false;
components.selection.selectAndFocusFire(fire);
return true;
},
};
}),
{
cohortLimit: FIRMS_AMBIENT_COHORT_LIMIT,
collisionCapacity: FIRMS_AMBIENT_COHORT_LIMIT,
moving: false,
},
);
}
return { rebuildAmbientLabels };
}
+42
View File
@@ -0,0 +1,42 @@
import { createModel } from './model.js';
import { createIngestion } from './ingestion.js';
import { createRendering } from './rendering.js';
import { createCards } from './cards.js';
import { createSelection } from './selection.js';
import { createViewport } from './viewport.js';
import { createLifecycle } from './lifecycle.js';
import { createQueries } from './queries.js';
import { createFirmsState } from './state.js';
export function createFirmsHelpers({ services }) {
const layerState = createFirmsState({ services, config: {} });
return createModel({ layerState, services, config: {}, components: {} });
}
/** Compose one fire layer with explicit source and scene operations. */
export function createFirmsHeatmapLayer({ services, feed, ...config }) {
if (typeof feed?.getSnapshot !== 'function')
throw new TypeError('Fires require a snapshot source');
const layerState = createFirmsState({ services, config });
const components = {};
const context = { layerState, services, config, components, feed };
components.model = createModel(context);
components.ingestion = createIngestion(context);
components.rendering = createRendering(context);
components.cards = createCards(context);
components.selection = createSelection(context);
components.viewport = createViewport(context);
components.lifecycle = createLifecycle(context);
components.queries = createQueries(context);
return Object.assign(
{},
components.queries.methods,
components.lifecycle.methods,
components.ingestion.methods,
);
}
export { createFirmsSource } from './source.js';
export { createFireAnchors, FIRE_ANCHOR_LIFT_M } from './anchors.js';
export * from '../../data/firmsAdapt.js';
export * from '../../data/firmsLabels.js';
+111
View File
@@ -0,0 +1,111 @@
import { adaptFirmsRecords } from '../../data/firmsAdapt.js';
import { fireDetectionKey } from '../../data/firmsLabels.js';
export function createIngestion({
layerState,
services,
components,
config,
feed,
}) {
const { clearSelectedEntityContextForLayer } = services.context;
const { id } = config;
/** Replace a validated snapshot while preserving source freshness and selection identity. */
async function loadHeatmap() {
if (!layerState._dataSource || !layerState._enabled) return;
layerState.request?.abort();
const request = new AbortController();
layerState.request = request;
layerState._loading = true;
try {
const payload = await feed.getSnapshot({ signal: request.signal });
if (
request.signal.aborted ||
layerState.request !== request ||
!layerState._enabled
)
return;
if (payload.keyRequired) {
layerState._keyRequired = true;
layerState._error = null;
layerState._stale = false;
return;
}
layerState._keyRequired = false;
layerState._error = null;
layerState._stale = Boolean(payload?.stale);
const previousSelection = layerState._selectedFire;
layerState._selectedFire = null;
layerState._fires = adaptFirmsRecords(payload?.fires);
layerState._cellCacheByGrid.clear(); // aggregation is per-dataset — new fires, new cells
layerState._firesByFrp = [...layerState._fires].sort(
(a, b) => b.frp - a.frp,
);
layerState._count = layerState._fires.length;
// Data age, not response age: a stale proxy payload truthfully reads old.
layerState._lastUpdate = Number.isFinite(payload?.fetchedAt)
? payload.fetchedAt
: Date.now();
// Settle the previous selection BEFORE the LOD rebuild. renderCurrentLod
// runs refreshContextRegistrations(), which deletes every context record
// not in the new top-N — including the one the store still points at.
// Clearing after that deletion fails the ownership guard inside
// clearSelectedEntityContextForLayer and emits nothing at all, so an
// eviction-aware readout never hears that its subject is gone.
const reselected = findMatchingFire(previousSelection);
if (!reselected && previousSelection) {
// The selected detection is not in the new payload: it left the feed
// rather than being deselected. Eviction-aware readouts hold their
// last-known values for this instead of tearing down.
clearSelectedEntityContextForLayer(id, { evicted: true });
}
components.rendering.renderCurrentLod(true);
if (reselected) components.selection.selectFire(reselected, false);
} catch (error) {
if (
request.signal.aborted ||
layerState.request !== request ||
!layerState._enabled
)
return;
console.warn(`[Data:${id}] FIRMS live load failed:`, error);
layerState._error = 'live feed unavailable';
} finally {
if (layerState.request === request) {
layerState.request = null;
layerState._loading = false;
}
}
}
/**
* Find the record in the freshly-loaded set matching a previous selection.
* Identity is the detection itself (lat/lon/acquisition time) — indices
* are regenerated every fetch, so object/index identity cannot be used.
* @param {?Object} previous - Previously selected fire record.
* @returns {?Object} Matching new record.
*/
function findMatchingFire(previous) {
if (!previous) return null;
const key = fireDetectionKey(previous);
return (
layerState._fires.find((fire) => fireDetectionKey(fire) === key) || null
);
}
const methods = {
async update() {
if (layerState._destroyed || !layerState._enabled || layerState._loading)
return;
// Scheduled 10-minute poll (and the manager's immediate first update):
// refetch live fires through the proxy and re-render. Viewport-driven
// re-renders between polls are handled by the LOD watcher.
await loadHeatmap();
},
};
return { loadHeatmap, findMatchingFire, methods };
}
+119
View File
@@ -0,0 +1,119 @@
import * as Cesium from 'cesium';
import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js';
export function createLifecycle({
layerState,
services,
components,
config,
feed,
}) {
const { registerPickOwner, unregisterPickOwner } = services.picking;
const { restoreSpriteOrderOnEnable } = services.sprites;
const { clearSelectedEntityContextForLayer } = services.context;
const { id, overlayHost } = config;
const methods = {
init(viewer) {
if (layerState._destroyed) return;
layerState._viewer = viewer;
layerState._dataSource = new Cesium.CustomDataSource(id);
layerState._dataSource.show = false;
viewer.dataSources.add(layerState._dataSource);
},
async enable(viewer) {
if (layerState._destroyed) return;
layerState._enabled = true;
layerState._viewer = viewer;
if (!layerState._dataSource) this.init(viewer);
if (layerState._dataSource) layerState._dataSource.show = true;
if (layerState._billboards) {
// The camera can have moved anywhere while the layer was off: moveEnd
// was not being listened to, and the preRender watcher is inert while
// disabled AND while the (untimed) refetch below is in flight. So the
// retained per-sprite show flags describe the OLD viewpoint. Re-cull
// BEFORE the collection becomes visible — otherwise re-enabling at a
// new location flashes far-side fires through the planet (and keeps
// near-side ones hidden) until the fetch resolves.
components.rendering.refreshHorizonCulling();
layerState._billboards.show = true;
}
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true);
components.viewport.installLodWatcher();
components.viewport.installMoveEndWatcher();
components.selection.installClickHandler();
registerPickOwner(id, (pickedId) =>
layerState._pickIndexById.has(pickedId),
);
if (!layerState._fires.length && !layerState._loading)
await components.ingestion.loadHeatmap();
if (
layerState._enabled &&
!layerState._destroyed &&
layerState._viewer === viewer
)
restoreSpriteOrderOnEnable('firms', viewer);
},
disable() {
layerState.request?.abort();
layerState.request = null;
layerState._loading = false;
layerState._enabled = false;
components.selection.clearFireSelection();
if (layerState._dataSource) layerState._dataSource.show = false;
if (layerState._billboards) layerState._billboards.show = false;
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
clearSelectedEntityContextForLayer(id);
components.selection.removeClickHandler();
components.viewport.removeMoveEndWatcher();
unregisterPickOwner(id);
components.viewport.removeLodWatcher();
},
destroy(viewer = layerState._viewer) {
layerState.request?.abort();
layerState.request = null;
layerState._loading = false;
if (layerState._destroyed) return;
layerState._destroyed = true;
layerState._enabled = false;
components.viewport.removeLodWatcher();
components.viewport.removeMoveEndWatcher();
components.selection.removeClickHandler();
unregisterPickOwner(id);
if (layerState._dataSource && viewer) {
viewer.dataSources.remove(layerState._dataSource, true);
}
layerState._dataSource = null;
components.rendering.removeDetectionCollections(viewer);
components.selection.clearContextRegistrations();
clearSelectedEntityContextForLayer(id);
layerState._fires = [];
layerState._firesByFrp = [];
layerState._cellCacheByGrid.clear();
layerState._count = 0;
layerState._cellCount = 0;
layerState._lastUpdate = null;
layerState._keyRequired = false;
layerState._stale = false;
layerState._error = null;
layerState._currentLodId = null;
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
layerState._currentLodIndex = -1;
layerState._lastViewRect = null;
layerState._selectedFire = null;
layerState._labelCandidates = [];
layerState._labelLodDistance = 0;
layerState._pickIndexById.clear();
layerState._fireByCardId.clear();
layerState._cullPositions.length = 0;
layerState._camSnapValid = false;
},
};
return { methods };
}
+532
View File
@@ -0,0 +1,532 @@
import * as Cesium from 'cesium';
import {
satelliteShortName,
fireDetectionKey,
accentForSeverity,
} from '../../data/firmsLabels.js';
import {
VIEW_PADDING,
DETECTION_COLOR_STOPS,
CULL_LIFT_THRESHOLD_M,
CULL_LIFT_M,
LABEL_MIN_SEP_PX,
} from './policy.js';
export function createModel({
layerState,
services,
components,
config,
feed,
}) {
const { fireAnchorHeight } = services.anchors;
/**
* Map one internal fire record (firmsAdapt.js shape) to a plain JSON-safe
* analyst record (analyst query engine seam). Pure — no Cesium types.
* Missing/unknown fields are null, never NaN/undefined. The id reuses the
* layer's FIRE-##### pick-id convention (index keys pick ids and
* context-store ids, so the two stay consistent).
* @param {Object|null|undefined} fire - Internal fire record.
* @returns {{id: string, lat: number|null, lon: number|null, frp: number|null,
* confidence: number|null, satellite: string|null, acqTime: number|null}}
*/
function mapAnalystRecord(fire) {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t || null;
};
return {
id: `FIRE-${String(fire?.index ?? 0).padStart(5, '0')}`,
lat: num(fire?.lat),
lon: num(fire?.lon),
frp: num(fire?.frp),
confidence: num(fire?.confidence), // normalized 0..1 (firmsAdapt.normalizeConfidence)
satellite: text(fire?.satellite) || text(fire?.sensor),
acqTime:
Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? fire.acqMs : null, // epoch ms; 0 = unparseable → null
};
}
/**
* Expand a view rectangle by VIEW_PADDING per side and convert to degree
* bounds with an explicit anti-meridian wrap flag. Returns null for
* near-global views where clipping would be pointless.
* @param {Cesium.Rectangle} rect - Camera view rectangle (radians).
* @returns {?{west: number, south: number, east: number, north: number, wraps: boolean}}
*/
function paddedDegreeBounds(rect) {
const width = Cesium.Rectangle.computeWidth(rect);
const height = Cesium.Rectangle.computeHeight(rect);
if (width >= Math.PI * 1.85) return null;
const padLon = width * VIEW_PADDING;
const padLat = height * VIEW_PADDING;
const south = Math.max(-Cesium.Math.PI_OVER_TWO, rect.south - padLat);
const north = Math.min(Cesium.Math.PI_OVER_TWO, rect.north + padLat);
let west = rect.west - padLon;
let east = rect.east + padLon;
let wraps;
if (width + padLon * 2 >= Cesium.Math.TWO_PI) {
west = -Math.PI;
east = Math.PI;
wraps = false;
} else {
west = Cesium.Math.negativePiToPi(west);
east = Cesium.Math.negativePiToPi(east);
wraps = west > east;
}
return {
west: Cesium.Math.toDegrees(west),
south: Cesium.Math.toDegrees(south),
east: Cesium.Math.toDegrees(east),
north: Cesium.Math.toDegrees(north),
wraps,
};
}
/** Point-in-bounds test in degrees, anti-meridian aware. */
function boundsContainPoint(bounds, lat, lon) {
if (lat < bounds.south || lat > bounds.north) return false;
if (bounds.wraps) return lon >= bounds.west || lon <= bounds.east;
return lon >= bounds.west && lon <= bounds.east;
}
/** Cell-rectangle/bounds intersection test in degrees, anti-meridian aware. */
function cellIntersectsBounds(cell, gridDegrees, bounds) {
if (
cell.latCell + gridDegrees < bounds.south ||
cell.latCell > bounds.north
)
return false;
const west = cell.lonCell;
const east = cell.lonCell + gridDegrees;
if (bounds.wraps) return east >= bounds.west || west <= bounds.east;
return east >= bounds.west && west <= bounds.east;
}
function heatScore(cell) {
return (
cell.intensity + cell.count * 0.8 + cell.night * 0.6 + cell.maxFrp * 0.12
);
}
function heatColor(value, alpha) {
if (value > 0.72) return Cesium.Color.RED.withAlpha(alpha);
if (value > 0.42) return Cesium.Color.ORANGE.withAlpha(alpha);
return Cesium.Color.YELLOW.withAlpha(alpha);
}
/**
* Pick a sprite color stop from FRP + confidence, reusing the same
* yellow → orange → red thresholds as the aggregated heat cells.
* @param {Object} fire - Detection record.
* @returns {{name: string, color: Cesium.Color}}
*/
function detectionColorStop(fire) {
const heat = Math.min(
1,
Math.sqrt(Math.max(0, fire.frp) / 150) * 0.85 + fire.confidence * 0.15,
);
if (heat > 0.72) return DETECTION_COLOR_STOPS[0];
if (heat > 0.42) return DETECTION_COLOR_STOPS[1];
return DETECTION_COLOR_STOPS[2];
}
/** FRP → core marker pixel size, clamped to 8..28px. */
function frpPixelSize(frp) {
return Math.max(
8,
Math.min(28, Math.round(8 + Math.sqrt(Math.max(0, frp)) * 2)),
);
}
/** Quantize a core size to a 2px bucket so the sprite cache stays tiny. */
function sizeBucket(coreSize) {
return Math.max(8, Math.min(28, Math.round(coreSize / 2) * 2));
}
/**
* Build (and cache) a radial-glow sprite: hot near-white center fading
* through the stop color to a transparent edge. The glow lives in the
* sprite itself because global bloom defaults OFF.
* @param {{name: string, color: Cesium.Color}} stop - Color stop.
* @param {number} corePx - Bucketed core size in pixels.
* @returns {string} PNG data URL.
*/
function glowSprite(stop, corePx) {
const key = `${stop.name}:${corePx}`;
const cached = layerState.glowSpriteCache.get(key);
if (cached) return cached;
const dimension = corePx * 2;
const canvas = document.createElement('canvas');
canvas.width = dimension;
canvas.height = dimension;
const context = canvas.getContext('2d');
if (!context) return '';
const radius = dimension / 2;
const rgb = [
Math.round(stop.color.red * 255),
Math.round(stop.color.green * 255),
Math.round(stop.color.blue * 255),
].join(',');
const gradient = context.createRadialGradient(
radius,
radius,
0,
radius,
radius,
radius,
);
gradient.addColorStop(0, 'rgba(255,255,235,0.95)');
gradient.addColorStop(0.25, `rgba(${rgb},0.9)`);
gradient.addColorStop(0.55, `rgba(${rgb},0.35)`);
gradient.addColorStop(1, `rgba(${rgb},0)`);
context.fillStyle = gradient;
context.fillRect(0, 0, dimension, dimension);
const dataUrl = canvas.toDataURL('image/png');
layerState.glowSpriteCache.set(key, dataUrl);
return dataUrl;
}
/**
* Lazily-cached Cartesian3 anchor at the shared ground floor (DEM/mesh cell
* + lift) when the floor is warm, else ellipsoid height 0 (owner field
* finding 2026-07-21: height-0 anchors read as buried under high terrain at
* close/oblique zoom). Warm-cache read only — floors are warmed in batch by
* renderDetections for the close band; everything else (cell-band context
* registrations, detectable objects) just rides whatever is already warm.
* Fires are static and floor cells latch, so each detection re-allocates at
* most once per floor state (cold 0 → warm DEM). Rendering stays depth-test
* free either way (contacts are ALWAYS visible — never below the surface,
* slightly above is fine).
* @param {Object} fire - Detection record.
* @returns {Cesium.Cartesian3}
*/
function firePosition(fire) {
const height = fireAnchorHeight(fire.lat, fire.lon);
if (!fire.position || fire.positionHeight !== height) {
fire.position = Cesium.Cartesian3.fromDegrees(fire.lon, fire.lat, height);
fire.positionHeight = height;
}
return fire.position;
}
/**
* Occlusion-test anchor for one detection. Returns the render position
* outright for anchors comfortably above the ellipsoid (the common case) and
* a lazily-cached lifted point otherwise — see CULL_LIFT_THRESHOLD_M. This
* NEVER feeds rendering: the datum-correct anchor from {@link firePosition}
* is what the sprite and the card are drawn at.
* @param {Object} fire - Detection record.
* @returns {Cesium.Cartesian3}
*/
function fireCullPosition(fire) {
const position = firePosition(fire);
if (fire.positionHeight >= CULL_LIFT_THRESHOLD_M) return position;
if (!fire.cullPosition) {
fire.cullPosition = Cesium.Cartesian3.fromDegrees(
fire.lon,
fire.lat,
CULL_LIFT_M,
);
}
return fire.cullPosition;
}
/**
* Occlusion-test anchor for an aggregated heat cell. Cell centers are built at
* height 0 (exactly on the ellipsoid), which the occluder treats as a limb
* boundary case, so they get the same lift as sub-ellipsoid fire anchors.
* @param {number} lon - Cell center longitude in degrees.
* @param {number} lat - Cell center latitude in degrees.
* @returns {Cesium.Cartesian3}
*/
function cellCullPosition(lon, lat) {
return Cesium.Cartesian3.fromDegrees(lon, lat, CULL_LIFT_M);
}
/**
* Horizon-cull a billboard collection against an ellipsoid occluder.
*
* The Cesium globe is hidden in this app (Google 3D Tiles render the planet),
* so nothing writes far-side depth — and these sprites are additionally
* always-on-top (`disableDepthTestDistance: INFINITY`, so a detection is never
* swallowed by terrain it sits on). Without an explicit occluder pass, fires on
* the opposite side of the Earth shine through the planet at tilted mid-altitude
* views. Same pattern as the flights/CCTV layers' `EllipsoidalOccluder` passes.
*
* Pure and allocation-free: reads `billboard.position`, writes `billboard.show`
* only when it actually flips (assigning `show` dirties the collection's vertex
* buffer). Accepts any `{length, get(i)}` shape so it is unit-testable without
* a WebGL scene.
*
* @param {?{length: number, get: function(number): (Object|undefined)}} billboards
* Billboard collection (or collection-shaped stub).
* @param {?{isPointVisible: function(Object): boolean}} occluder Horizon occluder.
* @param {?Array<Cesium.Cartesian3>} [cullPositions=null] Index-aligned lifted
* occlusion-test anchors (see {@link fireCullPosition}); falls back to the
* billboard's own render position wherever an entry is absent.
* @returns {number} Count of billboards left visible (0 when nothing to test).
*/
function applyHorizonCull(billboards, occluder, cullPositions = null) {
if (!billboards || typeof billboards.get !== 'function') return 0;
if (typeof occluder?.isPointVisible !== 'function') return 0;
const total = Number(billboards.length) || 0;
let visibleCount = 0;
for (let i = 0; i < total; i += 1) {
const billboard = billboards.get(i);
if (!billboard) continue;
const point = (cullPositions && cullPositions[i]) || billboard.position;
const visible = occluder.isPointVisible(point) === true;
if (billboard.show !== visible) billboard.show = visible;
if (visible) visibleCount += 1;
}
return visibleCount;
}
/**
* True when `screen` is at least LABEL_MIN_SEP_PX away from every accepted
* screen position (greedy declutter accept test).
* @param {Array<{x: number, y: number}>} accepted - Accepted label positions.
* @param {Cesium.Cartesian2} screen - Candidate window coordinates.
* @returns {boolean}
*/
function screenSeparated(accepted, screen) {
const minSq = LABEL_MIN_SEP_PX * LABEL_MIN_SEP_PX;
for (let i = 0; i < accepted.length; i += 1) {
const dx = screen.x - accepted[i].x;
const dy = screen.y - accepted[i].y;
if (dx * dx + dy * dy < minSq) return false;
}
return true;
}
/**
* Card model for a click-selected fire — the full-detail card, drawn last
* (on top) and never distance-faded by the overlay.
* Exported for unit tests.
* @param {Object} fire - Detection record.
* @param {number} nowMs - Current epoch milliseconds.
* @returns {Object} firmsLabels entry.
*/
function buildSelectedFireCard(fire, nowMs) {
const meta = [`${confidenceBucket(fire.confidence)} conf`];
if (fire.acqMs > 0) {
const age = formatAge(nowMs - fire.acqMs);
if (age) meta.push(`${age} ago`);
}
const sat = satelliteShortName(fire.satellite);
meta.push(
sat ? `${fire.sensor || 'VIIRS'} ${sat}` : fire.sensor || 'sensor n/a',
);
return {
id: `selected-fire:${fireDetectionKey(fire)}`,
actionable: true,
position: firePosition(fire),
// Host-side horizon test uses this instead of the render anchor.
cullPosition: fireCullPosition(fire),
gapPx: frpPixelSize(fire.frp),
accent: accentForSeverity(detectionColorStop(fire).name),
title: `FIRE · ${formatFrp(fire.frp)} MW`,
details: [
meta.join(' · '),
formatLatLon(fire.lat, fire.lon) + (fire.night ? ' · NIGHT' : ''),
],
selected: true,
priority: Number.MAX_SAFE_INTEGER,
};
}
/**
* Card model for an ambient fire detection, e.g. title "▲ 47 MW", detail
* "high · 14h · N20". Ages are computed against each detection's acquisition
* time — with the live feed everything reads under 24 h (and a stale cache
* reads truthfully old). Exported for unit tests.
* @param {{fire: Object, position: Cesium.Cartesian3}} candidate - Label candidate.
* @param {number} nowMs - Current epoch milliseconds.
* @returns {Object} firmsLabels entry.
*/
function buildFireCard(candidate, nowMs) {
const fire = candidate.fire;
const meta = [confidenceBucket(fire.confidence)];
if (fire.acqMs > 0) {
const age = formatAge(nowMs - fire.acqMs);
if (age) meta.push(age);
}
const sat = satelliteShortName(fire.satellite) || fire.sensor;
if (sat) meta.push(sat);
return {
id: `fire:${fireDetectionKey(fire)}`,
actionable: true,
position: candidate.position,
cullPosition: candidate.cullPosition || candidate.position,
gapPx: frpPixelSize(fire.frp),
accent: accentForSeverity(detectionColorStop(fire).name),
title: `▲ ${formatFrp(fire.frp)} MW`,
details: [meta.join(' · ')],
selected: false,
priority: Number(fire.frp) || 0,
};
}
/**
* 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.
* @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate
* @param {number} nowMs - Current epoch milliseconds.
* @returns {Object} firmsLabels entry.
*/
function buildCellCard(candidate, nowMs) {
const cell = candidate.cell;
const noun = cell.count === 1 ? 'FIRE' : 'FIRES';
const parts = [`max ${formatFrp(cell.maxFrp)} MW`];
if (cell.newestAcqMs > 0) {
const age = formatAge(nowMs - cell.newestAcqMs);
if (age) parts.push(`new ${age}`);
}
return {
id: `cell:${cell.latCell ?? 'x'}:${cell.lonCell ?? 'x'}`,
position: candidate.position,
cullPosition: candidate.cullPosition || candidate.position,
gapPx: 10,
accent: candidate.accent || accentForSeverity('yellow'),
title: `${cell.count} ${noun}`,
details: [parts.join(' · ')],
selected: false,
priority: Number(cell.maxFrp) || 0,
};
}
/**
* Add the host-owned layout/fade/collision fields to a source-formatted FIRMS
* card. Selected fires share the collision domain so their protected rect
* excludes ambient cards, but bypass both the 18-card cohort and distance fade.
* @param {Object} card Source-formatted card.
* @param {number} fadeDistance Current LOD fade distance in metres.
* @returns {Object}
*/
function applyFirmsOverlayPolicy(card, fadeDistance) {
const selected = card?.selected === true;
const rawGap = Number(card?.gapPx) || 10;
const gapPx = Math.max(12, rawGap + 8);
return {
...card,
variant: selected ? 'selected' : 'card',
protected: selected,
collisionGroup: 'ambient-card',
cardStyle: 'tactical',
gapPx,
leaderOffsetPx: Math.max(2, gapPx - 6),
verticalOnly: true,
viewportMargin: 4,
maxDistance: selected ? Number.POSITIVE_INFINITY : fadeDistance,
distanceFadeStartRatio: 0.7,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
// Aggregate cells omit actionable because they do not identify one fire.
interactive: card?.actionable === true,
};
}
/**
* Heat-normalized cell score → severity accent, mirroring heatColor's
* red/orange/yellow thresholds.
* @param {number} normalized - 0..1 normalized heat score.
* @returns {string} "r, g, b" accent string.
*/
function cellAccent(normalized) {
if (normalized > 0.72) return accentForSeverity('red');
if (normalized > 0.42) return accentForSeverity('orange');
return accentForSeverity('yellow');
}
/** Coordinate line like "30.512°N 75.831°E". */
function formatLatLon(lat, lon) {
const latPart = `${Math.abs(lat).toFixed(3)}°${lat >= 0 ? 'N' : 'S'}`;
const lonPart = `${Math.abs(lon).toFixed(3)}°${lon >= 0 ? 'E' : 'W'}`;
return `${latPart} ${lonPart}`;
}
function formatFrp(frp) {
return frp >= 10 ? frp.toFixed(0) : frp.toFixed(1);
}
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
function formatAge(deltaMs) {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return '';
const hours = deltaMs / 3600000;
if (hours < 1) return '<1h';
if (hours < 48) return `${Math.round(hours)}h`;
return `${Math.round(hours / 24)}d`;
}
/** Millisecond delta → "<1m ago" / "Xm ago" / "Xh ago" (fresh-feed readout). */
function formatAgoMinutes(deltaMs) {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return 'just now';
const minutes = Math.floor(deltaMs / 60000);
if (minutes < 1) return '<1m ago';
if (minutes < 90) return `${minutes}m ago`;
return `${Math.round(minutes / 60)}h ago`;
}
/** Normalized 0..1 confidence → low/nominal/high display bucket. */
function confidenceBucket(confidence) {
if (confidence >= 0.75) return 'high';
if (confidence >= 0.45) return 'nominal';
return 'low';
}
return {
mapAnalystRecord,
paddedDegreeBounds,
boundsContainPoint,
cellIntersectsBounds,
heatScore,
heatColor,
detectionColorStop,
frpPixelSize,
sizeBucket,
glowSprite,
firePosition,
fireCullPosition,
cellCullPosition,
applyHorizonCull,
screenSeparated,
buildSelectedFireCard,
buildFireCard,
buildCellCard,
applyFirmsOverlayPolicy,
cellAccent,
formatLatLon,
formatFrp,
formatAge,
formatAgoMinutes,
confidenceBucket,
};
}
+97
View File
@@ -0,0 +1,97 @@
import { FIRMS_AMBIENT_COHORT_LIMIT } from '../../data/firmsLabels.js';
import * as Cesium from 'cesium';
/** Client poll interval; the proxy's 30 min TTL is what guards upstream quota. */
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.
* Labels in every band go through the shared screen-space greedy declutter
* (hard cap MAX_AMBIENT_LABELS) — there are no per-band label knobs.
*/
export const LOD_LEVELS = [
{
id: 'global',
minHeight: 9000000,
mode: 'cells',
gridDegrees: 2.0,
maxCells: 1800,
labelDistance: 12000000,
},
{
id: 'regional',
minHeight: 3000000,
mode: 'cells',
gridDegrees: 1.0,
maxCells: 3600,
labelDistance: 8500000,
},
{
id: 'local',
minHeight: 750000,
mode: 'detections',
maxDetections: 2500,
labelDistance: 4500000,
},
{
id: 'close',
minHeight: 0,
mode: 'detections',
maxDetections: 3000,
labelDistance: 1800000,
},
];
export const LOD_CHECK_MS = 650;
/** +/-10% hysteresis on LOD band edges so slow zooms don't thrash rebuilds. */
export const LOD_HYSTERESIS = 0.1;
/** Padding fraction applied to the camera view rectangle before clipping. */
export const VIEW_PADDING = 0.3;
/** Number of top-FRP detections registered in the shared context store. */
export const CONTEXT_TOP_N = 50;
/** Hard cap on ambient (non-selected) labels, regardless of zoom/density. */
export const MAX_AMBIENT_LABELS = FIRMS_AMBIENT_COHORT_LIMIT;
/** Min screen-space separation between accepted labels, in CSS pixels. */
export const LABEL_MIN_SEP_PX = 150;
/** Candidates projecting outside the canvas by more than this are skipped. */
export const LABEL_VIEW_MARGIN_PX = 16;
/**
* Anchors below this height get a separate LIFTED point for occlusion tests
* (never for rendering). Mirrors the flights layer's `cullPosition` idiom
* (`renderAltitudeM < 10` → test at 12 m): `fireAnchorHeight` is ground floor
* + lift, and in negative-geoid coastal regions that lands a few tens of
* metres BELOW the WGS84 ellipsoid — EllipsoidalOccluder then judges such a
* point "beyond the horizon" while its true-surface neighbours stay visible,
* so near-limb fires would blink out for a datum reason.
*/
export const CULL_LIFT_THRESHOLD_M = 10;
/** Height of the lifted occlusion-test point (flights uses the same 12 m). */
export const CULL_LIFT_M = 12;
/** Color stops shared by cell heat fills and detection glow sprites. */
export const DETECTION_COLOR_STOPS = [
{ name: 'red', color: Cesium.Color.RED },
{ name: 'orange', color: Cesium.Color.ORANGE },
{ name: 'yellow', color: Cesium.Color.YELLOW },
];
+174
View File
@@ -0,0 +1,174 @@
import { fireDetectionKey } from '../../data/firmsLabels.js';
import { REFRESH_INTERVAL_MS } from './policy.js';
export function createQueries({
layerState,
services,
components,
config,
feed,
}) {
const { id, name, icon, source } = config;
const methods = {
/** Return the selected detection as a plain record for application consumers. */
getSelectedInfo() {
const fire = layerState._selectedFire;
if (!fire) return null;
return {
id: fireDetectionKey(fire),
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
latitude: fire.lat,
longitude: fire.lon,
frp: fire.frp,
confidencePct: Number.isFinite(fire.confidence)
? fire.confidence * 100
: null,
observedAt:
Number.isFinite(fire.acqMs) && fire.acqMs > 0 ? fire.acqMs : null,
source: fire.sensor || fire.satellite || null,
};
},
id,
name,
icon,
source,
// Live layer: the manager calls update() every 10 minutes while enabled,
// which refetches through the /api/firms proxy (the proxy's 30 min TTL —
// not this interval — is what protects the upstream FIRMS quota).
updateInterval: REFRESH_INTERVAL_MS,
/**
* Layer stats for the data panel. Degraded feed states surface through
* `error` (established qa-failstate pattern: a dead feed must never look
* like a healthy empty layer) with a matching human `loadingLabel`:
* 'LIVE · updated Xm ago' fresh, 'STALE · cached Xh' when the proxy
* served past-TTL cache, 'KEY REQUIRED' keyless.
*/
getStats() {
const now = Date.now();
const staleText = layerState._lastUpdate
? `STALE · cached ${components.model.formatAge(now - layerState._lastUpdate) || '<1h'}`
: 'STALE';
let loadingLabel = '';
if (layerState._loading) {
loadingLabel = layerState._fires.length
? 'refreshing...'
: 'loading...';
} else if (layerState._keyRequired) {
loadingLabel = 'KEY REQUIRED';
} else if (layerState._stale) {
loadingLabel = staleText;
} else if (layerState._error) {
loadingLabel = layerState._error;
} else if (layerState._lastUpdate) {
loadingLabel = `LIVE · updated ${components.model.formatAgoMinutes(now - layerState._lastUpdate)}`;
}
return {
count: layerState._count,
cells: layerState._cellCount,
lastUpdate: layerState._lastUpdate,
loading: layerState._loading,
stale: layerState._stale,
error: layerState._keyRequired
? 'KEY REQUIRED'
: layerState._stale
? staleText
: layerState._error,
loadingLabel,
};
},
/**
* Strongest currently-loaded detection (by FRP) for voice targeting
* ("take me to the biggest fire").
* @returns {{latitude: number, longitude: number, frp: number, label: string}|null}
*/
getStrongestFire() {
const strongest = layerState._firesByFrp.length
? layerState._firesByFrp[0]
: null;
if (!strongest) return null;
return {
latitude: strongest.lat,
longitude: strongest.lon,
frp: strongest.frp,
label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`,
};
},
/**
* Detection-overlay seam, shaped like the other layers' detectable
* objects (traffic/flights). NOT yet registered in initDetection's layer
* list — wiring fires through the detection/label-arbiter pipeline is a
* deliberate post-PR#1 task; when that happens the arbiter replaces this
* layer's greedy declutter as the SELECTOR and the shared overlay remains
* the renderer. Walks the FRP-sorted index so the strongest fires come first.
* @param {{maxCount?: number}} [options]
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string}>}
*/
getDetectableObjects(options = {}) {
if (!layerState._enabled || !layerState._firesByFrp.length) return [];
const maxCount = Number.isFinite(options.maxCount)
? Math.max(1, Math.floor(options.maxCount))
: 250;
const result = [];
for (const fire of layerState._firesByFrp) {
result.push({
position: components.model.firePosition(fire),
id: `FIRE-${String(fire.index).padStart(5, '0')}`,
type: 'FIRE',
});
if (result.length >= maxCount) break;
}
return result;
},
/**
* Snapshot the layer's in-memory fire records as plain JSON-safe
* objects for the analyst query engine. Walks the FRP-sorted index so
* truncation keeps the STRONGEST fires (200k+ detections can be live —
* the cap is load-bearing, not cosmetic). On-demand only (called at
* most once per spoken query) — zero per-frame cost, no listeners, no
* caching. Returns [] while the layer is disabled or empty.
* @param {number} [maxCount=2000] - Maximum records to return (truncation).
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
*/
getAnalystRecords(maxCount = 2000) {
if (!layerState._enabled || !layerState._firesByFrp.length) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const result = [];
for (const fire of layerState._firesByFrp) {
result.push(components.model.mapAnalystRecord(fire));
if (result.length >= limit) break;
}
return result;
},
/** Test seam that binds the production click path and indexes. */
_bindInteractionForTest(viewer, fires = []) {
layerState._viewer = viewer;
layerState._enabled = true;
layerState._fires = fires;
layerState._firesByFrp = [...fires];
layerState._pickIndexById.clear();
layerState._labelLodDistance = 1e7;
layerState._labelCandidates = fires.map((fire) => {
layerState._pickIndexById.set(`firms-${fire.index}`, fire);
return { fire, position: components.model.firePosition(fire) };
});
components.cards.rebuildAmbientLabels();
components.selection.installClickHandler();
return layerState._clickHandler;
},
};
return { methods };
}
+355
View File
@@ -0,0 +1,355 @@
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';
export function createRendering({
layerState,
services,
components,
config,
feed,
}) {
const { warmFireAnchorFloors } = services.anchors;
const { registerSpriteCollection, restoreSpriteOrder } = services.sprites;
const { overlayHost } = config;
/**
* Rebuild the render for the current LOD band and viewport. Skips work
* when neither the (hysteresis-damped) LOD band nor the padded view
* rectangle changed meaningfully.
* @param {boolean} force - Rebuild even if LOD/view appear unchanged.
* @returns {boolean} True when a rebuild actually ran. Callers use this to
* avoid a second horizon walk in the same tick: a rebuild already culls
* its freshly-added sprites, an early-out does not.
*/
function renderCurrentLod(force = false) {
if (
!layerState._dataSource ||
!layerState._fires.length ||
!layerState._viewer
)
return false;
const lodIndex = components.viewport.selectLodIndex(
components.viewport.cameraHeight(),
);
const lod = LOD_LEVELS[lodIndex];
const viewRect = components.viewport.computeViewRect();
if (
!force &&
lodIndex === layerState._currentLodIndex &&
!components.viewport.viewChangedEnough(viewRect)
)
return false;
layerState._currentLodIndex = lodIndex;
layerState._currentLodId = lod.id;
layerState._lastViewRect = viewRect
? Cesium.Rectangle.clone(viewRect)
: null;
const bounds = viewRect
? components.model.paddedDegreeBounds(viewRect)
: null;
if (lod.mode === 'detections') {
renderDetections(lod, bounds);
} else {
renderCells(aggregateFires(lod, bounds), lod, bounds);
}
return true;
}
/**
* Bin all detections into grid cells, then viewport-clip BEFORE the
* top-N cap so fires in view never lose their slot to higher-scoring
* cells on other continents. `bounds === null` (sky/horizon or
* near-global view) falls back to the global top-N behavior.
* @param {Object} lod - Active LOD descriptor.
* @param {?Object} bounds - Padded view bounds in degrees, or null.
* @returns {Array<Object>} Cells sorted by heat score, capped.
*/
function aggregateFires(lod, bounds) {
const gridDegrees = lod.gridDegrees;
let sorted = layerState._cellCacheByGrid.get(gridDegrees);
if (!sorted) {
const cells = new Map();
for (const fire of layerState._fires) {
const latCell = Math.floor(fire.lat / gridDegrees) * gridDegrees;
const lonCell = Math.floor(fire.lon / gridDegrees) * gridDegrees;
const key = `${latCell.toFixed(3)}:${lonCell.toFixed(3)}`;
// confidence is normalized 0..1 — weight ×4 preserves the old 0..100×0.04 scale.
const intensity = Math.max(
1,
fire.frp * 0.18 + fire.confidence * 4 + fire.brightness * 0.01,
);
const existing = cells.get(key) || {
latCell,
lonCell,
count: 0,
intensity: 0,
maxFrp: 0,
night: 0,
newestAcqMs: 0,
};
existing.count += 1;
existing.intensity += intensity;
existing.maxFrp = Math.max(existing.maxFrp, fire.frp);
existing.newestAcqMs = Math.max(existing.newestAcqMs, fire.acqMs);
if (fire.night) existing.night += 1;
cells.set(key, existing);
}
// Heat-sorted once; per-render clipping below preserves the order.
sorted = [...cells.values()].sort(
(a, b) => components.model.heatScore(b) - components.model.heatScore(a),
);
layerState._cellCacheByGrid.set(gridDegrees, sorted);
}
if (!bounds) return sorted.slice(0, lod.maxCells);
const clipped = [];
for (const cell of sorted) {
if (!components.model.cellIntersectsBounds(cell, gridDegrees, bounds))
continue;
clipped.push(cell);
if (clipped.length >= lod.maxCells) break;
}
return clipped;
}
/**
* 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.
* @param {Object} lod - Active LOD descriptor.
* @param {?Object} bounds - Padded view bounds in degrees, or null.
*/
function renderCells(cells, 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));
layerState._labelCandidates = [];
layerState._labelLodDistance = lod.labelDistance;
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,
cullPosition: components.model.cellCullPosition(centerLon, centerLat),
fire: null,
cell,
accent: components.model.cellAccent(normalized),
});
}
components.cards.rebuildAmbientLabels();
components.selection.refreshContextRegistrations(
components.selection.topFiresWithinBounds(bounds),
);
}
/**
* Render individual detections (local/close bands) as pre-baked
* radial-glow sprite billboards sized by FRP, capped per viewport by
* highest FRP, depth test disabled. The 3D Tiles mesh is never sampled
* per point; instead, the CLOSE band (where terrain parallax is actually
* visible) batch-warms the shared cached DEM floor for its rendered
* subset and re-renders once floors land, so anchors sit on the terrain
* (firePosition). The local band keeps cold anchors at height 0 — at
* ≥750 km camera height the divergence is invisible and warming a
* continent-wide viewport would waste the DEM proxy.
* Labels are not built here; candidates feed {@link rebuildAmbientLabels}.
* @param {Object} lod - Active LOD descriptor.
* @param {?Object} bounds - Padded view bounds in degrees, or null.
*/
function renderDetections(lod, bounds) {
ensureDetectionCollections();
layerState._dataSource.entities.removeAll();
if (!layerState._billboards) return;
layerState._billboards.removeAll();
layerState._pickIndexById.clear();
layerState._cullPositions.length = 0;
let candidates;
if (bounds) {
// Walk the pre-sorted FRP index and early-exit at the cap — the old
// collect-then-sort touched and sorted every in-view detection per
// rebuild (tens of thousands mid-zoom; field-test round 1 chug).
candidates = [];
for (const fire of layerState._firesByFrp) {
if (!components.model.boundsContainPoint(bounds, fire.lat, fire.lon))
continue;
candidates.push(fire);
if (candidates.length >= lod.maxDetections) break;
}
} else {
// Sky/horizon view: fall back to the globally strongest detections.
candidates = layerState._firesByFrp.slice(0, lod.maxDetections);
}
layerState._cellCount = candidates.length;
layerState._labelCandidates = [];
layerState._labelLodDistance = lod.labelDistance;
for (const fire of candidates) {
const coreSize = components.model.frpPixelSize(fire.frp);
const position = components.model.firePosition(fire);
const cullPosition = components.model.fireCullPosition(fire);
const pickId = `firms-${fire.index}`;
layerState._pickIndexById.set(pickId, fire);
layerState._cullPositions.push(cullPosition);
layerState._billboards.add({
id: pickId,
position,
image: components.model.glowSprite(
components.model.detectionColorStop(fire),
components.model.sizeBucket(coreSize),
),
verticalOrigin: Cesium.VerticalOrigin.CENTER,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
// FRP-sorted walk: candidate order doubles as label priority.
layerState._labelCandidates.push({
position,
cullPosition,
fire,
cell: null,
});
}
// Freshly-added sprites default to show=true; cull the far side now so a
// rebuild never flashes through-the-planet detections while the camera
// sits still (the moveEnd/preRender hooks only fire on camera motion).
refreshHorizonCulling();
components.cards.rebuildAmbientLabels();
components.selection.refreshContextRegistrations(
candidates.slice(0, CONTEXT_TOP_N),
);
if (lod.id === 'close') {
// Batched (chunked ≤200, sequential, session-cached) DEM warm for the
// rendered subset; fires are static so each coarse cell resolves once
// ever. Re-render ONLY when a floor actually landed — a failed resolve
// reports false, so this chain terminates instead of looping against a
// down proxy (the next camera-driven rebuild retries).
warmFireAnchorFloors(candidates).then((warmed) => {
if (!warmed || !layerState._enabled || !layerState._viewer) return;
const currentLod = LOD_LEVELS[layerState._currentLodIndex];
if (!currentLod || currentLod.mode !== 'detections') return;
renderCurrentLod(true);
});
}
}
function ensureDetectionCollections() {
if (!layerState._viewer || layerState._billboards) return;
layerState._billboards = new Cesium.BillboardCollection({
scene: layerState._viewer.scene,
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
layerState._billboards.show = layerState._enabled;
layerState._viewer.scene.primitives.add(layerState._billboards);
registerSpriteCollection('firms', layerState._billboards);
restoreSpriteOrder(layerState._viewer);
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, layerState._enabled);
}
function removeDetectionCollections(viewer) {
const scene = viewer?.scene || layerState._viewer?.scene;
if (scene && !scene.isDestroyed?.()) {
try {
if (layerState._billboards)
scene.primitives.remove(layerState._billboards);
} catch {
/* already torn down */
}
}
layerState._billboards = null;
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
}
/**
* Shared horizon occluder positioned at the current camera, or null when
* there is no usable camera yet (init order / headless stubs).
* `viewer.camera` and `viewer.scene.camera` are the SAME object in Cesium;
* both are probed so a partial stub (either shape) still gets a real
* occluder rather than silently degrading to "nothing is ever occluded".
* @returns {?Cesium.EllipsoidalOccluder}
*/
function fireHorizonOccluder() {
const camera = layerState._viewer?.camera?.positionWC
? layerState._viewer.camera
: layerState._viewer?.scene?.camera;
if (!camera?.positionWC) return null;
return horizonOccluder(camera);
}
/**
* Hide detection sprites that sit beyond the ellipsoid horizon (see
* {@link applyHorizonCull}). Runs on the cadences that already exist in this
* layer — the throttled LOD preRender watcher (camera-moved only), camera
* moveEnd, and each detection rebuild — never as a new per-frame pass.
* No-ops in the `cells` bands, where the collection is empty.
*/
function refreshHorizonCulling() {
if (!layerState._billboards || !layerState._billboards.length) return;
components.model.applyHorizonCull(
layerState._billboards,
fireHorizonOccluder(),
layerState._cullPositions,
);
}
return {
renderCurrentLod,
aggregateFires,
renderCells,
renderDetections,
ensureDetectionCollections,
removeDetectionCollections,
fireHorizonOccluder,
refreshHorizonCulling,
};
}
+251
View File
@@ -0,0 +1,251 @@
import {
FIRMS_OVERLAY_SOURCE_ID,
fireDetectionKey,
} from '../../data/firmsLabels.js';
import * as Cesium from 'cesium';
import { CONTEXT_TOP_N } from './policy.js';
export function createSelection({
layerState,
services,
components,
config,
feed,
}) {
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
const { requestWorldFocus } = services.focus;
const {
selectEntityContext,
clearSelectedEntityContextForLayer,
getContextStore,
registerEntityContext,
} = services.context;
const { id, name, overlayHost, screenSpaceEventHandlerFactory } = config;
/**
* Install the LEFT_CLICK handler for fire selection (enable-time only,
* mirroring the flight layers). Clicking a fire sprite selects it and
* surfaces it in the shared context store; clicking empty space clears
* the selection. Picks owned by sibling layers are left alone.
*/
function installClickHandler() {
if (layerState._clickHandler || !layerState._viewer) return;
layerState._clickHandler = screenSpaceEventHandlerFactory(
layerState._viewer,
);
layerState._clickHandler.setInputAction((click) => {
const picked = layerState._viewer.scene.pick(click.position);
const fire = pickedFire(picked);
if (fire) {
selectAndFocusFire(fire);
return;
}
// A pick that belongs to a sibling layer (e.g. an aircraft) is not
// "empty space" — leave the selection alone and let that layer handle it.
if (picked) {
const pickedId = resolvePickId(picked);
if (pickedId && isOwnedByOtherLayer(id, pickedId)) return;
}
const cardHit = overlayHost.hitTest?.(
click.position?.x,
click.position?.y,
{
sourceId: FIRMS_OVERLAY_SOURCE_ID,
},
);
if (cardHit) {
const carded = layerState._fireByCardId.get(cardHit.entryId);
if (carded) selectAndFocusFire(carded);
return;
}
clearFireSelection();
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
function removeClickHandler() {
if (layerState._clickHandler) {
layerState._clickHandler.destroy();
layerState._clickHandler = null;
}
}
/** Select one stable detection and request one UI-owned camera transfer. */
function selectAndFocusFire(fire) {
selectFire(fire);
requestWorldFocus({
kind: 'fire',
id: fireDetectionKey(fire),
label: 'FIRE',
position: components.model.firePosition(fire),
});
}
/**
* Resolve a scene pick to one of this layer's fire records, or null.
* BillboardCollection picks surface our id either on picked.primitive.id
* or directly on picked.id depending on the CesiumJS pick path.
* @param {*} picked - Result of scene.pick().
* @returns {?Object} Fire record.
*/
function pickedFire(picked) {
if (!picked) return null;
const primitiveId = picked.primitive?.id;
if (
typeof primitiveId === 'string' &&
layerState._pickIndexById.has(primitiveId)
) {
return layerState._pickIndexById.get(primitiveId);
}
if (
typeof picked.id === 'string' &&
layerState._pickIndexById.has(picked.id)
) {
return layerState._pickIndexById.get(picked.id);
}
return null;
}
/**
* Select a fire: show its detail label and mark it selected in the shared
* context store so voice "what's selected" resolves to it.
* @param {Object} fire - Detection record.
*/
function selectFire(fire, publishSelection = true) {
layerState._selectedFire = fire;
try {
registerFireContext(fire);
layerState._contextIds.add(fireDetectionKey(fire));
if (publishSelection) selectEntityContext(fire.contextEntity);
} catch {
// context store unavailable — the selection label still works
}
components.cards.rebuildAmbientLabels();
}
function clearFireSelection() {
if (!layerState._selectedFire) return;
layerState._selectedFire = null;
clearSelectedEntityContextForLayer(id);
components.cards.rebuildAmbientLabels();
}
/**
* Register the top detections of the current render set in the shared
* context store (voice "what's burning here") and drop stale entries
* from the previous rebuild.
* @param {Array<Object>} fires - FRP-ranked detections to register.
*/
function refreshContextRegistrations(fires) {
let store = null;
try {
store = getContextStore();
} catch {
return;
}
const nextIds = new Set();
for (const fire of fires) {
nextIds.add(registerFireContext(fire));
}
// The click-selected fire must stay queryable ("what's selected") even
// when it falls outside the top-N context slice.
if (layerState._selectedFire) {
nextIds.add(registerFireContext(layerState._selectedFire));
}
for (const staleId of layerState._contextIds) {
if (!nextIds.has(staleId)) store.entities.delete(staleId);
}
layerState._contextIds = nextIds;
}
/**
* Register one fire in the shared context store (idempotent) and return
* its record id.
* @param {Object} fire - Detection record.
* @returns {string} Context record id.
*/
function registerFireContext(fire) {
const recordId = fireDetectionKey(fire);
if (!fire.contextEntity) {
fire.contextEntity = {
show: true,
};
}
// Refreshed every registration: the anchor re-grounds onto the DEM when
// its floor cell warms, and voice targeting must project the same point
// the sprite renders at.
fire.contextEntity.__localBaseCartesian =
components.model.firePosition(fire);
registerEntityContext(fire.contextEntity, {
id: recordId,
layerId: id,
layerName: name,
source: 'NASA FIRMS',
dataSource: layerState._dataSource,
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
latitude: fire.lat,
longitude: fire.lon,
properties: {
frp: fire.frp,
confidence: components.model.confidenceBucket(fire.confidence),
age:
fire.acqMs > 0
? components.model.formatAge(Date.now() - fire.acqMs)
: 'unknown',
sensor: fire.sensor || 'unknown',
},
});
return recordId;
}
function clearContextRegistrations() {
if (!layerState._contextIds.size) return;
try {
const store = getContextStore();
for (const recordId of layerState._contextIds)
store.entities.delete(recordId);
} catch {
// store unavailable — nothing to clean
}
layerState._contextIds = new Set();
}
/**
* Top-FRP detections within the given bounds (or globally when bounds is
* null). Walks the pre-sorted FRP index so it stays a single cheap pass.
* @param {?Object} bounds - Padded view bounds in degrees, or null.
* @param {number} limit - Max detections returned.
* @returns {Array<Object>}
*/
function topFiresWithinBounds(bounds, limit = CONTEXT_TOP_N) {
if (!bounds) return layerState._firesByFrp.slice(0, limit);
const top = [];
for (const fire of layerState._firesByFrp) {
if (!components.model.boundsContainPoint(bounds, fire.lat, fire.lon))
continue;
top.push(fire);
if (top.length >= limit) break;
}
return top;
}
return {
installClickHandler,
removeClickHandler,
selectAndFocusFire,
pickedFire,
selectFire,
clearFireSelection,
refreshContextRegistrations,
registerFireContext,
clearContextRegistrations,
topFiresWithinBounds,
};
}
+29
View File
@@ -0,0 +1,29 @@
/** Construct the existing live-fire endpoint without making a request. */
export function createFirmsSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async getSnapshot({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/firms', {
signal,
cache: 'no-store',
});
let payload;
try {
payload = await response.json();
} catch {
/* status below remains authoritative */
}
signal?.throwIfAborted();
if (!response.ok) {
if (response.status === 503 && payload?.error === 'no_key')
return { keyRequired: true };
throw new Error(`FIRMS HTTP ${response.status}`);
}
if (!Array.isArray(payload?.fires))
throw new Error('Malformed fire snapshot');
return payload;
},
};
}
+45
View File
@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createFirmsSource } from './source.js';
test('a malformed successful response is never accepted as an empty fire snapshot', async () => {
for (const payload of [{}, { fires: null }, { fires: {} }]) {
const source = createFirmsSource({
fetchImpl: async () => ({ ok: true, json: async () => payload }),
});
await assert.rejects(source.getSnapshot(), /Malformed fire snapshot/);
}
});
test('optional-key guidance is distinct from denial or upstream failure', async () => {
for (const status of [401, 403, 429, 500, 503]) {
const source = createFirmsSource({
fetchImpl: async () => ({
ok: false,
status,
json: async () => ({ error: 'no_key' }),
}),
});
if (status === 503)
assert.deepEqual(await source.getSnapshot(), { keyRequired: true });
else
await assert.rejects(
source.getSnapshot(),
new RegExp(`FIRMS HTTP ${status}`),
);
}
});
test('response-body completion honors cancellation without replacing records', async () => {
const abort = new AbortController();
const source = createFirmsSource({
fetchImpl: async () => ({
ok: true,
json: async () => {
abort.abort();
return { fires: [] };
},
}),
});
await assert.rejects(source.getSnapshot({ signal: abort.signal }), {
name: 'AbortError',
});
});
+115
View File
@@ -0,0 +1,115 @@
import * as Cesium from 'cesium';
export function createFirmsState({ services, config }) {
const layerState = {};
/** Pre-baked radial-glow sprites keyed by `<colorStop>:<sizeBucket>`. */
layerState.glowSpriteCache = new Map();
layerState.scratchViewRect = new Cesium.Rectangle();
layerState.scratchCenterA = new Cesium.Cartographic();
layerState.scratchCenterB = new Cesium.Cartographic();
layerState.scratchWindowCoord = new Cesium.Cartesian2();
layerState._viewer = null;
layerState._dataSource = null;
layerState._billboards = null;
layerState._enabled = false;
layerState._destroyed = false;
layerState._loading = false;
/** True when the proxy answered 503 {error:'no_key'} — FIRMS_MAP_KEY unset. */
layerState._keyRequired = false;
/** True when the proxy served a cached payload past TTL (upstream failing). */
layerState._stale = false;
/** Surfaced error string when the live fetch failed outright. */
layerState._error = null;
layerState._fires = [];
layerState._firesByFrp = [];
layerState._count = 0;
layerState._cellCount = 0;
layerState._lastUpdate = null;
layerState._currentLodId = null;
layerState._currentLodIndex = -1;
layerState._lastViewRect = null;
layerState._preRenderRemover = null;
layerState._moveEndSettleRemover = null;
layerState._lastLodCheck = 0;
layerState._contextIds = new Set();
layerState._clickHandler = null;
layerState._moveEndRemover = null;
layerState._selectedFire = null;
/** Priority-ordered label candidates from the last rebuild (detections or cells). */
layerState._labelCandidates = [];
layerState._labelLodDistance = 0;
/** pick id string -> fire record, for the currently rendered sprites. */
layerState._pickIndexById = new Map();
/** Actionable card id -> current detection record painted for that id. */
layerState._fireByCardId = new Map();
/**
* Occlusion-test anchors index-aligned with the billboard collection (the
* collection is cleared and refilled in one ordered pass per rebuild, and
* nothing else adds to it). Lifted where the render anchor sits at/below
* the ellipsoid — see {@link fireCullPosition}.
* @type {Array<Cesium.Cartesian3>}
*/
layerState._cullPositions = [];
/**
* gridDegrees -> heat-sorted full cell list. The full-grid aggregation is
* viewport-independent but walks every detection (~200k live), so it is
* computed once per grid size per data refresh; renders only clip + cap
* (field-test round 1: intermediate-zoom chug during LOD rebuilds).
* @type {Map<number, Array<Object>>}
*/
layerState._cellCacheByGrid = new Map();
/** Camera idle snapshot so the throttled LOD check is ~free when parked. */
layerState._camSnapValid = false;
layerState._camPos = new Cesium.Cartesian3();
layerState._camDir = new Cesium.Cartesian3();
layerState.request = null;
return layerState;
}
+216
View File
@@ -0,0 +1,216 @@
import * as Cesium from 'cesium';
import {
LOD_LEVELS,
LOD_HYSTERESIS,
VIEW_PADDING,
LOD_CHECK_MS,
} from './policy.js';
export function createViewport({
layerState,
services,
components,
config,
feed,
}) {
const { governorRequestRender } = services.render;
/**
* Ambient labels are placed via screen-space projection, so they need a
* re-declutter after the camera settles — on moveEnd, never per frame.
*/
function installMoveEndWatcher() {
if (layerState._moveEndRemover || !layerState._viewer) return;
layerState._moveEndRemover =
layerState._viewer.camera.moveEnd.addEventListener(() => {
if (!layerState._enabled) return;
// Settled-camera exactness for the horizon pass (the throttled preRender
// watcher can last have run up to LOD_CHECK_MS before the camera
// stopped). Runs ahead of the `_loading` gate on purpose: the sprites on
// screen during a refresh are the ones that need culling.
components.rendering.refreshHorizonCulling();
if (layerState._loading) return;
components.cards.rebuildAmbientLabels();
});
}
function removeMoveEndWatcher() {
if (layerState._moveEndRemover) {
layerState._moveEndRemover();
layerState._moveEndRemover = null;
}
}
function cameraHeight() {
return (
layerState._viewer?.camera?.positionCartographic?.height ??
Number.POSITIVE_INFINITY
);
}
/** Raw LOD index for a camera height (0 = global ... 3 = close). */
function rawLodIndex(height) {
const index = LOD_LEVELS.findIndex((level) => height >= level.minHeight);
return index === -1 ? LOD_LEVELS.length - 1 : index;
}
/**
* LOD selection with ±10% hysteresis around band floors: a band switch
* only happens once the camera clears the boundary by the margin, so a
* slow zoom oscillating on an edge cannot trigger a rebuild every check.
* @param {number} height - Camera height in meters.
* @returns {number} LOD index to render.
*/
function selectLodIndex(height) {
const raw = rawLodIndex(height);
if (layerState._currentLodIndex < 0 || raw === layerState._currentLodIndex)
return raw;
let index = layerState._currentLodIndex;
// Zooming out: enter a coarser band only after clearing its floor by +10%.
while (
index > raw &&
height >= LOD_LEVELS[index - 1].minHeight * (1 + LOD_HYSTERESIS)
)
index -= 1;
// Zooming in: leave the current band only after dropping 10% below its floor.
while (
index < raw &&
height <= LOD_LEVELS[index].minHeight * (1 - LOD_HYSTERESIS)
)
index += 1;
return index;
}
/** Current camera view rectangle, or null when looking at sky/horizon. */
function computeViewRect() {
try {
const rect = layerState._viewer?.camera?.computeViewRectangle(
layerState._viewer?.scene?.globe?.ellipsoid,
layerState.scratchViewRect,
);
return rect || null;
} catch {
return null;
}
}
/**
* True when the camera view rectangle drifted far enough from the last
* rendered one that the padding is at risk of being consumed (pan) or
* the footprint changed notably (zoom within a band).
* @param {?Cesium.Rectangle} viewRect - Current view rectangle.
* @returns {boolean}
*/
function viewChangedEnough(viewRect) {
if (!viewRect) return false; // sky/horizon — keep the current render
if (!layerState._lastViewRect) return true; // previous render was global fallback
const lastWidth = Cesium.Rectangle.computeWidth(layerState._lastViewRect);
const lastHeight = Cesium.Rectangle.computeHeight(layerState._lastViewRect);
const width = Cesium.Rectangle.computeWidth(viewRect);
const height = Cesium.Rectangle.computeHeight(viewRect);
if (width > lastWidth * 1.3 || width < lastWidth * 0.75) return true;
if (height > lastHeight * 1.3 || height < lastHeight * 0.75) return true;
const lastCenter = Cesium.Rectangle.center(
layerState._lastViewRect,
layerState.scratchCenterA,
);
const center = Cesium.Rectangle.center(viewRect, layerState.scratchCenterB);
const latShift = Math.abs(center.latitude - lastCenter.latitude);
let lonShift = Math.abs(center.longitude - lastCenter.longitude);
if (lonShift > Math.PI) lonShift = Cesium.Math.TWO_PI - lonShift;
return (
latShift > lastHeight * VIEW_PADDING * 0.6 ||
lonShift > lastWidth * VIEW_PADDING * 0.6
);
}
function installLodWatcher() {
if (layerState._preRenderRemover || !layerState._viewer) return;
// The LOD sweep is throttle-gated inside preRender. When the camera
// settles just inside the throttle window, the final rebuild would wait
// for a frame that idle mode never produces — schedule one. (perf wave 2)
if (!layerState._moveEndSettleRemover) {
layerState._moveEndSettleRemover =
layerState._viewer.camera.moveEnd.addEventListener(() => {
if (!layerState._enabled) return;
setTimeout(
() => governorRequestRender('firms-lod-settle'),
LOD_CHECK_MS + 40,
);
});
}
layerState._preRenderRemover =
layerState._viewer.scene.preRender.addEventListener(() => {
// NOTE: `_loading`/empty-data are deliberately NOT part of this guard.
// A refresh has no timeout, and sprites from the previous payload stay
// on screen throughout it — so the horizon pass below must keep running
// while a fetch is in flight. Only the rebuild is gated on fresh data.
if (!layerState._enabled) return;
const now = performance.now();
if (now - layerState._lastLodCheck < LOD_CHECK_MS) return;
layerState._lastLodCheck = now;
// Idle camera: two scratch compares and out — no view-rect math, no
// aggregation, no allocation while the user is parked.
const camera = layerState._viewer.camera;
if (
layerState._camSnapValid &&
Cesium.Cartesian3.equalsEpsilon(
camera.positionWC,
layerState._camPos,
0,
0.5,
) &&
Cesium.Cartesian3.equalsEpsilon(
camera.directionWC,
layerState._camDir,
0,
1e-7,
)
) {
return;
}
Cesium.Cartesian3.clone(camera.positionWC, layerState._camPos);
Cesium.Cartesian3.clone(camera.directionWC, layerState._camDir);
layerState._camSnapValid = true;
// The camera moved, so the horizon moved. A rebuild culls its own fresh
// sprites (renderDetections); an early-out — same LOD band and view
// rect, or a refresh in flight — does not, so cull here instead.
// Exactly ONE occlusion walk per tick either way, bounded by the
// idle-gate + LOD_CHECK_MS throttle above: a parked camera costs
// nothing, a moving one pays one ≤maxDetections show-flip walk at
// ~1.5 Hz.
const rebuilt =
layerState._fires.length && !layerState._loading
? components.rendering.renderCurrentLod(false)
: false;
if (!rebuilt) components.rendering.refreshHorizonCulling();
});
}
function removeLodWatcher() {
if (layerState._preRenderRemover) {
layerState._preRenderRemover();
layerState._preRenderRemover = null;
}
if (layerState._moveEndSettleRemover) {
layerState._moveEndSettleRemover();
layerState._moveEndSettleRemover = null;
}
}
return {
installMoveEndWatcher,
removeMoveEndWatcher,
cameraHeight,
rawLodIndex,
selectLodIndex,
computeViewRect,
viewChangedEnough,
installLodWatcher,
removeLodWatcher,
};
}
+9 -4
View File
@@ -6,9 +6,12 @@ import { fileURLToPath } from 'node:url';
export function readLayerSource(file) {
const path = file instanceof URL ? fileURLToPath(file) : file;
if (
!['flights.js', 'militaryFlights.js', 'aisLiveVessels.js'].includes(
basename(path),
)
![
'flights.js',
'militaryFlights.js',
'aisLiveVessels.js',
'firmsHeatmap.js',
].includes(basename(path))
)
return readFileSync(path, 'utf8');
const directory = join(
@@ -17,7 +20,9 @@ export function readLayerSource(file) {
? '../layers/flights'
: basename(path) === 'militaryFlights.js'
? '../layers/military'
: '../layers/vessels',
: basename(path) === 'firmsHeatmap.js'
? '../layers/firms'
: '../layers/vessels',
);
return readdirSync(directory)
.filter((name) => name.endsWith('.js'))