Files
gods-eye-view/src/layers/firms/queries.js
T
German KhokhlovandCodex fd78bc3884 fix(zh-CN): localize readouts and keep short labels intact
Preserve Chinese unit casing and spacing in the HUD and cockpit, localize FIRMS cache ages, CCTV health and radio counts, and keep imagery resolution and route modes readable. Add regression coverage for localized output and unchanged English.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-21 13:36:38 +04:00

197 lines
7.1 KiB
JavaScript

import { fireDetectionKey } from '../../data/firmsLabels.js';
import { t } from '../../i18n/index.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: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(fire.frp),
}),
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,
// The one data-layer control a provider key gates: the proxy answers
// 503 {error:'no_key'} without a FIRMS key. Declared as a key-registry id
// rather than an env-var string, so the panel can name the key from the
// one place that owns what each key is called.
requiresKeyId: 'firms',
// 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
? t('STALE · cached {age}', {
age:
components.model.formatAgeLabel(now - layerState._lastUpdate) ||
t('<1h', { context: 'age' }),
})
: t('STALE');
let loadingLabel = '';
if (layerState._loading) {
loadingLabel = layerState._fires.length
? t('refreshing...')
: t('loading...');
} else if (layerState._keyRequired) {
loadingLabel = t('KEY REQUIRED');
} else if (layerState._stale) {
loadingLabel = staleText;
} else if (layerState._error) {
loadingLabel = layerState._error;
} else if (layerState._lastUpdate) {
loadingLabel = t('LIVE · updated {age}', {
age: components.model.formatAgoMinutes(now - layerState._lastUpdate),
});
}
return {
count: layerState._count,
cells: layerState._cellCount,
lastUpdate: layerState._lastUpdate,
loading: layerState._loading,
stale: layerState._stale,
// The machine-readable half of the keyless state, ahead of the human
// strings below: without it "no key configured" is indistinguishable
// from a broken feed, and the row reads as a fault instead of a step
// the operator can take.
keyRequired: layerState._keyRequired,
error: layerState._keyRequired
? t('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: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(strongest.frp),
}),
};
},
/**
* 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 };
}