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

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

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
import { fireDetectionKey } from '../../data/firmsLabels.js';
import { REFRESH_INTERVAL_MS } from './policy.js';
export function createQueries({
layerState,
services,
components,
config,
feed,
}) {
const { id, name, icon, source } = config;
const methods = {
/** Return the selected detection as a plain record for application consumers. */
getSelectedInfo() {
const fire = layerState._selectedFire;
if (!fire) return null;
return {
id: fireDetectionKey(fire),
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
latitude: fire.lat,
longitude: fire.lon,
frp: fire.frp,
confidencePct: Number.isFinite(fire.confidence)
? fire.confidence * 100
: null,
observedAt:
Number.isFinite(fire.acqMs) && fire.acqMs > 0 ? fire.acqMs : null,
source: fire.sensor || fire.satellite || null,
};
},
id,
name,
icon,
source,
// Live layer: the manager calls update() every 10 minutes while enabled,
// which refetches through the /api/firms proxy (the proxy's 30 min TTL —
// not this interval — is what protects the upstream FIRMS quota).
updateInterval: REFRESH_INTERVAL_MS,
/**
* Layer stats for the data panel. Degraded feed states surface through
* `error` (established qa-failstate pattern: a dead feed must never look
* like a healthy empty layer) with a matching human `loadingLabel`:
* 'LIVE · updated Xm ago' fresh, 'STALE · cached Xh' when the proxy
* served past-TTL cache, 'KEY REQUIRED' keyless.
*/
getStats() {
const now = Date.now();
const staleText = layerState._lastUpdate
? `STALE · cached ${components.model.formatAge(now - layerState._lastUpdate) || '<1h'}`
: 'STALE';
let loadingLabel = '';
if (layerState._loading) {
loadingLabel = layerState._fires.length
? 'refreshing...'
: 'loading...';
} else if (layerState._keyRequired) {
loadingLabel = 'KEY REQUIRED';
} else if (layerState._stale) {
loadingLabel = staleText;
} else if (layerState._error) {
loadingLabel = layerState._error;
} else if (layerState._lastUpdate) {
loadingLabel = `LIVE · updated ${components.model.formatAgoMinutes(now - layerState._lastUpdate)}`;
}
return {
count: layerState._count,
cells: layerState._cellCount,
lastUpdate: layerState._lastUpdate,
loading: layerState._loading,
stale: layerState._stale,
error: layerState._keyRequired
? 'KEY REQUIRED'
: layerState._stale
? staleText
: layerState._error,
loadingLabel,
};
},
/**
* Strongest currently-loaded detection (by FRP) for voice targeting
* ("take me to the biggest fire").
* @returns {{latitude: number, longitude: number, frp: number, label: string}|null}
*/
getStrongestFire() {
const strongest = layerState._firesByFrp.length
? layerState._firesByFrp[0]
: null;
if (!strongest) return null;
return {
latitude: strongest.lat,
longitude: strongest.lon,
frp: strongest.frp,
label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`,
};
},
/**
* Detection-overlay seam, shaped like the other layers' detectable
* objects (traffic/flights). NOT yet registered in initDetection's layer
* list — wiring fires through the detection/label-arbiter pipeline is a
* deliberate post-PR#1 task; when that happens the arbiter replaces this
* layer's greedy declutter as the SELECTOR and the shared overlay remains
* the renderer. Walks the FRP-sorted index so the strongest fires come first.
* @param {{maxCount?: number}} [options]
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string}>}
*/
getDetectableObjects(options = {}) {
if (!layerState._enabled || !layerState._firesByFrp.length) return [];
const maxCount = Number.isFinite(options.maxCount)
? Math.max(1, Math.floor(options.maxCount))
: 250;
const result = [];
for (const fire of layerState._firesByFrp) {
result.push({
position: components.model.firePosition(fire),
id: `FIRE-${String(fire.index).padStart(5, '0')}`,
type: 'FIRE',
});
if (result.length >= maxCount) break;
}
return result;
},
/**
* Snapshot the layer's in-memory fire records as plain JSON-safe
* objects for the analyst query engine. Walks the FRP-sorted index so
* truncation keeps the STRONGEST fires (200k+ detections can be live —
* the cap is load-bearing, not cosmetic). On-demand only (called at
* most once per spoken query) — zero per-frame cost, no listeners, no
* caching. Returns [] while the layer is disabled or empty.
* @param {number} [maxCount=2000] - Maximum records to return (truncation).
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
*/
getAnalystRecords(maxCount = 2000) {
if (!layerState._enabled || !layerState._firesByFrp.length) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const result = [];
for (const fire of layerState._firesByFrp) {
result.push(components.model.mapAnalystRecord(fire));
if (result.length >= limit) break;
}
return result;
},
/** Test seam that binds the production click path and indexes. */
_bindInteractionForTest(viewer, fires = []) {
layerState._viewer = viewer;
layerState._enabled = true;
layerState._fires = fires;
layerState._firesByFrp = [...fires];
layerState._pickIndexById.clear();
layerState._labelLodDistance = 1e7;
layerState._labelCandidates = fires.map((fire) => {
layerState._pickIndexById.set(`firms-${fire.index}`, fire);
return { fire, position: components.model.firePosition(fire) };
});
components.cards.rebuildAmbientLabels();
components.selection.installClickHandler();
return layerState._clickHandler;
},
};
return { methods };
}