mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
refactor(fires): separate source, rendering and interaction components
This commit is contained in:
1 parent
e99cc8cf68
commit
6a9f13eab8
28 files changed
+2540
-1731
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -1,5 +1,13 @@
|
||||
# God's Eye View Current State
|
||||
|
||||
The fire layer now exposes `./layers/firms`: an instance factory with separate
|
||||
snapshot requests, records, rendering, cards, selection, viewport scheduling and
|
||||
terrain-anchor batching. The standalone entry supplies the existing FIRMS source
|
||||
and scene services. Disable and teardown cancel pending refreshes; malformed
|
||||
snapshots preserve the last good display. Refresh restoration keeps selection
|
||||
identity without announcing a new user selection. Source/label, LOD thresholds,
|
||||
card limits, fire identities, altitude placement and analyst records are retained.
|
||||
|
||||
Earthquake rendering is exposed through `./layers/earthquakes`. The layer owns
|
||||
its entities and request lifecycle; the application supplies the overlay host
|
||||
and snapshot source. The standalone adapter keeps the existing USGS daily feed,
|
||||
|
||||
+2
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
@@ -694,5 +694,27 @@
|
||||
"src/layers/earthquakes/source.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"fire-layer": {
|
||||
"exports": ["./layers/firms"],
|
||||
"modules": [
|
||||
"src/data/firmsAdapt.js",
|
||||
"src/data/firmsLabels.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/layers/firms/anchors.js",
|
||||
"src/layers/firms/cards.js",
|
||||
"src/layers/firms/index.js",
|
||||
"src/layers/firms/ingestion.js",
|
||||
"src/layers/firms/lifecycle.js",
|
||||
"src/layers/firms/model.js",
|
||||
"src/layers/firms/policy.js",
|
||||
"src/layers/firms/queries.js",
|
||||
"src/layers/firms/rendering.js",
|
||||
"src/layers/firms/selection.js",
|
||||
"src/layers/firms/source.js",
|
||||
"src/layers/firms/state.js",
|
||||
"src/layers/firms/viewport.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
}
|
||||
}
|
||||
+69
-29
@@ -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') });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
|
||||
const firms = fs.readFileSync(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'), 'utf8');
|
||||
const firms = readLayerSource(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'));
|
||||
const vessels = readLayerSource(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'));
|
||||
const voice = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8');
|
||||
const cameraVerbs = fs.readFileSync(path.join(ROOT, 'src', 'cameraVerbs.js'), 'utf8');
|
||||
|
||||
+9
-87
@@ -1,87 +1,9 @@
|
||||
// src/data/fireAnchors.js — DEM ground anchors for rendered FIRMS detections
|
||||
// (owner field finding 2026-07-21: at close/oblique zoom over high country,
|
||||
// fire dots anchored at ellipsoid height 0 read as buried inside the terrain
|
||||
// ~1-2 km below the visible surface).
|
||||
//
|
||||
// The fix rides the height-datum ship's machinery end to end: anchors read
|
||||
// the SAME shared ground floor every other ground-adjacent consumer uses
|
||||
// (groundFloor.cachedGroundFloor — rendered-mesh cell ?? real Re:Earth DEM
|
||||
// cell), and cold cells warm through the same batched, chunked, cached
|
||||
// resolver. No 3D-Tiles sampling happens here (zero steady-state raycasts);
|
||||
// fires are static, and floor cells latch warm for the session, so each
|
||||
// rendered detection costs at most one DEM lookup EVER — and detections
|
||||
// sharing a ~111 m coarse cell share that lookup.
|
||||
import { cachedGroundFloor, resolveGroundFloorCells } from './groundFloor.js';
|
||||
|
||||
/** @constant {number} Metres above the resolved floor for a fire anchor.
|
||||
* The DEM is bare earth while the fire glow represents a 375 m VIIRS pixel;
|
||||
* a few metres of lift biases "slightly above the visible surface" (owner
|
||||
* principle) without a visible hover on the screen-sized sprite. */
|
||||
export const FIRE_ANCHOR_LIFT_M = 5;
|
||||
|
||||
/**
|
||||
* Synchronous anchor height for one detection: shared ground floor + lift
|
||||
* when the floor is warm, else 0 (the pre-fix ellipsoid anchor — dots render
|
||||
* exactly as before until their floor lands). Warm-cache read only; never
|
||||
* triggers network or sampling.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {number} Ellipsoidal anchor height in metres.
|
||||
*/
|
||||
export function fireAnchorHeight(lat, lon) {
|
||||
const floor = cachedGroundFloor(lat, lon);
|
||||
return floor != null ? floor + FIRE_ANCHOR_LIFT_M : 0;
|
||||
}
|
||||
|
||||
/** @type {?Promise<boolean>} Tail of the batch chain — batches run strictly
|
||||
* sequentially so overlapping renders can't stack concurrent requests on
|
||||
* the single dev-server proxy (same courtesy as terrainHeights' sequential
|
||||
* chunking). Each queued batch re-filters against the warm cache when it
|
||||
* actually runs, so cells resolved by an earlier batch are never refetched. */
|
||||
let _chain = null;
|
||||
|
||||
/**
|
||||
* Batched warm of the ground-floor cells behind a rendered detection set.
|
||||
* Fire-and-forget safe (never rejects). Resolves `true` only when at least
|
||||
* one requested point actually gained a warm floor — a failed resolve (proxy
|
||||
* down caches only geoid fallbacks, which do NOT count as a floor) resolves
|
||||
* `false`, so a render → warm → re-render chain terminates instead of
|
||||
* looping; the next natural rebuild retries and the cache self-heals.
|
||||
* @param {Array<{lat: number, lon: number}>} points - Rendered detections.
|
||||
* @returns {Promise<boolean>} Whether any requested point warmed.
|
||||
*/
|
||||
export function warmFireAnchorFloors(points) {
|
||||
const cold = collectCold(points);
|
||||
if (!cold.length) return Promise.resolve(false);
|
||||
const prev = _chain;
|
||||
const run = prev ? prev.then(() => resolveBatch(cold)) : resolveBatch(cold);
|
||||
_chain = run.then(() => true, () => false);
|
||||
return run;
|
||||
}
|
||||
|
||||
/** @returns {Array<{lat: number, lon: number}>} Points with no warm floor. */
|
||||
function collectCold(points) {
|
||||
if (!Array.isArray(points)) return [];
|
||||
const cold = [];
|
||||
for (const p of points) {
|
||||
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
|
||||
if (cachedGroundFloor(p.lat, p.lon) != null) continue;
|
||||
cold.push({ lat: p.lat, lon: p.lon });
|
||||
}
|
||||
return cold;
|
||||
}
|
||||
|
||||
/** Resolves one batch (re-filtered at run time) and reports whether it warmed anything. */
|
||||
async function resolveBatch(points) {
|
||||
const cold = points.filter((p) => cachedGroundFloor(p.lat, p.lon) == null);
|
||||
if (!cold.length) return false;
|
||||
try {
|
||||
await resolveGroundFloorCells(cold);
|
||||
} catch { /* resolver is best-effort and never throws; belt and braces */ }
|
||||
return cold.some((p) => cachedGroundFloor(p.lat, p.lon) != null);
|
||||
}
|
||||
|
||||
/** Test hook: drops the batch chain (module caches live in groundFloor/terrainHeights). */
|
||||
export function _resetFireAnchorsForTest() {
|
||||
_chain = null;
|
||||
}
|
||||
import { createFireAnchors } from '../layers/firms/anchors.js';
|
||||
import * as groundFloor from './groundFloor.js';
|
||||
export { FIRE_ANCHOR_LIFT_M } from '../layers/firms/anchors.js';
|
||||
const anchors = createFireAnchors(groundFloor);
|
||||
export const {
|
||||
fireAnchorHeight,
|
||||
warmFireAnchorFloors,
|
||||
_resetFireAnchorsForTest,
|
||||
} = anchors;
|
||||
+45
-1602
File diff suppressed because it is too large.
Load diff
@@ -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(); }
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// src/data/fireAnchors.js — DEM ground anchors for rendered FIRMS detections
|
||||
// (owner field finding 2026-07-21: at close/oblique zoom over high country,
|
||||
// fire dots anchored at ellipsoid height 0 read as buried inside the terrain
|
||||
// ~1-2 km below the visible surface).
|
||||
//
|
||||
// The fix rides the height-datum ship's machinery end to end: anchors read
|
||||
// the SAME shared ground floor every other ground-adjacent consumer uses
|
||||
// (groundFloor.cachedGroundFloor — rendered-mesh cell ?? real Re:Earth DEM
|
||||
// cell), and cold cells warm through the same batched, chunked, cached
|
||||
// resolver. No 3D-Tiles sampling happens here (zero steady-state raycasts);
|
||||
// fires are static, and floor cells latch warm for the session, so each
|
||||
// rendered detection costs at most one DEM lookup EVER — and detections
|
||||
// sharing a ~111 m coarse cell share that lookup.
|
||||
|
||||
/** @constant {number} Metres above the resolved floor for a fire anchor.
|
||||
* The DEM is bare earth while the fire glow represents a 375 m VIIRS pixel;
|
||||
* a few metres of lift biases "slightly above the visible surface" (owner
|
||||
* principle) without a visible hover on the screen-sized sprite. */
|
||||
export const FIRE_ANCHOR_LIFT_M = 5;
|
||||
|
||||
/**
|
||||
* Synchronous anchor height for one detection: shared ground floor + lift
|
||||
* when the floor is warm, else 0 (the pre-fix ellipsoid anchor — dots render
|
||||
* exactly as before until their floor lands). Warm-cache read only; never
|
||||
* triggers network or sampling.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {number} Ellipsoidal anchor height in metres.
|
||||
*/
|
||||
export function createFireAnchors({
|
||||
cachedGroundFloor,
|
||||
resolveGroundFloorCells,
|
||||
}) {
|
||||
function fireAnchorHeight(lat, lon) {
|
||||
const floor = cachedGroundFloor(lat, lon);
|
||||
return floor != null ? floor + FIRE_ANCHOR_LIFT_M : 0;
|
||||
}
|
||||
|
||||
/** @type {?Promise<boolean>} Tail of the batch chain — batches run strictly
|
||||
* sequentially so overlapping renders can't stack concurrent requests on
|
||||
* the single dev-server proxy (same courtesy as terrainHeights' sequential
|
||||
* chunking). Each queued batch re-filters against the warm cache when it
|
||||
* actually runs, so cells resolved by an earlier batch are never refetched. */
|
||||
let _chain = null;
|
||||
|
||||
/**
|
||||
* Batched warm of the ground-floor cells behind a rendered detection set.
|
||||
* Fire-and-forget safe (never rejects). Resolves `true` only when at least
|
||||
* one requested point actually gained a warm floor — a failed resolve (proxy
|
||||
* down caches only geoid fallbacks, which do NOT count as a floor) resolves
|
||||
* `false`, so a render → warm → re-render chain terminates instead of
|
||||
* looping; the next natural rebuild retries and the cache self-heals.
|
||||
* @param {Array<{lat: number, lon: number}>} points - Rendered detections.
|
||||
* @returns {Promise<boolean>} Whether any requested point warmed.
|
||||
*/
|
||||
function warmFireAnchorFloors(points) {
|
||||
const cold = collectCold(points);
|
||||
if (!cold.length) return Promise.resolve(false);
|
||||
const prev = _chain;
|
||||
const run = prev ? prev.then(() => resolveBatch(cold)) : resolveBatch(cold);
|
||||
_chain = run.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
/** @returns {Array<{lat: number, lon: number}>} Points with no warm floor. */
|
||||
function collectCold(points) {
|
||||
if (!Array.isArray(points)) return [];
|
||||
const cold = [];
|
||||
for (const p of points) {
|
||||
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
|
||||
if (cachedGroundFloor(p.lat, p.lon) != null) continue;
|
||||
cold.push({ lat: p.lat, lon: p.lon });
|
||||
}
|
||||
return cold;
|
||||
}
|
||||
|
||||
/** Resolves one batch (re-filtered at run time) and reports whether it warmed anything. */
|
||||
async function resolveBatch(points) {
|
||||
const cold = points.filter((p) => cachedGroundFloor(p.lat, p.lon) == null);
|
||||
if (!cold.length) return false;
|
||||
try {
|
||||
await resolveGroundFloorCells(cold);
|
||||
} catch {
|
||||
/* resolver is best-effort and never throws; belt and braces */
|
||||
}
|
||||
return cold.some((p) => cachedGroundFloor(p.lat, p.lon) != null);
|
||||
}
|
||||
|
||||
/** Test hook: drops the batch chain (module caches live in groundFloor/terrainHeights). */
|
||||
function _resetFireAnchorsForTest() {
|
||||
_chain = null;
|
||||
}
|
||||
|
||||
return { fireAnchorHeight, warmFireAnchorFloors, _resetFireAnchorsForTest };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
satelliteShortName,
|
||||
fireDetectionKey,
|
||||
accentForSeverity,
|
||||
} from '../../data/firmsLabels.js';
|
||||
import {
|
||||
VIEW_PADDING,
|
||||
DETECTION_COLOR_STOPS,
|
||||
CULL_LIFT_THRESHOLD_M,
|
||||
CULL_LIFT_M,
|
||||
LABEL_MIN_SEP_PX,
|
||||
} from './policy.js';
|
||||
|
||||
export function createModel({
|
||||
layerState,
|
||||
services,
|
||||
components,
|
||||
config,
|
||||
feed,
|
||||
}) {
|
||||
const { fireAnchorHeight } = services.anchors;
|
||||
|
||||
/**
|
||||
* Map one internal fire record (firmsAdapt.js shape) to a plain JSON-safe
|
||||
* analyst record (analyst query engine seam). Pure — no Cesium types.
|
||||
* Missing/unknown fields are null, never NaN/undefined. The id reuses the
|
||||
* layer's FIRE-##### pick-id convention (index keys pick ids and
|
||||
* context-store ids, so the two stay consistent).
|
||||
* @param {Object|null|undefined} fire - Internal fire record.
|
||||
* @returns {{id: string, lat: number|null, lon: number|null, frp: number|null,
|
||||
* confidence: number|null, satellite: string|null, acqTime: number|null}}
|
||||
*/
|
||||
|
||||
function mapAnalystRecord(fire) {
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
const text = (v) => {
|
||||
const t = String(v ?? '').trim();
|
||||
return t || null;
|
||||
};
|
||||
return {
|
||||
id: `FIRE-${String(fire?.index ?? 0).padStart(5, '0')}`,
|
||||
lat: num(fire?.lat),
|
||||
lon: num(fire?.lon),
|
||||
frp: num(fire?.frp),
|
||||
confidence: num(fire?.confidence), // normalized 0..1 (firmsAdapt.normalizeConfidence)
|
||||
satellite: text(fire?.satellite) || text(fire?.sensor),
|
||||
acqTime:
|
||||
Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? fire.acqMs : null, // epoch ms; 0 = unparseable → null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a view rectangle by VIEW_PADDING per side and convert to degree
|
||||
* bounds with an explicit anti-meridian wrap flag. Returns null for
|
||||
* near-global views where clipping would be pointless.
|
||||
* @param {Cesium.Rectangle} rect - Camera view rectangle (radians).
|
||||
* @returns {?{west: number, south: number, east: number, north: number, wraps: boolean}}
|
||||
*/
|
||||
|
||||
function paddedDegreeBounds(rect) {
|
||||
const width = Cesium.Rectangle.computeWidth(rect);
|
||||
const height = Cesium.Rectangle.computeHeight(rect);
|
||||
if (width >= Math.PI * 1.85) return null;
|
||||
const padLon = width * VIEW_PADDING;
|
||||
const padLat = height * VIEW_PADDING;
|
||||
const south = Math.max(-Cesium.Math.PI_OVER_TWO, rect.south - padLat);
|
||||
const north = Math.min(Cesium.Math.PI_OVER_TWO, rect.north + padLat);
|
||||
let west = rect.west - padLon;
|
||||
let east = rect.east + padLon;
|
||||
let wraps;
|
||||
if (width + padLon * 2 >= Cesium.Math.TWO_PI) {
|
||||
west = -Math.PI;
|
||||
east = Math.PI;
|
||||
wraps = false;
|
||||
} else {
|
||||
west = Cesium.Math.negativePiToPi(west);
|
||||
east = Cesium.Math.negativePiToPi(east);
|
||||
wraps = west > east;
|
||||
}
|
||||
return {
|
||||
west: Cesium.Math.toDegrees(west),
|
||||
south: Cesium.Math.toDegrees(south),
|
||||
east: Cesium.Math.toDegrees(east),
|
||||
north: Cesium.Math.toDegrees(north),
|
||||
wraps,
|
||||
};
|
||||
}
|
||||
|
||||
/** Point-in-bounds test in degrees, anti-meridian aware. */
|
||||
|
||||
function boundsContainPoint(bounds, lat, lon) {
|
||||
if (lat < bounds.south || lat > bounds.north) return false;
|
||||
if (bounds.wraps) return lon >= bounds.west || lon <= bounds.east;
|
||||
return lon >= bounds.west && lon <= bounds.east;
|
||||
}
|
||||
|
||||
/** Cell-rectangle/bounds intersection test in degrees, anti-meridian aware. */
|
||||
|
||||
function cellIntersectsBounds(cell, gridDegrees, bounds) {
|
||||
if (
|
||||
cell.latCell + gridDegrees < bounds.south ||
|
||||
cell.latCell > bounds.north
|
||||
)
|
||||
return false;
|
||||
const west = cell.lonCell;
|
||||
const east = cell.lonCell + gridDegrees;
|
||||
if (bounds.wraps) return east >= bounds.west || west <= bounds.east;
|
||||
return east >= bounds.west && west <= bounds.east;
|
||||
}
|
||||
|
||||
function heatScore(cell) {
|
||||
return (
|
||||
cell.intensity + cell.count * 0.8 + cell.night * 0.6 + cell.maxFrp * 0.12
|
||||
);
|
||||
}
|
||||
|
||||
function heatColor(value, alpha) {
|
||||
if (value > 0.72) return Cesium.Color.RED.withAlpha(alpha);
|
||||
if (value > 0.42) return Cesium.Color.ORANGE.withAlpha(alpha);
|
||||
return Cesium.Color.YELLOW.withAlpha(alpha);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a sprite color stop from FRP + confidence, reusing the same
|
||||
* yellow → orange → red thresholds as the aggregated heat cells.
|
||||
* @param {Object} fire - Detection record.
|
||||
* @returns {{name: string, color: Cesium.Color}}
|
||||
*/
|
||||
|
||||
function detectionColorStop(fire) {
|
||||
const heat = Math.min(
|
||||
1,
|
||||
Math.sqrt(Math.max(0, fire.frp) / 150) * 0.85 + fire.confidence * 0.15,
|
||||
);
|
||||
if (heat > 0.72) return DETECTION_COLOR_STOPS[0];
|
||||
if (heat > 0.42) return DETECTION_COLOR_STOPS[1];
|
||||
return DETECTION_COLOR_STOPS[2];
|
||||
}
|
||||
|
||||
/** FRP → core marker pixel size, clamped to 8..28px. */
|
||||
|
||||
function frpPixelSize(frp) {
|
||||
return Math.max(
|
||||
8,
|
||||
Math.min(28, Math.round(8 + Math.sqrt(Math.max(0, frp)) * 2)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Quantize a core size to a 2px bucket so the sprite cache stays tiny. */
|
||||
|
||||
function sizeBucket(coreSize) {
|
||||
return Math.max(8, Math.min(28, Math.round(coreSize / 2) * 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build (and cache) a radial-glow sprite: hot near-white center fading
|
||||
* through the stop color to a transparent edge. The glow lives in the
|
||||
* sprite itself because global bloom defaults OFF.
|
||||
* @param {{name: string, color: Cesium.Color}} stop - Color stop.
|
||||
* @param {number} corePx - Bucketed core size in pixels.
|
||||
* @returns {string} PNG data URL.
|
||||
*/
|
||||
|
||||
function glowSprite(stop, corePx) {
|
||||
const key = `${stop.name}:${corePx}`;
|
||||
const cached = layerState.glowSpriteCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const dimension = corePx * 2;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = dimension;
|
||||
canvas.height = dimension;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return '';
|
||||
const radius = dimension / 2;
|
||||
const rgb = [
|
||||
Math.round(stop.color.red * 255),
|
||||
Math.round(stop.color.green * 255),
|
||||
Math.round(stop.color.blue * 255),
|
||||
].join(',');
|
||||
const gradient = context.createRadialGradient(
|
||||
radius,
|
||||
radius,
|
||||
0,
|
||||
radius,
|
||||
radius,
|
||||
radius,
|
||||
);
|
||||
gradient.addColorStop(0, 'rgba(255,255,235,0.95)');
|
||||
gradient.addColorStop(0.25, `rgba(${rgb},0.9)`);
|
||||
gradient.addColorStop(0.55, `rgba(${rgb},0.35)`);
|
||||
gradient.addColorStop(1, `rgba(${rgb},0)`);
|
||||
context.fillStyle = gradient;
|
||||
context.fillRect(0, 0, dimension, dimension);
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
layerState.glowSpriteCache.set(key, dataUrl);
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily-cached Cartesian3 anchor at the shared ground floor (DEM/mesh cell
|
||||
* + lift) when the floor is warm, else ellipsoid height 0 (owner field
|
||||
* finding 2026-07-21: height-0 anchors read as buried under high terrain at
|
||||
* close/oblique zoom). Warm-cache read only — floors are warmed in batch by
|
||||
* renderDetections for the close band; everything else (cell-band context
|
||||
* registrations, detectable objects) just rides whatever is already warm.
|
||||
* Fires are static and floor cells latch, so each detection re-allocates at
|
||||
* most once per floor state (cold 0 → warm DEM). Rendering stays depth-test
|
||||
* free either way (contacts are ALWAYS visible — never below the surface,
|
||||
* slightly above is fine).
|
||||
* @param {Object} fire - Detection record.
|
||||
* @returns {Cesium.Cartesian3}
|
||||
*/
|
||||
|
||||
function firePosition(fire) {
|
||||
const height = fireAnchorHeight(fire.lat, fire.lon);
|
||||
if (!fire.position || fire.positionHeight !== height) {
|
||||
fire.position = Cesium.Cartesian3.fromDegrees(fire.lon, fire.lat, height);
|
||||
fire.positionHeight = height;
|
||||
}
|
||||
return fire.position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Occlusion-test anchor for one detection. Returns the render position
|
||||
* outright for anchors comfortably above the ellipsoid (the common case) and
|
||||
* a lazily-cached lifted point otherwise — see CULL_LIFT_THRESHOLD_M. This
|
||||
* NEVER feeds rendering: the datum-correct anchor from {@link firePosition}
|
||||
* is what the sprite and the card are drawn at.
|
||||
* @param {Object} fire - Detection record.
|
||||
* @returns {Cesium.Cartesian3}
|
||||
*/
|
||||
|
||||
function fireCullPosition(fire) {
|
||||
const position = firePosition(fire);
|
||||
if (fire.positionHeight >= CULL_LIFT_THRESHOLD_M) return position;
|
||||
if (!fire.cullPosition) {
|
||||
fire.cullPosition = Cesium.Cartesian3.fromDegrees(
|
||||
fire.lon,
|
||||
fire.lat,
|
||||
CULL_LIFT_M,
|
||||
);
|
||||
}
|
||||
return fire.cullPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Occlusion-test anchor for an aggregated heat cell. Cell centers are built at
|
||||
* height 0 (exactly on the ellipsoid), which the occluder treats as a limb
|
||||
* boundary case, so they get the same lift as sub-ellipsoid fire anchors.
|
||||
* @param {number} lon - Cell center longitude in degrees.
|
||||
* @param {number} lat - Cell center latitude in degrees.
|
||||
* @returns {Cesium.Cartesian3}
|
||||
*/
|
||||
|
||||
function cellCullPosition(lon, lat) {
|
||||
return Cesium.Cartesian3.fromDegrees(lon, lat, CULL_LIFT_M);
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizon-cull a billboard collection against an ellipsoid occluder.
|
||||
*
|
||||
* The Cesium globe is hidden in this app (Google 3D Tiles render the planet),
|
||||
* so nothing writes far-side depth — and these sprites are additionally
|
||||
* always-on-top (`disableDepthTestDistance: INFINITY`, so a detection is never
|
||||
* swallowed by terrain it sits on). Without an explicit occluder pass, fires on
|
||||
* the opposite side of the Earth shine through the planet at tilted mid-altitude
|
||||
* views. Same pattern as the flights/CCTV layers' `EllipsoidalOccluder` passes.
|
||||
*
|
||||
* Pure and allocation-free: reads `billboard.position`, writes `billboard.show`
|
||||
* only when it actually flips (assigning `show` dirties the collection's vertex
|
||||
* buffer). Accepts any `{length, get(i)}` shape so it is unit-testable without
|
||||
* a WebGL scene.
|
||||
*
|
||||
* @param {?{length: number, get: function(number): (Object|undefined)}} billboards
|
||||
* Billboard collection (or collection-shaped stub).
|
||||
* @param {?{isPointVisible: function(Object): boolean}} occluder Horizon occluder.
|
||||
* @param {?Array<Cesium.Cartesian3>} [cullPositions=null] Index-aligned lifted
|
||||
* occlusion-test anchors (see {@link fireCullPosition}); falls back to the
|
||||
* billboard's own render position wherever an entry is absent.
|
||||
* @returns {number} Count of billboards left visible (0 when nothing to test).
|
||||
*/
|
||||
|
||||
function applyHorizonCull(billboards, occluder, cullPositions = null) {
|
||||
if (!billboards || typeof billboards.get !== 'function') return 0;
|
||||
if (typeof occluder?.isPointVisible !== 'function') return 0;
|
||||
const total = Number(billboards.length) || 0;
|
||||
let visibleCount = 0;
|
||||
for (let i = 0; i < total; i += 1) {
|
||||
const billboard = billboards.get(i);
|
||||
if (!billboard) continue;
|
||||
const point = (cullPositions && cullPositions[i]) || billboard.position;
|
||||
const visible = occluder.isPointVisible(point) === true;
|
||||
if (billboard.show !== visible) billboard.show = visible;
|
||||
if (visible) visibleCount += 1;
|
||||
}
|
||||
return visibleCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `screen` is at least LABEL_MIN_SEP_PX away from every accepted
|
||||
* screen position (greedy declutter accept test).
|
||||
* @param {Array<{x: number, y: number}>} accepted - Accepted label positions.
|
||||
* @param {Cesium.Cartesian2} screen - Candidate window coordinates.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
function screenSeparated(accepted, screen) {
|
||||
const minSq = LABEL_MIN_SEP_PX * LABEL_MIN_SEP_PX;
|
||||
for (let i = 0; i < accepted.length; i += 1) {
|
||||
const dx = screen.x - accepted[i].x;
|
||||
const dy = screen.y - accepted[i].y;
|
||||
if (dx * dx + dy * dy < minSq) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card model for a click-selected fire — the full-detail card, drawn last
|
||||
* (on top) and never distance-faded by the overlay.
|
||||
* Exported for unit tests.
|
||||
* @param {Object} fire - Detection record.
|
||||
* @param {number} nowMs - Current epoch milliseconds.
|
||||
* @returns {Object} firmsLabels entry.
|
||||
*/
|
||||
|
||||
function buildSelectedFireCard(fire, nowMs) {
|
||||
const meta = [`${confidenceBucket(fire.confidence)} conf`];
|
||||
if (fire.acqMs > 0) {
|
||||
const age = formatAge(nowMs - fire.acqMs);
|
||||
if (age) meta.push(`${age} ago`);
|
||||
}
|
||||
const sat = satelliteShortName(fire.satellite);
|
||||
meta.push(
|
||||
sat ? `${fire.sensor || 'VIIRS'} ${sat}` : fire.sensor || 'sensor n/a',
|
||||
);
|
||||
return {
|
||||
id: `selected-fire:${fireDetectionKey(fire)}`,
|
||||
actionable: true,
|
||||
position: firePosition(fire),
|
||||
// Host-side horizon test uses this instead of the render anchor.
|
||||
cullPosition: fireCullPosition(fire),
|
||||
gapPx: frpPixelSize(fire.frp),
|
||||
accent: accentForSeverity(detectionColorStop(fire).name),
|
||||
title: `FIRE · ${formatFrp(fire.frp)} MW`,
|
||||
details: [
|
||||
meta.join(' · '),
|
||||
formatLatLon(fire.lat, fire.lon) + (fire.night ? ' · NIGHT' : ''),
|
||||
],
|
||||
selected: true,
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Card model for an ambient fire detection, e.g. title "▲ 47 MW", detail
|
||||
* "high · 14h · N20". Ages are computed against each detection's acquisition
|
||||
* time — with the live feed everything reads under 24 h (and a stale cache
|
||||
* reads truthfully old). Exported for unit tests.
|
||||
* @param {{fire: Object, position: Cesium.Cartesian3}} candidate - Label candidate.
|
||||
* @param {number} nowMs - Current epoch milliseconds.
|
||||
* @returns {Object} firmsLabels entry.
|
||||
*/
|
||||
|
||||
function buildFireCard(candidate, nowMs) {
|
||||
const fire = candidate.fire;
|
||||
const meta = [confidenceBucket(fire.confidence)];
|
||||
if (fire.acqMs > 0) {
|
||||
const age = formatAge(nowMs - fire.acqMs);
|
||||
if (age) meta.push(age);
|
||||
}
|
||||
const sat = satelliteShortName(fire.satellite) || fire.sensor;
|
||||
if (sat) meta.push(sat);
|
||||
return {
|
||||
id: `fire:${fireDetectionKey(fire)}`,
|
||||
actionable: true,
|
||||
position: candidate.position,
|
||||
cullPosition: candidate.cullPosition || candidate.position,
|
||||
gapPx: frpPixelSize(fire.frp),
|
||||
accent: accentForSeverity(detectionColorStop(fire).name),
|
||||
title: `▲ ${formatFrp(fire.frp)} MW`,
|
||||
details: [meta.join(' · ')],
|
||||
selected: false,
|
||||
priority: Number(fire.frp) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Card model for an aggregated heat cell, e.g. title "14 FIRES", detail
|
||||
* "max 210 MW · new 3h". Accent comes from the candidate (heat-normalized
|
||||
* score is only known at renderCells time). Exported for unit tests.
|
||||
* @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate
|
||||
* @param {number} nowMs - Current epoch milliseconds.
|
||||
* @returns {Object} firmsLabels entry.
|
||||
*/
|
||||
|
||||
function buildCellCard(candidate, nowMs) {
|
||||
const cell = candidate.cell;
|
||||
const noun = cell.count === 1 ? 'FIRE' : 'FIRES';
|
||||
const parts = [`max ${formatFrp(cell.maxFrp)} MW`];
|
||||
if (cell.newestAcqMs > 0) {
|
||||
const age = formatAge(nowMs - cell.newestAcqMs);
|
||||
if (age) parts.push(`new ${age}`);
|
||||
}
|
||||
return {
|
||||
id: `cell:${cell.latCell ?? 'x'}:${cell.lonCell ?? 'x'}`,
|
||||
position: candidate.position,
|
||||
cullPosition: candidate.cullPosition || candidate.position,
|
||||
gapPx: 10,
|
||||
accent: candidate.accent || accentForSeverity('yellow'),
|
||||
title: `${cell.count} ${noun}`,
|
||||
details: [parts.join(' · ')],
|
||||
selected: false,
|
||||
priority: Number(cell.maxFrp) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the host-owned layout/fade/collision fields to a source-formatted FIRMS
|
||||
* card. Selected fires share the collision domain so their protected rect
|
||||
* excludes ambient cards, but bypass both the 18-card cohort and distance fade.
|
||||
* @param {Object} card Source-formatted card.
|
||||
* @param {number} fadeDistance Current LOD fade distance in metres.
|
||||
* @returns {Object}
|
||||
*/
|
||||
|
||||
function applyFirmsOverlayPolicy(card, fadeDistance) {
|
||||
const selected = card?.selected === true;
|
||||
const rawGap = Number(card?.gapPx) || 10;
|
||||
const gapPx = Math.max(12, rawGap + 8);
|
||||
return {
|
||||
...card,
|
||||
variant: selected ? 'selected' : 'card',
|
||||
protected: selected,
|
||||
collisionGroup: 'ambient-card',
|
||||
cardStyle: 'tactical',
|
||||
gapPx,
|
||||
leaderOffsetPx: Math.max(2, gapPx - 6),
|
||||
verticalOnly: true,
|
||||
viewportMargin: 4,
|
||||
maxDistance: selected ? Number.POSITIVE_INFINITY : fadeDistance,
|
||||
distanceFadeStartRatio: 0.7,
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
// Aggregate cells omit actionable because they do not identify one fire.
|
||||
interactive: card?.actionable === true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Heat-normalized cell score → severity accent, mirroring heatColor's
|
||||
* red/orange/yellow thresholds.
|
||||
* @param {number} normalized - 0..1 normalized heat score.
|
||||
* @returns {string} "r, g, b" accent string.
|
||||
*/
|
||||
|
||||
function cellAccent(normalized) {
|
||||
if (normalized > 0.72) return accentForSeverity('red');
|
||||
if (normalized > 0.42) return accentForSeverity('orange');
|
||||
return accentForSeverity('yellow');
|
||||
}
|
||||
|
||||
/** Coordinate line like "30.512°N 75.831°E". */
|
||||
|
||||
function formatLatLon(lat, lon) {
|
||||
const latPart = `${Math.abs(lat).toFixed(3)}°${lat >= 0 ? 'N' : 'S'}`;
|
||||
const lonPart = `${Math.abs(lon).toFixed(3)}°${lon >= 0 ? 'E' : 'W'}`;
|
||||
return `${latPart} ${lonPart}`;
|
||||
}
|
||||
|
||||
function formatFrp(frp) {
|
||||
return frp >= 10 ? frp.toFixed(0) : frp.toFixed(1);
|
||||
}
|
||||
|
||||
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
|
||||
|
||||
function formatAge(deltaMs) {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return '';
|
||||
const hours = deltaMs / 3600000;
|
||||
if (hours < 1) return '<1h';
|
||||
if (hours < 48) return `${Math.round(hours)}h`;
|
||||
return `${Math.round(hours / 24)}d`;
|
||||
}
|
||||
|
||||
/** Millisecond delta → "<1m ago" / "Xm ago" / "Xh ago" (fresh-feed readout). */
|
||||
|
||||
function formatAgoMinutes(deltaMs) {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return 'just now';
|
||||
const minutes = Math.floor(deltaMs / 60000);
|
||||
if (minutes < 1) return '<1m ago';
|
||||
if (minutes < 90) return `${minutes}m ago`;
|
||||
return `${Math.round(minutes / 60)}h ago`;
|
||||
}
|
||||
|
||||
/** Normalized 0..1 confidence → low/nominal/high display bucket. */
|
||||
|
||||
function confidenceBucket(confidence) {
|
||||
if (confidence >= 0.75) return 'high';
|
||||
if (confidence >= 0.45) return 'nominal';
|
||||
return 'low';
|
||||
}
|
||||
return {
|
||||
mapAnalystRecord,
|
||||
paddedDegreeBounds,
|
||||
boundsContainPoint,
|
||||
cellIntersectsBounds,
|
||||
heatScore,
|
||||
heatColor,
|
||||
detectionColorStop,
|
||||
frpPixelSize,
|
||||
sizeBucket,
|
||||
glowSprite,
|
||||
firePosition,
|
||||
fireCullPosition,
|
||||
cellCullPosition,
|
||||
applyHorizonCull,
|
||||
screenSeparated,
|
||||
buildSelectedFireCard,
|
||||
buildFireCard,
|
||||
buildCellCard,
|
||||
applyFirmsOverlayPolicy,
|
||||
cellAccent,
|
||||
formatLatLon,
|
||||
formatFrp,
|
||||
formatAge,
|
||||
formatAgoMinutes,
|
||||
confidenceBucket,
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -0,0 +1,174 @@
|
||||
import { fireDetectionKey } from '../../data/firmsLabels.js';
|
||||
import { REFRESH_INTERVAL_MS } from './policy.js';
|
||||
|
||||
export function createQueries({
|
||||
layerState,
|
||||
services,
|
||||
components,
|
||||
config,
|
||||
feed,
|
||||
}) {
|
||||
const { id, name, icon, source } = config;
|
||||
|
||||
const methods = {
|
||||
/** Return the selected detection as a plain record for application consumers. */
|
||||
getSelectedInfo() {
|
||||
const fire = layerState._selectedFire;
|
||||
if (!fire) return null;
|
||||
return {
|
||||
id: fireDetectionKey(fire),
|
||||
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
|
||||
latitude: fire.lat,
|
||||
longitude: fire.lon,
|
||||
frp: fire.frp,
|
||||
confidencePct: Number.isFinite(fire.confidence)
|
||||
? fire.confidence * 100
|
||||
: null,
|
||||
observedAt:
|
||||
Number.isFinite(fire.acqMs) && fire.acqMs > 0 ? fire.acqMs : null,
|
||||
source: fire.sensor || fire.satellite || null,
|
||||
};
|
||||
},
|
||||
|
||||
id,
|
||||
|
||||
name,
|
||||
|
||||
icon,
|
||||
|
||||
source,
|
||||
|
||||
// Live layer: the manager calls update() every 10 minutes while enabled,
|
||||
// which refetches through the /api/firms proxy (the proxy's 30 min TTL —
|
||||
// not this interval — is what protects the upstream FIRMS quota).
|
||||
updateInterval: REFRESH_INTERVAL_MS,
|
||||
|
||||
/**
|
||||
* Layer stats for the data panel. Degraded feed states surface through
|
||||
* `error` (established qa-failstate pattern: a dead feed must never look
|
||||
* like a healthy empty layer) with a matching human `loadingLabel`:
|
||||
* 'LIVE · updated Xm ago' fresh, 'STALE · cached Xh' when the proxy
|
||||
* served past-TTL cache, 'KEY REQUIRED' keyless.
|
||||
*/
|
||||
getStats() {
|
||||
const now = Date.now();
|
||||
const staleText = layerState._lastUpdate
|
||||
? `STALE · cached ${components.model.formatAge(now - layerState._lastUpdate) || '<1h'}`
|
||||
: 'STALE';
|
||||
let loadingLabel = '';
|
||||
if (layerState._loading) {
|
||||
loadingLabel = layerState._fires.length
|
||||
? 'refreshing...'
|
||||
: 'loading...';
|
||||
} else if (layerState._keyRequired) {
|
||||
loadingLabel = 'KEY REQUIRED';
|
||||
} else if (layerState._stale) {
|
||||
loadingLabel = staleText;
|
||||
} else if (layerState._error) {
|
||||
loadingLabel = layerState._error;
|
||||
} else if (layerState._lastUpdate) {
|
||||
loadingLabel = `LIVE · updated ${components.model.formatAgoMinutes(now - layerState._lastUpdate)}`;
|
||||
}
|
||||
return {
|
||||
count: layerState._count,
|
||||
cells: layerState._cellCount,
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
loading: layerState._loading,
|
||||
stale: layerState._stale,
|
||||
error: layerState._keyRequired
|
||||
? 'KEY REQUIRED'
|
||||
: layerState._stale
|
||||
? staleText
|
||||
: layerState._error,
|
||||
loadingLabel,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Strongest currently-loaded detection (by FRP) for voice targeting
|
||||
* ("take me to the biggest fire").
|
||||
* @returns {{latitude: number, longitude: number, frp: number, label: string}|null}
|
||||
*/
|
||||
getStrongestFire() {
|
||||
const strongest = layerState._firesByFrp.length
|
||||
? layerState._firesByFrp[0]
|
||||
: null;
|
||||
if (!strongest) return null;
|
||||
return {
|
||||
latitude: strongest.lat,
|
||||
longitude: strongest.lon,
|
||||
frp: strongest.frp,
|
||||
label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Detection-overlay seam, shaped like the other layers' detectable
|
||||
* objects (traffic/flights). NOT yet registered in initDetection's layer
|
||||
* list — wiring fires through the detection/label-arbiter pipeline is a
|
||||
* deliberate post-PR#1 task; when that happens the arbiter replaces this
|
||||
* layer's greedy declutter as the SELECTOR and the shared overlay remains
|
||||
* the renderer. Walks the FRP-sorted index so the strongest fires come first.
|
||||
* @param {{maxCount?: number}} [options]
|
||||
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string}>}
|
||||
*/
|
||||
getDetectableObjects(options = {}) {
|
||||
if (!layerState._enabled || !layerState._firesByFrp.length) return [];
|
||||
const maxCount = Number.isFinite(options.maxCount)
|
||||
? Math.max(1, Math.floor(options.maxCount))
|
||||
: 250;
|
||||
const result = [];
|
||||
for (const fire of layerState._firesByFrp) {
|
||||
result.push({
|
||||
position: components.model.firePosition(fire),
|
||||
id: `FIRE-${String(fire.index).padStart(5, '0')}`,
|
||||
type: 'FIRE',
|
||||
});
|
||||
if (result.length >= maxCount) break;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Snapshot the layer's in-memory fire records as plain JSON-safe
|
||||
* objects for the analyst query engine. Walks the FRP-sorted index so
|
||||
* truncation keeps the STRONGEST fires (200k+ detections can be live —
|
||||
* the cap is load-bearing, not cosmetic). On-demand only (called at
|
||||
* most once per spoken query) — zero per-frame cost, no listeners, no
|
||||
* caching. Returns [] while the layer is disabled or empty.
|
||||
* @param {number} [maxCount=2000] - Maximum records to return (truncation).
|
||||
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
|
||||
*/
|
||||
getAnalystRecords(maxCount = 2000) {
|
||||
if (!layerState._enabled || !layerState._firesByFrp.length) return [];
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
: 2000;
|
||||
const result = [];
|
||||
for (const fire of layerState._firesByFrp) {
|
||||
result.push(components.model.mapAnalystRecord(fire));
|
||||
if (result.length >= limit) break;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/** Test seam that binds the production click path and indexes. */
|
||||
_bindInteractionForTest(viewer, fires = []) {
|
||||
layerState._viewer = viewer;
|
||||
layerState._enabled = true;
|
||||
layerState._fires = fires;
|
||||
layerState._firesByFrp = [...fires];
|
||||
layerState._pickIndexById.clear();
|
||||
layerState._labelLodDistance = 1e7;
|
||||
layerState._labelCandidates = fires.map((fire) => {
|
||||
layerState._pickIndexById.set(`firms-${fire.index}`, fire);
|
||||
return { fire, position: components.model.firePosition(fire) };
|
||||
});
|
||||
components.cards.rebuildAmbientLabels();
|
||||
components.selection.installClickHandler();
|
||||
return layerState._clickHandler;
|
||||
},
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js';
|
||||
|
||||
export function createRendering({
|
||||
layerState,
|
||||
services,
|
||||
components,
|
||||
config,
|
||||
feed,
|
||||
}) {
|
||||
const { warmFireAnchorFloors } = services.anchors;
|
||||
const { registerSpriteCollection, restoreSpriteOrder } = services.sprites;
|
||||
const { overlayHost } = config;
|
||||
|
||||
/**
|
||||
* Rebuild the render for the current LOD band and viewport. Skips work
|
||||
* when neither the (hysteresis-damped) LOD band nor the padded view
|
||||
* rectangle changed meaningfully.
|
||||
* @param {boolean} force - Rebuild even if LOD/view appear unchanged.
|
||||
* @returns {boolean} True when a rebuild actually ran. Callers use this to
|
||||
* avoid a second horizon walk in the same tick: a rebuild already culls
|
||||
* its freshly-added sprites, an early-out does not.
|
||||
*/
|
||||
|
||||
function renderCurrentLod(force = false) {
|
||||
if (
|
||||
!layerState._dataSource ||
|
||||
!layerState._fires.length ||
|
||||
!layerState._viewer
|
||||
)
|
||||
return false;
|
||||
const lodIndex = components.viewport.selectLodIndex(
|
||||
components.viewport.cameraHeight(),
|
||||
);
|
||||
const lod = LOD_LEVELS[lodIndex];
|
||||
const viewRect = components.viewport.computeViewRect();
|
||||
if (
|
||||
!force &&
|
||||
lodIndex === layerState._currentLodIndex &&
|
||||
!components.viewport.viewChangedEnough(viewRect)
|
||||
)
|
||||
return false;
|
||||
layerState._currentLodIndex = lodIndex;
|
||||
layerState._currentLodId = lod.id;
|
||||
layerState._lastViewRect = viewRect
|
||||
? Cesium.Rectangle.clone(viewRect)
|
||||
: null;
|
||||
const bounds = viewRect
|
||||
? components.model.paddedDegreeBounds(viewRect)
|
||||
: null;
|
||||
|
||||
if (lod.mode === 'detections') {
|
||||
renderDetections(lod, bounds);
|
||||
} else {
|
||||
renderCells(aggregateFires(lod, bounds), lod, bounds);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bin all detections into grid cells, then viewport-clip BEFORE the
|
||||
* top-N cap so fires in view never lose their slot to higher-scoring
|
||||
* cells on other continents. `bounds === null` (sky/horizon or
|
||||
* near-global view) falls back to the global top-N behavior.
|
||||
* @param {Object} lod - Active LOD descriptor.
|
||||
* @param {?Object} bounds - Padded view bounds in degrees, or null.
|
||||
* @returns {Array<Object>} Cells sorted by heat score, capped.
|
||||
*/
|
||||
|
||||
function aggregateFires(lod, bounds) {
|
||||
const gridDegrees = lod.gridDegrees;
|
||||
|
||||
let sorted = layerState._cellCacheByGrid.get(gridDegrees);
|
||||
if (!sorted) {
|
||||
const cells = new Map();
|
||||
for (const fire of layerState._fires) {
|
||||
const latCell = Math.floor(fire.lat / gridDegrees) * gridDegrees;
|
||||
const lonCell = Math.floor(fire.lon / gridDegrees) * gridDegrees;
|
||||
const key = `${latCell.toFixed(3)}:${lonCell.toFixed(3)}`;
|
||||
// confidence is normalized 0..1 — weight ×4 preserves the old 0..100×0.04 scale.
|
||||
const intensity = Math.max(
|
||||
1,
|
||||
fire.frp * 0.18 + fire.confidence * 4 + fire.brightness * 0.01,
|
||||
);
|
||||
const existing = cells.get(key) || {
|
||||
latCell,
|
||||
lonCell,
|
||||
count: 0,
|
||||
intensity: 0,
|
||||
maxFrp: 0,
|
||||
night: 0,
|
||||
newestAcqMs: 0,
|
||||
};
|
||||
existing.count += 1;
|
||||
existing.intensity += intensity;
|
||||
existing.maxFrp = Math.max(existing.maxFrp, fire.frp);
|
||||
existing.newestAcqMs = Math.max(existing.newestAcqMs, fire.acqMs);
|
||||
if (fire.night) existing.night += 1;
|
||||
cells.set(key, existing);
|
||||
}
|
||||
// Heat-sorted once; per-render clipping below preserves the order.
|
||||
sorted = [...cells.values()].sort(
|
||||
(a, b) => components.model.heatScore(b) - components.model.heatScore(a),
|
||||
);
|
||||
layerState._cellCacheByGrid.set(gridDegrees, sorted);
|
||||
}
|
||||
|
||||
if (!bounds) return sorted.slice(0, lod.maxCells);
|
||||
const clipped = [];
|
||||
for (const cell of sorted) {
|
||||
if (!components.model.cellIntersectsBounds(cell, gridDegrees, bounds))
|
||||
continue;
|
||||
clipped.push(cell);
|
||||
if (clipped.length >= lod.maxCells) break;
|
||||
}
|
||||
return clipped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render aggregated heat cells (global/regional bands) as ground-clamped
|
||||
* rectangles. Cell labels do NOT live on the entities anymore — they go
|
||||
* through the same unclamped, screen-space-decluttered label pipeline as
|
||||
* detections (see {@link rebuildAmbientLabels}).
|
||||
* @param {Array<Object>} cells - Aggregated cells, heat-sorted descending.
|
||||
* @param {Object} lod - Active LOD descriptor.
|
||||
* @param {?Object} bounds - Padded view bounds in degrees, or null.
|
||||
*/
|
||||
|
||||
function renderCells(cells, lod, bounds) {
|
||||
ensureDetectionCollections();
|
||||
layerState._dataSource.entities.removeAll();
|
||||
if (layerState._billboards) layerState._billboards.removeAll();
|
||||
layerState._pickIndexById.clear();
|
||||
layerState._cullPositions.length = 0;
|
||||
layerState._cellCount = cells.length;
|
||||
const maxScore = Math.max(1, ...cells.map(components.model.heatScore));
|
||||
|
||||
layerState._labelCandidates = [];
|
||||
layerState._labelLodDistance = lod.labelDistance;
|
||||
|
||||
for (const cell of cells) {
|
||||
const score = components.model.heatScore(cell);
|
||||
const normalized = Math.min(1, Math.sqrt(score / maxScore));
|
||||
const alpha = 0.16 + normalized * 0.5;
|
||||
const color = components.model.heatColor(normalized, alpha);
|
||||
const centerLon = cell.lonCell + lod.gridDegrees / 2;
|
||||
const centerLat = cell.latCell + lod.gridDegrees / 2;
|
||||
const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0);
|
||||
|
||||
layerState._dataSource.entities.add({
|
||||
rectangle: {
|
||||
coordinates: Cesium.Rectangle.fromDegrees(
|
||||
cell.lonCell,
|
||||
cell.latCell,
|
||||
cell.lonCell + lod.gridDegrees,
|
||||
cell.latCell + lod.gridDegrees,
|
||||
),
|
||||
material: new Cesium.ColorMaterialProperty(color),
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
},
|
||||
position,
|
||||
properties: {
|
||||
count: cell.count,
|
||||
intensity: score,
|
||||
maxFrp: cell.maxFrp,
|
||||
night: cell.night,
|
||||
},
|
||||
});
|
||||
|
||||
// Cells arrive heat-sorted, so candidate order doubles as label priority.
|
||||
// Accent is resolved here because `normalized` only exists in this walk.
|
||||
layerState._labelCandidates.push({
|
||||
position,
|
||||
cullPosition: components.model.cellCullPosition(centerLon, centerLat),
|
||||
fire: null,
|
||||
cell,
|
||||
accent: components.model.cellAccent(normalized),
|
||||
});
|
||||
}
|
||||
|
||||
components.cards.rebuildAmbientLabels();
|
||||
components.selection.refreshContextRegistrations(
|
||||
components.selection.topFiresWithinBounds(bounds),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render individual detections (local/close bands) as pre-baked
|
||||
* radial-glow sprite billboards sized by FRP, capped per viewport by
|
||||
* highest FRP, depth test disabled. The 3D Tiles mesh is never sampled
|
||||
* per point; instead, the CLOSE band (where terrain parallax is actually
|
||||
* visible) batch-warms the shared cached DEM floor for its rendered
|
||||
* subset and re-renders once floors land, so anchors sit on the terrain
|
||||
* (firePosition). The local band keeps cold anchors at height 0 — at
|
||||
* ≥750 km camera height the divergence is invisible and warming a
|
||||
* continent-wide viewport would waste the DEM proxy.
|
||||
* Labels are not built here; candidates feed {@link rebuildAmbientLabels}.
|
||||
* @param {Object} lod - Active LOD descriptor.
|
||||
* @param {?Object} bounds - Padded view bounds in degrees, or null.
|
||||
*/
|
||||
|
||||
function renderDetections(lod, bounds) {
|
||||
ensureDetectionCollections();
|
||||
layerState._dataSource.entities.removeAll();
|
||||
if (!layerState._billboards) return;
|
||||
layerState._billboards.removeAll();
|
||||
layerState._pickIndexById.clear();
|
||||
layerState._cullPositions.length = 0;
|
||||
|
||||
let candidates;
|
||||
if (bounds) {
|
||||
// Walk the pre-sorted FRP index and early-exit at the cap — the old
|
||||
// collect-then-sort touched and sorted every in-view detection per
|
||||
// rebuild (tens of thousands mid-zoom; field-test round 1 chug).
|
||||
candidates = [];
|
||||
for (const fire of layerState._firesByFrp) {
|
||||
if (!components.model.boundsContainPoint(bounds, fire.lat, fire.lon))
|
||||
continue;
|
||||
candidates.push(fire);
|
||||
if (candidates.length >= lod.maxDetections) break;
|
||||
}
|
||||
} else {
|
||||
// Sky/horizon view: fall back to the globally strongest detections.
|
||||
candidates = layerState._firesByFrp.slice(0, lod.maxDetections);
|
||||
}
|
||||
|
||||
layerState._cellCount = candidates.length;
|
||||
layerState._labelCandidates = [];
|
||||
layerState._labelLodDistance = lod.labelDistance;
|
||||
|
||||
for (const fire of candidates) {
|
||||
const coreSize = components.model.frpPixelSize(fire.frp);
|
||||
const position = components.model.firePosition(fire);
|
||||
const cullPosition = components.model.fireCullPosition(fire);
|
||||
const pickId = `firms-${fire.index}`;
|
||||
layerState._pickIndexById.set(pickId, fire);
|
||||
layerState._cullPositions.push(cullPosition);
|
||||
layerState._billboards.add({
|
||||
id: pickId,
|
||||
position,
|
||||
image: components.model.glowSprite(
|
||||
components.model.detectionColorStop(fire),
|
||||
components.model.sizeBucket(coreSize),
|
||||
),
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
// FRP-sorted walk: candidate order doubles as label priority.
|
||||
layerState._labelCandidates.push({
|
||||
position,
|
||||
cullPosition,
|
||||
fire,
|
||||
cell: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Freshly-added sprites default to show=true; cull the far side now so a
|
||||
// rebuild never flashes through-the-planet detections while the camera
|
||||
// sits still (the moveEnd/preRender hooks only fire on camera motion).
|
||||
refreshHorizonCulling();
|
||||
components.cards.rebuildAmbientLabels();
|
||||
components.selection.refreshContextRegistrations(
|
||||
candidates.slice(0, CONTEXT_TOP_N),
|
||||
);
|
||||
|
||||
if (lod.id === 'close') {
|
||||
// Batched (chunked ≤200, sequential, session-cached) DEM warm for the
|
||||
// rendered subset; fires are static so each coarse cell resolves once
|
||||
// ever. Re-render ONLY when a floor actually landed — a failed resolve
|
||||
// reports false, so this chain terminates instead of looping against a
|
||||
// down proxy (the next camera-driven rebuild retries).
|
||||
warmFireAnchorFloors(candidates).then((warmed) => {
|
||||
if (!warmed || !layerState._enabled || !layerState._viewer) return;
|
||||
const currentLod = LOD_LEVELS[layerState._currentLodIndex];
|
||||
if (!currentLod || currentLod.mode !== 'detections') return;
|
||||
renderCurrentLod(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDetectionCollections() {
|
||||
if (!layerState._viewer || layerState._billboards) return;
|
||||
layerState._billboards = new Cesium.BillboardCollection({
|
||||
scene: layerState._viewer.scene,
|
||||
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
||||
});
|
||||
layerState._billboards.show = layerState._enabled;
|
||||
layerState._viewer.scene.primitives.add(layerState._billboards);
|
||||
registerSpriteCollection('firms', layerState._billboards);
|
||||
restoreSpriteOrder(layerState._viewer);
|
||||
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, layerState._enabled);
|
||||
}
|
||||
|
||||
function removeDetectionCollections(viewer) {
|
||||
const scene = viewer?.scene || layerState._viewer?.scene;
|
||||
if (scene && !scene.isDestroyed?.()) {
|
||||
try {
|
||||
if (layerState._billboards)
|
||||
scene.primitives.remove(layerState._billboards);
|
||||
} catch {
|
||||
/* already torn down */
|
||||
}
|
||||
}
|
||||
layerState._billboards = null;
|
||||
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared horizon occluder positioned at the current camera, or null when
|
||||
* there is no usable camera yet (init order / headless stubs).
|
||||
* `viewer.camera` and `viewer.scene.camera` are the SAME object in Cesium;
|
||||
* both are probed so a partial stub (either shape) still gets a real
|
||||
* occluder rather than silently degrading to "nothing is ever occluded".
|
||||
* @returns {?Cesium.EllipsoidalOccluder}
|
||||
*/
|
||||
|
||||
function fireHorizonOccluder() {
|
||||
const camera = layerState._viewer?.camera?.positionWC
|
||||
? layerState._viewer.camera
|
||||
: layerState._viewer?.scene?.camera;
|
||||
if (!camera?.positionWC) return null;
|
||||
return horizonOccluder(camera);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide detection sprites that sit beyond the ellipsoid horizon (see
|
||||
* {@link applyHorizonCull}). Runs on the cadences that already exist in this
|
||||
* layer — the throttled LOD preRender watcher (camera-moved only), camera
|
||||
* moveEnd, and each detection rebuild — never as a new per-frame pass.
|
||||
* No-ops in the `cells` bands, where the collection is empty.
|
||||
*/
|
||||
|
||||
function refreshHorizonCulling() {
|
||||
if (!layerState._billboards || !layerState._billboards.length) return;
|
||||
components.model.applyHorizonCull(
|
||||
layerState._billboards,
|
||||
fireHorizonOccluder(),
|
||||
layerState._cullPositions,
|
||||
);
|
||||
}
|
||||
return {
|
||||
renderCurrentLod,
|
||||
aggregateFires,
|
||||
renderCells,
|
||||
renderDetections,
|
||||
ensureDetectionCollections,
|
||||
removeDetectionCollections,
|
||||
fireHorizonOccluder,
|
||||
refreshHorizonCulling,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import {
|
||||
FIRMS_OVERLAY_SOURCE_ID,
|
||||
fireDetectionKey,
|
||||
} from '../../data/firmsLabels.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { CONTEXT_TOP_N } from './policy.js';
|
||||
|
||||
export function createSelection({
|
||||
layerState,
|
||||
services,
|
||||
components,
|
||||
config,
|
||||
feed,
|
||||
}) {
|
||||
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
|
||||
const { requestWorldFocus } = services.focus;
|
||||
const {
|
||||
selectEntityContext,
|
||||
clearSelectedEntityContextForLayer,
|
||||
getContextStore,
|
||||
registerEntityContext,
|
||||
} = services.context;
|
||||
const { id, name, overlayHost, screenSpaceEventHandlerFactory } = config;
|
||||
|
||||
/**
|
||||
* Install the LEFT_CLICK handler for fire selection (enable-time only,
|
||||
* mirroring the flight layers). Clicking a fire sprite selects it and
|
||||
* surfaces it in the shared context store; clicking empty space clears
|
||||
* the selection. Picks owned by sibling layers are left alone.
|
||||
*/
|
||||
|
||||
function installClickHandler() {
|
||||
if (layerState._clickHandler || !layerState._viewer) return;
|
||||
layerState._clickHandler = screenSpaceEventHandlerFactory(
|
||||
layerState._viewer,
|
||||
);
|
||||
layerState._clickHandler.setInputAction((click) => {
|
||||
const picked = layerState._viewer.scene.pick(click.position);
|
||||
const fire = pickedFire(picked);
|
||||
if (fire) {
|
||||
selectAndFocusFire(fire);
|
||||
return;
|
||||
}
|
||||
// A pick that belongs to a sibling layer (e.g. an aircraft) is not
|
||||
// "empty space" — leave the selection alone and let that layer handle it.
|
||||
if (picked) {
|
||||
const pickedId = resolvePickId(picked);
|
||||
if (pickedId && isOwnedByOtherLayer(id, pickedId)) return;
|
||||
}
|
||||
const cardHit = overlayHost.hitTest?.(
|
||||
click.position?.x,
|
||||
click.position?.y,
|
||||
{
|
||||
sourceId: FIRMS_OVERLAY_SOURCE_ID,
|
||||
},
|
||||
);
|
||||
if (cardHit) {
|
||||
const carded = layerState._fireByCardId.get(cardHit.entryId);
|
||||
if (carded) selectAndFocusFire(carded);
|
||||
return;
|
||||
}
|
||||
clearFireSelection();
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
|
||||
function removeClickHandler() {
|
||||
if (layerState._clickHandler) {
|
||||
layerState._clickHandler.destroy();
|
||||
layerState._clickHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Select one stable detection and request one UI-owned camera transfer. */
|
||||
|
||||
function selectAndFocusFire(fire) {
|
||||
selectFire(fire);
|
||||
requestWorldFocus({
|
||||
kind: 'fire',
|
||||
id: fireDetectionKey(fire),
|
||||
label: 'FIRE',
|
||||
position: components.model.firePosition(fire),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a scene pick to one of this layer's fire records, or null.
|
||||
* BillboardCollection picks surface our id either on picked.primitive.id
|
||||
* or directly on picked.id depending on the CesiumJS pick path.
|
||||
* @param {*} picked - Result of scene.pick().
|
||||
* @returns {?Object} Fire record.
|
||||
*/
|
||||
|
||||
function pickedFire(picked) {
|
||||
if (!picked) return null;
|
||||
const primitiveId = picked.primitive?.id;
|
||||
if (
|
||||
typeof primitiveId === 'string' &&
|
||||
layerState._pickIndexById.has(primitiveId)
|
||||
) {
|
||||
return layerState._pickIndexById.get(primitiveId);
|
||||
}
|
||||
if (
|
||||
typeof picked.id === 'string' &&
|
||||
layerState._pickIndexById.has(picked.id)
|
||||
) {
|
||||
return layerState._pickIndexById.get(picked.id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a fire: show its detail label and mark it selected in the shared
|
||||
* context store so voice "what's selected" resolves to it.
|
||||
* @param {Object} fire - Detection record.
|
||||
*/
|
||||
|
||||
function selectFire(fire, publishSelection = true) {
|
||||
layerState._selectedFire = fire;
|
||||
try {
|
||||
registerFireContext(fire);
|
||||
layerState._contextIds.add(fireDetectionKey(fire));
|
||||
if (publishSelection) selectEntityContext(fire.contextEntity);
|
||||
} catch {
|
||||
// context store unavailable — the selection label still works
|
||||
}
|
||||
components.cards.rebuildAmbientLabels();
|
||||
}
|
||||
|
||||
function clearFireSelection() {
|
||||
if (!layerState._selectedFire) return;
|
||||
layerState._selectedFire = null;
|
||||
clearSelectedEntityContextForLayer(id);
|
||||
components.cards.rebuildAmbientLabels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the top detections of the current render set in the shared
|
||||
* context store (voice "what's burning here") and drop stale entries
|
||||
* from the previous rebuild.
|
||||
* @param {Array<Object>} fires - FRP-ranked detections to register.
|
||||
*/
|
||||
|
||||
function refreshContextRegistrations(fires) {
|
||||
let store = null;
|
||||
try {
|
||||
store = getContextStore();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIds = new Set();
|
||||
for (const fire of fires) {
|
||||
nextIds.add(registerFireContext(fire));
|
||||
}
|
||||
// The click-selected fire must stay queryable ("what's selected") even
|
||||
// when it falls outside the top-N context slice.
|
||||
if (layerState._selectedFire) {
|
||||
nextIds.add(registerFireContext(layerState._selectedFire));
|
||||
}
|
||||
|
||||
for (const staleId of layerState._contextIds) {
|
||||
if (!nextIds.has(staleId)) store.entities.delete(staleId);
|
||||
}
|
||||
layerState._contextIds = nextIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one fire in the shared context store (idempotent) and return
|
||||
* its record id.
|
||||
* @param {Object} fire - Detection record.
|
||||
* @returns {string} Context record id.
|
||||
*/
|
||||
|
||||
function registerFireContext(fire) {
|
||||
const recordId = fireDetectionKey(fire);
|
||||
if (!fire.contextEntity) {
|
||||
fire.contextEntity = {
|
||||
show: true,
|
||||
};
|
||||
}
|
||||
// Refreshed every registration: the anchor re-grounds onto the DEM when
|
||||
// its floor cell warms, and voice targeting must project the same point
|
||||
// the sprite renders at.
|
||||
fire.contextEntity.__localBaseCartesian =
|
||||
components.model.firePosition(fire);
|
||||
registerEntityContext(fire.contextEntity, {
|
||||
id: recordId,
|
||||
layerId: id,
|
||||
layerName: name,
|
||||
source: 'NASA FIRMS',
|
||||
dataSource: layerState._dataSource,
|
||||
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
|
||||
latitude: fire.lat,
|
||||
longitude: fire.lon,
|
||||
properties: {
|
||||
frp: fire.frp,
|
||||
confidence: components.model.confidenceBucket(fire.confidence),
|
||||
age:
|
||||
fire.acqMs > 0
|
||||
? components.model.formatAge(Date.now() - fire.acqMs)
|
||||
: 'unknown',
|
||||
sensor: fire.sensor || 'unknown',
|
||||
},
|
||||
});
|
||||
return recordId;
|
||||
}
|
||||
|
||||
function clearContextRegistrations() {
|
||||
if (!layerState._contextIds.size) return;
|
||||
try {
|
||||
const store = getContextStore();
|
||||
for (const recordId of layerState._contextIds)
|
||||
store.entities.delete(recordId);
|
||||
} catch {
|
||||
// store unavailable — nothing to clean
|
||||
}
|
||||
layerState._contextIds = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-FRP detections within the given bounds (or globally when bounds is
|
||||
* null). Walks the pre-sorted FRP index so it stays a single cheap pass.
|
||||
* @param {?Object} bounds - Padded view bounds in degrees, or null.
|
||||
* @param {number} limit - Max detections returned.
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
|
||||
function topFiresWithinBounds(bounds, limit = CONTEXT_TOP_N) {
|
||||
if (!bounds) return layerState._firesByFrp.slice(0, limit);
|
||||
const top = [];
|
||||
for (const fire of layerState._firesByFrp) {
|
||||
if (!components.model.boundsContainPoint(bounds, fire.lat, fire.lon))
|
||||
continue;
|
||||
top.push(fire);
|
||||
if (top.length >= limit) break;
|
||||
}
|
||||
return top;
|
||||
}
|
||||
return {
|
||||
installClickHandler,
|
||||
removeClickHandler,
|
||||
selectAndFocusFire,
|
||||
pickedFire,
|
||||
selectFire,
|
||||
clearFireSelection,
|
||||
refreshContextRegistrations,
|
||||
registerFireContext,
|
||||
clearContextRegistrations,
|
||||
topFiresWithinBounds,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
export function createFirmsState({ services, config }) {
|
||||
const layerState = {};
|
||||
|
||||
/** Pre-baked radial-glow sprites keyed by `<colorStop>:<sizeBucket>`. */
|
||||
|
||||
layerState.glowSpriteCache = new Map();
|
||||
|
||||
layerState.scratchViewRect = new Cesium.Rectangle();
|
||||
|
||||
layerState.scratchCenterA = new Cesium.Cartographic();
|
||||
|
||||
layerState.scratchCenterB = new Cesium.Cartographic();
|
||||
|
||||
layerState.scratchWindowCoord = new Cesium.Cartesian2();
|
||||
|
||||
layerState._viewer = null;
|
||||
|
||||
layerState._dataSource = null;
|
||||
|
||||
layerState._billboards = null;
|
||||
|
||||
layerState._enabled = false;
|
||||
|
||||
layerState._destroyed = false;
|
||||
|
||||
layerState._loading = false;
|
||||
|
||||
/** True when the proxy answered 503 {error:'no_key'} — FIRMS_MAP_KEY unset. */
|
||||
|
||||
layerState._keyRequired = false;
|
||||
|
||||
/** True when the proxy served a cached payload past TTL (upstream failing). */
|
||||
|
||||
layerState._stale = false;
|
||||
|
||||
/** Surfaced error string when the live fetch failed outright. */
|
||||
|
||||
layerState._error = null;
|
||||
|
||||
layerState._fires = [];
|
||||
|
||||
layerState._firesByFrp = [];
|
||||
|
||||
layerState._count = 0;
|
||||
|
||||
layerState._cellCount = 0;
|
||||
|
||||
layerState._lastUpdate = null;
|
||||
|
||||
layerState._currentLodId = null;
|
||||
|
||||
layerState._currentLodIndex = -1;
|
||||
|
||||
layerState._lastViewRect = null;
|
||||
|
||||
layerState._preRenderRemover = null;
|
||||
|
||||
layerState._moveEndSettleRemover = null;
|
||||
|
||||
layerState._lastLodCheck = 0;
|
||||
|
||||
layerState._contextIds = new Set();
|
||||
|
||||
layerState._clickHandler = null;
|
||||
|
||||
layerState._moveEndRemover = null;
|
||||
|
||||
layerState._selectedFire = null;
|
||||
|
||||
/** Priority-ordered label candidates from the last rebuild (detections or cells). */
|
||||
|
||||
layerState._labelCandidates = [];
|
||||
|
||||
layerState._labelLodDistance = 0;
|
||||
|
||||
/** pick id string -> fire record, for the currently rendered sprites. */
|
||||
|
||||
layerState._pickIndexById = new Map();
|
||||
|
||||
/** Actionable card id -> current detection record painted for that id. */
|
||||
|
||||
layerState._fireByCardId = new Map();
|
||||
|
||||
/**
|
||||
* Occlusion-test anchors index-aligned with the billboard collection (the
|
||||
* collection is cleared and refilled in one ordered pass per rebuild, and
|
||||
* nothing else adds to it). Lifted where the render anchor sits at/below
|
||||
* the ellipsoid — see {@link fireCullPosition}.
|
||||
* @type {Array<Cesium.Cartesian3>}
|
||||
*/
|
||||
|
||||
layerState._cullPositions = [];
|
||||
|
||||
/**
|
||||
* gridDegrees -> heat-sorted full cell list. The full-grid aggregation is
|
||||
* viewport-independent but walks every detection (~200k live), so it is
|
||||
* computed once per grid size per data refresh; renders only clip + cap
|
||||
* (field-test round 1: intermediate-zoom chug during LOD rebuilds).
|
||||
* @type {Map<number, Array<Object>>}
|
||||
*/
|
||||
|
||||
layerState._cellCacheByGrid = new Map();
|
||||
|
||||
/** Camera idle snapshot so the throttled LOD check is ~free when parked. */
|
||||
|
||||
layerState._camSnapValid = false;
|
||||
|
||||
layerState._camPos = new Cesium.Cartesian3();
|
||||
|
||||
layerState._camDir = new Cesium.Cartesian3();
|
||||
layerState.request = null;
|
||||
return layerState;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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'))
|
||||
|
||||
Reference in new issue
Block a user