diff --git a/CHANGELOG.md b/CHANGELOG.md index d305a7af8..11b35b8f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/TESTING.md b/TESTING.md index fb9ca960a..ed070a03d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 40f0b537c..368461efd 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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, diff --git a/package.json b/package.json index 4e0bda32c..d69f93e4c 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index d10742f80..6c7e3631a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e72e82380..84bbb1f5b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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"] } } diff --git a/scripts/qa-firms.mjs b/scripts/qa-firms.mjs index dd93013e6..d32ede533 100644 --- a/scripts/qa-firms.mjs +++ b/scripts/qa-firms.mjs @@ -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') }); } } diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 5cc39544f..2b8d3367f 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -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'); diff --git a/src/data/fireAnchors.js b/src/data/fireAnchors.js index 60736a3d6..7d0912b03 100644 --- a/src/data/fireAnchors.js +++ b/src/data/fireAnchors.js @@ -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} 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} 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; diff --git a/src/data/firmsHeatmap.js b/src/data/firmsHeatmap.js index f3990e6de..4b2428cf9 100644 --- a/src/data/firmsHeatmap.js +++ b/src/data/firmsHeatmap.js @@ -1,1607 +1,50 @@ import * as Cesium from 'cesium'; -import { governorRequestRender } from '../renderGovernor.js'; import { - registerSpriteCollection, - restoreSpriteOrder, - restoreSpriteOrderOnEnable, -} from './spriteOrder.js'; -import { - clearSelectedEntityContextForLayer, - getContextStore, - registerEntityContext, - selectEntityContext, -} from './contextStore.js'; -import { - isOwnedByOtherLayer, - registerPickOwner, - resolvePickId, - unregisterPickOwner, -} from './pickRegistry.js'; -import { adaptFirmsRecords } from './firmsAdapt.js'; -import { fireAnchorHeight, warmFireAnchorFloors } from './fireAnchors.js'; -import { horizonOccluder } from './iconOrientation.js'; -import { - accentForSeverity, - fireDetectionKey, - FIRMS_AMBIENT_COHORT_LIMIT, - FIRMS_OVERLAY_SOURCE_ID, - satelliteShortName, -} from './firmsLabels.js'; -import { - clearOverlaySource, - hitTestWorldOverlay, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; -import { requestWorldFocus } from '../worldFocus.js'; + createFirmsHeatmapLayer as createLayer, + createFirmsHelpers, + createFirmsSource, +} from '../layers/firms/index.js'; +import * as render from '../renderGovernor.js'; +import * as sprites from './spriteOrder.js'; +import * as context from './contextStore.js'; +import * as picking from './pickRegistry.js'; +import * as anchors from './fireAnchors.js'; +import * as overlays from '../overlays/worldOverlay.js'; +import * as focus from '../worldFocus.js'; +const services = { + render, + sprites, + context, + picking, + anchors, + overlays, + focus, +}; +const helpers = createFirmsHelpers({ services }); +export const mapAnalystRecord = helpers.mapAnalystRecord; +export const fireCullPosition = helpers.fireCullPosition; +export const applyHorizonCull = helpers.applyHorizonCull; +export const buildSelectedFireCard = helpers.buildSelectedFireCard; +export const buildFireCard = helpers.buildFireCard; +export const buildCellCard = helpers.buildCellCard; +export const applyFirmsOverlayPolicy = helpers.applyFirmsOverlayPolicy; -/** Same-origin live-fires proxy (vite.config.js firmsProxy — key stays server-side). */ -const FIRMS_API_URL = '/api/firms'; -/** Client poll interval; the proxy's 30 min TTL is what guards upstream quota. */ -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. - */ -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 }, -]; -const LOD_CHECK_MS = 650; -/** +/-10% hysteresis on LOD band edges so slow zooms don't thrash rebuilds. */ -const LOD_HYSTERESIS = 0.1; -/** Padding fraction applied to the camera view rectangle before clipping. */ -const VIEW_PADDING = 0.3; -/** Number of top-FRP detections registered in the shared context store. */ -const CONTEXT_TOP_N = 50; -/** Hard cap on ambient (non-selected) labels, regardless of zoom/density. */ -const MAX_AMBIENT_LABELS = FIRMS_AMBIENT_COHORT_LIMIT; -/** Min screen-space separation between accepted labels, in CSS pixels. */ -const LABEL_MIN_SEP_PX = 150; -/** Candidates projecting outside the canvas by more than this are skipped. */ -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. - */ -const CULL_LIFT_THRESHOLD_M = 10; -/** Height of the lifted occlusion-test point (flights uses the same 12 m). */ -const CULL_LIFT_M = 12; - -/** Color stops shared by cell heat fills and detection glow sprites. */ -const DETECTION_COLOR_STOPS = [ - { name: 'red', color: Cesium.Color.RED }, - { name: 'orange', color: Cesium.Color.ORANGE }, - { name: 'yellow', color: Cesium.Color.YELLOW }, -]; - -/** Pre-baked radial-glow sprites keyed by `:`. */ -const glowSpriteCache = new Map(); - -const scratchViewRect = new Cesium.Rectangle(); -const scratchCenterA = new Cesium.Cartographic(); -const scratchCenterB = new Cesium.Cartographic(); -const scratchWindowCoord = new Cesium.Cartesian2(); - -/** - * 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}} - */ -export 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 - }; -} - -export function createFirmsHeatmapLayer({ - id, - name, - icon = '▲', - source = 'NASA FIRMS', - overlayHost = { - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, - hitTest: hitTestWorldOverlay, - }, - screenSpaceEventHandlerFactory = (viewer) => ( - new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas) - ), -}) { - let _viewer = null; - let _dataSource = null; - let _billboards = null; - let _enabled = false; - let _destroyed = false; - let _loading = false; - /** True when the proxy answered 503 {error:'no_key'} — FIRMS_MAP_KEY unset. */ - let _keyRequired = false; - /** True when the proxy served a cached payload past TTL (upstream failing). */ - let _stale = false; - /** Surfaced error string when the live fetch failed outright. */ - let _error = null; - let _fires = []; - let _firesByFrp = []; - let _count = 0; - let _cellCount = 0; - let _lastUpdate = null; - let _currentLodId = null; - let _currentLodIndex = -1; - let _lastViewRect = null; - let _preRenderRemover = null; - let _moveEndSettleRemover = null; - let _lastLodCheck = 0; - let _contextIds = new Set(); - let _clickHandler = null; - let _moveEndRemover = null; - let _selectedFire = null; - /** Priority-ordered label candidates from the last rebuild (detections or cells). */ - let _labelCandidates = []; - let _labelLodDistance = 0; - /** pick id string -> fire record, for the currently rendered sprites. */ - const _pickIndexById = new Map(); - /** Actionable card id -> current detection record painted for that id. */ - const _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} - */ - const _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>} - */ - const _cellCacheByGrid = new Map(); - /** Camera idle snapshot so the throttled LOD check is ~free when parked. */ - let _camSnapValid = false; - const _camPos = new Cesium.Cartesian3(); - const _camDir = new Cesium.Cartesian3(); - - return { - 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, - - init(viewer) { - if (_destroyed) return; - _viewer = viewer; - _dataSource = new Cesium.CustomDataSource(id); - _dataSource.show = false; - viewer.dataSources.add(_dataSource); +/** Supply the local source and the application's scene service owners. */ +export function createFirmsHeatmapLayer(options) { + return createLayer({ + ...options, + icon: options.icon ?? '▲', + source: options.source ?? 'NASA FIRMS', + feed: options.feed ?? createFirmsSource(), + services, + overlayHost: options.overlayHost ?? { + setEntries: overlays.setOverlayEntries, + setVisible: overlays.setOverlaySourceVisible, + clearSource: overlays.clearOverlaySource, + hitTest: overlays.hitTestWorldOverlay, }, - - async enable(viewer) { - if (_destroyed) return; - _enabled = true; - _viewer = viewer; - if (!_dataSource) this.init(viewer); - if (_dataSource) _dataSource.show = true; - if (_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. - refreshHorizonCulling(); - _billboards.show = true; - } - overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true); - installLodWatcher(); - installMoveEndWatcher(); - installClickHandler(); - registerPickOwner(id, (pickedId) => _pickIndexById.has(pickedId)); - if (!_fires.length && !_loading) await loadHeatmap(); - restoreSpriteOrderOnEnable('firms', viewer); - }, - - disable() { - _enabled = false; - clearFireSelection(); - if (_dataSource) _dataSource.show = false; - if (_billboards) _billboards.show = false; - overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID); - overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); - clearSelectedEntityContextForLayer(id); - removeClickHandler(); - removeMoveEndWatcher(); - unregisterPickOwner(id); - removeLodWatcher(); - }, - - async update() { - if (_destroyed || !_enabled || _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(); - }, - - destroy(viewer) { - if (_destroyed) return; - _destroyed = true; - _enabled = false; - removeLodWatcher(); - removeMoveEndWatcher(); - removeClickHandler(); - unregisterPickOwner(id); - if (_dataSource && viewer) { - viewer.dataSources.remove(_dataSource, true); - } - _dataSource = null; - removeDetectionCollections(viewer); - clearContextRegistrations(); - clearSelectedEntityContextForLayer(id); - _fires = []; - _firesByFrp = []; - _cellCacheByGrid.clear(); - _count = 0; - _cellCount = 0; - _lastUpdate = null; - _keyRequired = false; - _stale = false; - _error = null; - _currentLodId = null; - overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID); - overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); - _currentLodIndex = -1; - _lastViewRect = null; - _selectedFire = null; - _labelCandidates = []; - _labelLodDistance = 0; - _pickIndexById.clear(); - _fireByCardId.clear(); - _cullPositions.length = 0; - _camSnapValid = false; - }, - - /** - * 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 = _lastUpdate ? `STALE · cached ${formatAge(now - _lastUpdate) || '<1h'}` : 'STALE'; - let loadingLabel = ''; - if (_loading) { - loadingLabel = _fires.length ? 'refreshing...' : 'loading...'; - } else if (_keyRequired) { - loadingLabel = 'KEY REQUIRED'; - } else if (_stale) { - loadingLabel = staleText; - } else if (_error) { - loadingLabel = _error; - } else if (_lastUpdate) { - loadingLabel = `LIVE · updated ${formatAgoMinutes(now - _lastUpdate)}`; - } - return { - count: _count, - cells: _cellCount, - lastUpdate: _lastUpdate, - loading: _loading, - stale: _stale, - error: _keyRequired ? 'KEY REQUIRED' : (_stale ? staleText : _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 = _firesByFrp.length ? _firesByFrp[0] : null; - if (!strongest) return null; - return { - latitude: strongest.lat, - longitude: strongest.lon, - frp: strongest.frp, - label: `Fire · FRP ${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 (!_enabled || !_firesByFrp.length) return []; - const maxCount = Number.isFinite(options.maxCount) - ? Math.max(1, Math.floor(options.maxCount)) - : 250; - const result = []; - for (const fire of _firesByFrp) { - result.push({ - position: 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} See mapAnalystRecord for the record shape. - */ - getAnalystRecords(maxCount = 2000) { - if (!_enabled || !_firesByFrp.length) return []; - const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; - const result = []; - for (const fire of _firesByFrp) { - result.push(mapAnalystRecord(fire)); - if (result.length >= limit) break; - } - return result; - }, - - /** Test seam that binds the production click path and indexes. */ - _bindInteractionForTest(viewer, fires = []) { - _viewer = viewer; - _enabled = true; - _fires = fires; - _firesByFrp = [...fires]; - _pickIndexById.clear(); - _labelLodDistance = 1e7; - _labelCandidates = fires.map((fire) => { - _pickIndexById.set(`firms-${fire.index}`, fire); - return { fire, position: firePosition(fire) }; - }); - rebuildAmbientLabels(); - installClickHandler(); - return _clickHandler; - }, - }; - - /** - * Fetch live fires from the /api/firms proxy and swap them in. Handles the - * three degraded states distinctly: keyless (503 {error:'no_key'} → KEY - * REQUIRED, no retry storm — the 10 min poll re-checks cheaply without - * touching upstream), stale (proxy served past-TTL cache → surfaced via - * getStats), and outright failure (existing fires are KEPT — old data - * beats a wiped map). A click-selected fire survives the swap when the - * same detection (lat/lon/acqMs) is still present. - */ - async function loadHeatmap() { - if (!_dataSource) return; - _loading = true; - - try { - const response = await fetch(FIRMS_API_URL, { cache: 'no-store' }); - if (!response.ok) { - let payload = null; - try { - payload = await response.json(); - } catch { /* non-JSON error body — fall through to the generic error */ } - if (response.status === 503 && payload?.error === 'no_key') { - _keyRequired = true; - _error = null; - _stale = false; - return; - } - throw new Error(`FIRMS HTTP ${response.status}`); - } - - const payload = await response.json(); - _keyRequired = false; - _error = null; - _stale = Boolean(payload?.stale); - const previousSelection = _selectedFire; - _selectedFire = null; - _fires = adaptFirmsRecords(payload?.fires); - _cellCacheByGrid.clear(); // aggregation is per-dataset — new fires, new cells - _firesByFrp = [..._fires].sort((a, b) => b.frp - a.frp); - _count = _fires.length; - // Data age, not response age: a stale proxy payload truthfully reads old. - _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 }); - } - renderCurrentLod(true); - if (reselected) selectFire(reselected); - } catch (error) { - console.warn(`[Data:${id}] FIRMS live load failed:`, error); - _error = 'live feed unavailable'; - } finally { - _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 _fires.find((fire) => fireDetectionKey(fire) === key) || null; - } - - /** - * 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 (!_dataSource || !_fires.length || !_viewer) return false; - const lodIndex = selectLodIndex(cameraHeight()); - const lod = LOD_LEVELS[lodIndex]; - const viewRect = computeViewRect(); - if (!force && lodIndex === _currentLodIndex && !viewChangedEnough(viewRect)) return false; - _currentLodIndex = lodIndex; - _currentLodId = lod.id; - _lastViewRect = viewRect ? Cesium.Rectangle.clone(viewRect) : null; - const bounds = viewRect ? 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} Cells sorted by heat score, capped. - */ - function aggregateFires(lod, bounds) { - const gridDegrees = lod.gridDegrees; - - let sorted = _cellCacheByGrid.get(gridDegrees); - if (!sorted) { - const cells = new Map(); - for (const fire of _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) => heatScore(b) - heatScore(a)); - _cellCacheByGrid.set(gridDegrees, sorted); - } - - if (!bounds) return sorted.slice(0, lod.maxCells); - const clipped = []; - for (const cell of sorted) { - if (!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} 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(); - _dataSource.entities.removeAll(); - if (_billboards) _billboards.removeAll(); - _pickIndexById.clear(); - _cullPositions.length = 0; - _cellCount = cells.length; - const maxScore = Math.max(1, ...cells.map(heatScore)); - - _labelCandidates = []; - _labelLodDistance = lod.labelDistance; - - for (const cell of cells) { - const score = heatScore(cell); - const normalized = Math.min(1, Math.sqrt(score / maxScore)); - const alpha = 0.16 + normalized * 0.5; - const color = 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); - - _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. - _labelCandidates.push({ - position, - cullPosition: cellCullPosition(centerLon, centerLat), - fire: null, - cell, - accent: cellAccent(normalized), - }); - } - - rebuildAmbientLabels(); - refreshContextRegistrations(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(); - _dataSource.entities.removeAll(); - if (!_billboards) return; - _billboards.removeAll(); - _pickIndexById.clear(); - _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 _firesByFrp) { - if (!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 = _firesByFrp.slice(0, lod.maxDetections); - } - - _cellCount = candidates.length; - _labelCandidates = []; - _labelLodDistance = lod.labelDistance; - - for (const fire of candidates) { - const coreSize = frpPixelSize(fire.frp); - const position = firePosition(fire); - const cullPosition = fireCullPosition(fire); - const pickId = `firms-${fire.index}`; - _pickIndexById.set(pickId, fire); - _cullPositions.push(cullPosition); - _billboards.add({ - id: pickId, - position, - image: glowSprite(detectionColorStop(fire), sizeBucket(coreSize)), - verticalOrigin: Cesium.VerticalOrigin.CENTER, - horizontalOrigin: Cesium.HorizontalOrigin.CENTER, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }); - // FRP-sorted walk: candidate order doubles as label priority. - _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(); - rebuildAmbientLabels(); - 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 || !_enabled || !_viewer) return; - const currentLod = LOD_LEVELS[_currentLodIndex]; - if (!currentLod || currentLod.mode !== 'detections') return; - renderCurrentLod(true); - }); - } - } - - /** - * 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 (!_viewer) return; - const scene = _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 = fireHorizonOccluder(); - const beyondHorizon = (position) => ( - occluder ? occluder.isPointVisible(position) !== true : false - ); - _fireByCardId.clear(); - - if (_selectedFire) { - const selectedCard = buildSelectedFireCard(_selectedFire, now); - _fireByCardId.set(selectedCard.id, _selectedFire); - entries.push(selectedCard); - const screen = Cesium.SceneTransforms.worldToWindowCoordinates( - scene, - firePosition(_selectedFire), - 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(fireCullPosition(_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 _labelCandidates) { - if (ambientCount >= MAX_AMBIENT_LABELS) break; - if (candidate.fire && candidate.fire === _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, - 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 (!screenSeparated(accepted, screen)) continue; - accepted.push({ x: screen.x, y: screen.y }); - ambientCount += 1; - const card = candidate.fire - ? buildFireCard(candidate, now) - : buildCellCard(candidate, now); - if (candidate.fire) _fireByCardId.set(card.id, candidate.fire); - entries.push(card); - } - - overlayHost.setEntries( - FIRMS_OVERLAY_SOURCE_ID, - entries.map((entry) => { - const card = applyFirmsOverlayPolicy(entry, _labelLodDistance); - if (!card.interactive) return card; - return { - ...card, - accessibilityLabel: `Focus fire detection ${card.title}, ${card.details.join(', ')}`, - activate: () => { - const fire = _fireByCardId.get(card.id); - if (!fire) return false; - selectAndFocusFire(fire); - return true; - }, - }; - }), - { - cohortLimit: FIRMS_AMBIENT_COHORT_LIMIT, - collisionCapacity: FIRMS_AMBIENT_COHORT_LIMIT, - moving: false, - }, - ); - } - - /** - * 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 (_clickHandler || !_viewer) return; - _clickHandler = screenSpaceEventHandlerFactory(_viewer); - _clickHandler.setInputAction((click) => { - const picked = _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 = _fireByCardId.get(cardHit.entryId); - if (carded) selectAndFocusFire(carded); - return; - } - clearFireSelection(); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - } - - function removeClickHandler() { - if (_clickHandler) { - _clickHandler.destroy(); - _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: 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' && _pickIndexById.has(primitiveId)) { - return _pickIndexById.get(primitiveId); - } - if (typeof picked.id === 'string' && _pickIndexById.has(picked.id)) { - return _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) { - _selectedFire = fire; - try { - registerFireContext(fire); - _contextIds.add(fireDetectionKey(fire)); - selectEntityContext(fire.contextEntity); - } catch { - // context store unavailable — the selection label still works - } - rebuildAmbientLabels(); - } - - function clearFireSelection() { - if (!_selectedFire) return; - _selectedFire = null; - clearSelectedEntityContextForLayer(id); - rebuildAmbientLabels(); - } - - /** - * 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 (_moveEndRemover || !_viewer) return; - _moveEndRemover = _viewer.camera.moveEnd.addEventListener(() => { - if (!_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. - refreshHorizonCulling(); - if (_loading) return; - rebuildAmbientLabels(); - }); - } - - function removeMoveEndWatcher() { - if (_moveEndRemover) { - _moveEndRemover(); - _moveEndRemover = null; - } - } - - /** - * 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} 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 (_selectedFire) { - nextIds.add(registerFireContext(_selectedFire)); - } - - for (const staleId of _contextIds) { - if (!nextIds.has(staleId)) store.entities.delete(staleId); - } - _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 = firePosition(fire); - registerEntityContext(fire.contextEntity, { - id: recordId, - layerId: id, - layerName: name, - source: 'NASA FIRMS', - dataSource: _dataSource, - label: `Fire · FRP ${formatFrp(fire.frp)} MW`, - latitude: fire.lat, - longitude: fire.lon, - properties: { - frp: fire.frp, - confidence: confidenceBucket(fire.confidence), - age: fire.acqMs > 0 ? formatAge(Date.now() - fire.acqMs) : 'unknown', - sensor: fire.sensor || 'unknown', - }, - }); - return recordId; - } - - function clearContextRegistrations() { - if (!_contextIds.size) return; - try { - const store = getContextStore(); - for (const recordId of _contextIds) store.entities.delete(recordId); - } catch { - // store unavailable — nothing to clean - } - _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} - */ - function topFiresWithinBounds(bounds, limit = CONTEXT_TOP_N) { - if (!bounds) return _firesByFrp.slice(0, limit); - const top = []; - for (const fire of _firesByFrp) { - if (!boundsContainPoint(bounds, fire.lat, fire.lon)) continue; - top.push(fire); - if (top.length >= limit) break; - } - return top; - } - - function ensureDetectionCollections() { - if (!_viewer || _billboards) return; - _billboards = new Cesium.BillboardCollection({ - scene: _viewer.scene, - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - _billboards.show = _enabled; - _viewer.scene.primitives.add(_billboards); - registerSpriteCollection('firms', _billboards); - restoreSpriteOrder(_viewer); - overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, _enabled); - } - - function removeDetectionCollections(viewer) { - const scene = viewer?.scene || _viewer?.scene; - if (scene && !scene.isDestroyed?.()) { - try { - if (_billboards) scene.primitives.remove(_billboards); - } catch { /* already torn down */ } - } - _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 = _viewer?.camera?.positionWC ? _viewer.camera : _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 (!_billboards || !_billboards.length) return; - applyHorizonCull(_billboards, fireHorizonOccluder(), _cullPositions); - } - - function cameraHeight() { - return _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 (_currentLodIndex < 0 || raw === _currentLodIndex) return raw; - let index = _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 = _viewer?.camera?.computeViewRectangle( - _viewer?.scene?.globe?.ellipsoid, - 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 (!_lastViewRect) return true; // previous render was global fallback - const lastWidth = Cesium.Rectangle.computeWidth(_lastViewRect); - const lastHeight = Cesium.Rectangle.computeHeight(_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(_lastViewRect, scratchCenterA); - const center = Cesium.Rectangle.center(viewRect, 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 (_preRenderRemover || !_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 (!_moveEndSettleRemover) { - _moveEndSettleRemover = _viewer.camera.moveEnd.addEventListener(() => { - if (!_enabled) return; - setTimeout(() => governorRequestRender('firms-lod-settle'), LOD_CHECK_MS + 40); - }); - } - _preRenderRemover = _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 (!_enabled) return; - const now = performance.now(); - if (now - _lastLodCheck < LOD_CHECK_MS) return; - _lastLodCheck = now; - // Idle camera: two scratch compares and out — no view-rect math, no - // aggregation, no allocation while the user is parked. - const camera = _viewer.camera; - if (_camSnapValid - && Cesium.Cartesian3.equalsEpsilon(camera.positionWC, _camPos, 0, 0.5) - && Cesium.Cartesian3.equalsEpsilon(camera.directionWC, _camDir, 0, 1e-7)) { - return; - } - Cesium.Cartesian3.clone(camera.positionWC, _camPos); - Cesium.Cartesian3.clone(camera.directionWC, _camDir); - _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 = (_fires.length && !_loading) ? renderCurrentLod(false) : false; - if (!rebuilt) refreshHorizonCulling(); - }); - } - - function removeLodWatcher() { - if (_preRenderRemover) { - _preRenderRemover(); - _preRenderRemover = null; - } - if (_moveEndSettleRemover) { - _moveEndSettleRemover(); - _moveEndSettleRemover = 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 = 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'); - 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} - */ -export 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} [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). - */ -export 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. - */ -export 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. - */ -export 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. - */ -export 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} - */ -export 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'; + screenSpaceEventHandlerFactory: + options.screenSpaceEventHandlerFactory ?? + ((viewer) => new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)), + }); } diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index 8d4cc1d8c..442af60b2 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -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(); } +}); diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 120a8dd74..8378cf5b0 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -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( diff --git a/src/data/spriteOrder.test.mjs b/src/data/spriteOrder.test.mjs index 1225e462e..e5ea59d6c 100644 --- a/src/data/spriteOrder.test.mjs +++ b/src/data/spriteOrder.test.mjs @@ -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', ); }); diff --git a/src/layers/firms/anchors.js b/src/layers/firms/anchors.js new file mode 100644 index 000000000..8b740bdab --- /dev/null +++ b/src/layers/firms/anchors.js @@ -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} 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} 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 }; +} diff --git a/src/layers/firms/cards.js b/src/layers/firms/cards.js new file mode 100644 index 000000000..6b8a72ab6 --- /dev/null +++ b/src/layers/firms/cards.js @@ -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 }; +} diff --git a/src/layers/firms/index.js b/src/layers/firms/index.js new file mode 100644 index 000000000..080755872 --- /dev/null +++ b/src/layers/firms/index.js @@ -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'; diff --git a/src/layers/firms/ingestion.js b/src/layers/firms/ingestion.js new file mode 100644 index 000000000..e363fe72b --- /dev/null +++ b/src/layers/firms/ingestion.js @@ -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 }; +} diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js new file mode 100644 index 000000000..7e0b42a00 --- /dev/null +++ b/src/layers/firms/lifecycle.js @@ -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 }; +} diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js new file mode 100644 index 000000000..563a2d735 --- /dev/null +++ b/src/layers/firms/model.js @@ -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} [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, + }; +} diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js new file mode 100644 index 000000000..673aab871 --- /dev/null +++ b/src/layers/firms/policy.js @@ -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 }, +]; diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js new file mode 100644 index 000000000..d1dcb3f5e --- /dev/null +++ b/src/layers/firms/queries.js @@ -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} 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 }; +} diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js new file mode 100644 index 000000000..8ae445f91 --- /dev/null +++ b/src/layers/firms/rendering.js @@ -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} 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} 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, + }; +} diff --git a/src/layers/firms/selection.js b/src/layers/firms/selection.js new file mode 100644 index 000000000..4cec52c69 --- /dev/null +++ b/src/layers/firms/selection.js @@ -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} 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} + */ + + 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, + }; +} diff --git a/src/layers/firms/source.js b/src/layers/firms/source.js new file mode 100644 index 000000000..9ee4ebc6c --- /dev/null +++ b/src/layers/firms/source.js @@ -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; + }, + }; +} diff --git a/src/layers/firms/source.test.mjs b/src/layers/firms/source.test.mjs new file mode 100644 index 000000000..f67bcdd9b --- /dev/null +++ b/src/layers/firms/source.test.mjs @@ -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', + }); +}); diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js new file mode 100644 index 000000000..48ae11db2 --- /dev/null +++ b/src/layers/firms/state.js @@ -0,0 +1,115 @@ +import * as Cesium from 'cesium'; + +export function createFirmsState({ services, config }) { + const layerState = {}; + + /** Pre-baked radial-glow sprites keyed by `:`. */ + + 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} + */ + + 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>} + */ + + 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; +} diff --git a/src/layers/firms/viewport.js b/src/layers/firms/viewport.js new file mode 100644 index 000000000..932fe2e75 --- /dev/null +++ b/src/layers/firms/viewport.js @@ -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, + }; +} diff --git a/src/testSupport/readLayerSource.mjs b/src/testSupport/readLayerSource.mjs index e0693bc20..57a1a2069 100644 --- a/src/testSupport/readLayerSource.mjs +++ b/src/testSupport/readLayerSource.mjs @@ -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'))