mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(fires): FRP intensity filter, confidence switch and six-stop color ramp (HAL-676)
Color every FIRMS detection by fire radiative power on the Portolan cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · 50 · 100 · 300 MW), replacing the three warm stops that made a 2 MW and a 40 MW fire look alike. Heat cells, glow sprites and card accents all read the same ramp (src/data/firmsIntensity.js). Add a minimum-FRP log slider whose track is painted with the ramp (so it doubles as the legend), an All / Nominal+ / High confidence switch and an "N of M shown" counter to the Active Fires row. The filter runs once in ingestion.applyFilter(), so markers, cards, heat cells, the context store, the top-fires query and the analyst records all see one set. Default is 0 MW / All: many of the strongest fires are rated low. Persist both options in the layer state (token w: minFrp `f`, confidence `c`), including the short share codec, and give the agent's get_active_fires tool matching min_frp_mw / confidence arguments. Layer panel gains generic `slider` and `segmented` row descriptors (src/ui/rowFilterControls.js). New strings in es / ru / zh-CN. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
15de9d738c
commit
38d29f2beb
27 files changed
+1500
-70
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { adaptFirmsRecords } from '../../data/firmsAdapt.js';
|
||||
import { fireDetectionKey } from '../../data/firmsLabels.js';
|
||||
import { filterFires } from '../../data/firmsIntensity.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
|
||||
export function createIngestion({
|
||||
@@ -40,12 +41,13 @@ export function createIngestion({
|
||||
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(
|
||||
layerState._allFires = adaptFirmsRecords(payload?.fires);
|
||||
// Sorted once per payload; applyFilter only filters, so a slider drag
|
||||
// over 200k detections never re-sorts.
|
||||
layerState._allFiresByFrp = [...layerState._allFires].sort(
|
||||
(a, b) => b.frp - a.frp,
|
||||
);
|
||||
layerState._count = layerState._fires.length;
|
||||
applyFilter();
|
||||
// Data age, not response age: a stale proxy payload truthfully reads old.
|
||||
layerState._lastUpdate = Number.isFinite(payload?.fetchedAt)
|
||||
? payload.fetchedAt
|
||||
@@ -65,6 +67,7 @@ export function createIngestion({
|
||||
}
|
||||
components.rendering.renderCurrentLod(true);
|
||||
if (reselected) components.selection.selectFire(reselected, false);
|
||||
layerState._rowControlsListener?.();
|
||||
} catch (error) {
|
||||
if (
|
||||
request.signal.aborted ||
|
||||
@@ -83,6 +86,25 @@ export function createIngestion({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the intensity filter over the loaded payload. This is the ONE place
|
||||
* the shown set is derived: `_fires` / `_firesByFrp` feed markers, cards,
|
||||
* heat cells, the context store, the top-fires query and the analyst
|
||||
* records, so none of them can disagree about which fires exist. Cells are
|
||||
* aggregated per shown set, so the cache goes with it.
|
||||
*/
|
||||
|
||||
function applyFilter() {
|
||||
layerState._fires = filterFires(layerState._allFires, layerState._filter);
|
||||
layerState._cellCacheByGrid.clear();
|
||||
layerState._firesByFrp = filterFires(
|
||||
layerState._allFiresByFrp,
|
||||
layerState._filter,
|
||||
);
|
||||
layerState._count = layerState._fires.length;
|
||||
layerState._totalCount = layerState._allFires.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the record in the freshly-loaded set matching a previous selection.
|
||||
* Identity is the detection itself (lat/lon/acquisition time) — indices
|
||||
@@ -109,5 +131,5 @@ export function createIngestion({
|
||||
},
|
||||
};
|
||||
|
||||
return { loadHeatmap, findMatchingFire, methods };
|
||||
return { loadHeatmap, applyFilter, findMatchingFire, methods };
|
||||
}
|
||||
@@ -91,10 +91,14 @@ export function createLifecycle({
|
||||
components.rendering.removeDetectionCollections(viewer);
|
||||
components.selection.clearContextRegistrations();
|
||||
clearSelectedEntityContextForLayer(id);
|
||||
layerState._allFires = [];
|
||||
layerState._allFiresByFrp = [];
|
||||
layerState._fires = [];
|
||||
layerState._firesByFrp = [];
|
||||
layerState._cellCacheByGrid.clear();
|
||||
layerState._count = 0;
|
||||
layerState._totalCount = 0;
|
||||
layerState._rowControlsListener = null;
|
||||
layerState._cellCount = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._keyRequired = false;
|
||||
|
||||
+36
-21
@@ -6,6 +6,7 @@ import {
|
||||
accentForSeverity,
|
||||
formatFrpMw,
|
||||
} from '../../data/firmsLabels.js';
|
||||
import { FRP_RAMP, frpRampStop } from '../../data/firmsIntensity.js';
|
||||
import {
|
||||
VIEW_PADDING,
|
||||
DETECTION_COLOR_STOPS,
|
||||
@@ -119,27 +120,42 @@ export function createModel({
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
/**
|
||||
* The ramp stop for a fire radiative power (stepped, coldest first).
|
||||
* Intensity alone drives the color: confidence feeds the card text and the
|
||||
* switch, brightness is unreliable (saturates at 367 K) — see HAL-675.
|
||||
* @param {number} frp - Megawatts.
|
||||
* @returns {{name: string, minFrp: number, color: Cesium.Color}}
|
||||
*/
|
||||
|
||||
function frpColorStop(frp) {
|
||||
const name = frpRampStop(frp).name;
|
||||
return (
|
||||
DETECTION_COLOR_STOPS.find((stop) => stop.name === name) ||
|
||||
DETECTION_COLOR_STOPS[0]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a sprite color stop from FRP + confidence, reusing the same
|
||||
* yellow → orange → red thresholds as the aggregated heat cells.
|
||||
* Fill color for an aggregated heat cell: the ramp color of its strongest
|
||||
* detection, so cells, markers and the slider legend read on one scale.
|
||||
* @param {number} maxFrp - Strongest FRP in the cell, megawatts.
|
||||
* @param {number} alpha - Fill alpha (heat-normalized by the caller).
|
||||
* @returns {Cesium.Color}
|
||||
*/
|
||||
|
||||
function heatColor(maxFrp, alpha) {
|
||||
return frpColorStop(maxFrp).color.withAlpha(alpha);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a sprite color stop for one detection from its FRP on the shared ramp.
|
||||
* @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];
|
||||
return frpColorStop(fire?.frp);
|
||||
}
|
||||
|
||||
/** FRP → core marker pixel size, clamped to 8..28px. */
|
||||
@@ -418,7 +434,7 @@ export function createModel({
|
||||
position: candidate.position,
|
||||
cullPosition: candidate.cullPosition || candidate.position,
|
||||
gapPx: 10,
|
||||
accent: candidate.accent || accentForSeverity('yellow'),
|
||||
accent: candidate.accent || accentForSeverity(FRP_RAMP[0].name),
|
||||
title: `${cell.count} ${noun}`,
|
||||
details: [parts.join(' · ')],
|
||||
selected: false,
|
||||
@@ -460,16 +476,14 @@ export function createModel({
|
||||
}
|
||||
|
||||
/**
|
||||
* Heat-normalized cell score → severity accent, mirroring heatColor's
|
||||
* red/orange/yellow thresholds.
|
||||
* @param {number} normalized - 0..1 normalized heat score.
|
||||
* Cell card accent: the ramp stop of the cell's strongest detection,
|
||||
* mirroring heatColor.
|
||||
* @param {number} maxFrp - Strongest FRP in the cell, megawatts.
|
||||
* @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');
|
||||
function cellAccent(maxFrp) {
|
||||
return accentForSeverity(frpColorStop(maxFrp).name);
|
||||
}
|
||||
|
||||
/** Coordinate line like "30.512°N 75.831°E". */
|
||||
@@ -576,6 +590,7 @@ export function createModel({
|
||||
cellIntersectsBounds,
|
||||
heatScore,
|
||||
heatColor,
|
||||
frpColorStop,
|
||||
detectionColorStop,
|
||||
frpPixelSize,
|
||||
sizeBucket,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FIRMS_AMBIENT_COHORT_LIMIT } from '../../data/firmsLabels.js';
|
||||
import { FRP_RAMP } from '../../data/firmsIntensity.js';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/** Client poll interval; the proxy's 30 min TTL is what guards upstream quota. */
|
||||
@@ -88,10 +89,16 @@ export const CULL_LIFT_THRESHOLD_M = 10;
|
||||
|
||||
export const CULL_LIFT_M = 12;
|
||||
|
||||
/** Color stops shared by cell heat fills and detection glow sprites. */
|
||||
/**
|
||||
* Color stops shared by cell heat fills and detection glow sprites: the
|
||||
* six-stop cold → hot FRP ramp (firmsIntensity.js), coldest first, as Cesium
|
||||
* colors. Stop names key the glow-sprite cache and the card accents.
|
||||
*/
|
||||
|
||||
export const DETECTION_COLOR_STOPS = [
|
||||
{ name: 'red', color: Cesium.Color.RED },
|
||||
{ name: 'orange', color: Cesium.Color.ORANGE },
|
||||
{ name: 'yellow', color: Cesium.Color.YELLOW },
|
||||
];
|
||||
export const DETECTION_COLOR_STOPS = FRP_RAMP.map((stop) =>
|
||||
Object.freeze({
|
||||
name: stop.name,
|
||||
minFrp: stop.minFrp,
|
||||
color: Cesium.Color.fromCssColorString(stop.hex),
|
||||
}),
|
||||
);
|
||||
+141
-1
@@ -1,7 +1,19 @@
|
||||
import { fireDetectionKey } from '../../data/firmsLabels.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
FIRMS_CONFIDENCE_MODES,
|
||||
FRP_SLIDER_TICKS,
|
||||
firePassesFilter,
|
||||
frpForSliderPosition,
|
||||
normalizeFireFilter,
|
||||
rampTrackGradient,
|
||||
sliderPositionForFrp,
|
||||
} from '../../data/firmsIntensity.js';
|
||||
import { formatNumber, t } from '../../i18n/index.js';
|
||||
import { REFRESH_INTERVAL_MS } from './policy.js';
|
||||
|
||||
/** Slider resolution: whole-percent positions along the log axis. */
|
||||
const SLIDER_STEPS = 1000;
|
||||
|
||||
export function createQueries({
|
||||
layerState,
|
||||
services,
|
||||
@@ -11,6 +23,25 @@ export function createQueries({
|
||||
}) {
|
||||
const { id, name, icon, source } = config;
|
||||
|
||||
/** Whole-megawatt label for a slider value or tick ("0", "20", "1 000"). */
|
||||
function frpLabel(frp) {
|
||||
return formatNumber(frp, { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
const confidenceLabels = () => ({
|
||||
all: t('All', { context: 'fire confidence filter' }),
|
||||
nominal: t('Nominal+', { context: 'fire confidence filter' }),
|
||||
high: t('High', { context: 'fire confidence filter' }),
|
||||
});
|
||||
|
||||
const confidenceTitles = () => ({
|
||||
all: t(
|
||||
'Every detection — many of the strongest fires are rated low confidence',
|
||||
),
|
||||
nominal: t('Nominal and high confidence detections'),
|
||||
high: t('High confidence detections only'),
|
||||
});
|
||||
|
||||
const methods = {
|
||||
/** Return the selected detection as a plain record for application consumers. */
|
||||
getSelectedInfo() {
|
||||
@@ -51,6 +82,109 @@ export function createQueries({
|
||||
// not this interval — is what protects the upstream FIRMS quota).
|
||||
updateInterval: REFRESH_INTERVAL_MS,
|
||||
|
||||
/**
|
||||
* The intensity filter as layer options (share token `w`, owner
|
||||
* `local-firms` in layerState.js).
|
||||
* @returns {{minFrp: number, confidence: string}}
|
||||
*/
|
||||
getParams() {
|
||||
return { ...layerState._filter };
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply a (partial) intensity filter. Invalid fields keep their current
|
||||
* value. The filter runs in one place (ingestion.applyFilter) and the LOD
|
||||
* rebuild re-derives markers, cells, cards and context registrations from
|
||||
* the shown set; a selected fire that no longer passes is deselected.
|
||||
* @param {{minFrp?: number, confidence?: string}} params
|
||||
* @returns {boolean} True — the request is always accepted.
|
||||
*/
|
||||
setParams(params = {}) {
|
||||
const next = normalizeFireFilter(params, layerState._filter);
|
||||
if (
|
||||
next.minFrp === layerState._filter.minFrp &&
|
||||
next.confidence === layerState._filter.confidence
|
||||
)
|
||||
return true;
|
||||
layerState._filter = next;
|
||||
if (!layerState._allFires.length) return true;
|
||||
components.ingestion.applyFilter();
|
||||
if (
|
||||
layerState._selectedFire &&
|
||||
!firePassesFilter(layerState._selectedFire, next)
|
||||
) {
|
||||
components.selection.clearFireSelection();
|
||||
}
|
||||
if (layerState._enabled) components.rendering.renderCurrentLod(true);
|
||||
layerState._rowControlsListener?.();
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Row controls under the Active Fires row: the log-scale minimum-FRP
|
||||
* slider whose track is painted with the intensity ramp (so it doubles as
|
||||
* the legend), the All / Nominal+ / High confidence switch, and the
|
||||
* "N of M shown" counter.
|
||||
*/
|
||||
getRowControls() {
|
||||
const { minFrp, confidence } = layerState._filter;
|
||||
const labels = confidenceLabels();
|
||||
const titles = confidenceTitles();
|
||||
const shown = layerState._count;
|
||||
const total = layerState._totalCount;
|
||||
return {
|
||||
slider: {
|
||||
id: 'min-frp',
|
||||
label: t('Min FRP'),
|
||||
ariaLabel: t('Minimum fire radiative power, megawatts'),
|
||||
valueText:
|
||||
minFrp > 0
|
||||
? t('{value} MW', { value: frpLabel(minFrp) })
|
||||
: t('All', { context: 'fire intensity filter' }),
|
||||
min: 0,
|
||||
max: SLIDER_STEPS,
|
||||
step: 1,
|
||||
value: Math.round(sliderPositionForFrp(minFrp) * SLIDER_STEPS),
|
||||
track: rampTrackGradient(),
|
||||
ticks: FRP_SLIDER_TICKS.map((frp) => ({
|
||||
at: sliderPositionForFrp(frp),
|
||||
label: frpLabel(frp),
|
||||
})),
|
||||
params: (value) => ({
|
||||
minFrp: frpForSliderPosition(Number(value) / SLIDER_STEPS),
|
||||
}),
|
||||
},
|
||||
segmented: {
|
||||
id: 'confidence',
|
||||
ariaLabel: t('Detection confidence'),
|
||||
options: FIRMS_CONFIDENCE_MODES.map((mode) => ({
|
||||
id: `confidence-${mode}`,
|
||||
label: labels[mode],
|
||||
title: titles[mode],
|
||||
active: mode === confidence,
|
||||
params: { confidence: mode },
|
||||
})),
|
||||
},
|
||||
info: total
|
||||
? t('{shown} of {total} shown', {
|
||||
shown: formatNumber(shown),
|
||||
total: formatNumber(total),
|
||||
})
|
||||
: '',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Install the panel's "row controls changed" callback: a load or a filter
|
||||
* change moves the counter, and nothing else repaints the row before the
|
||||
* next scheduled refresh.
|
||||
* @param {(() => void)|null} listener
|
||||
*/
|
||||
setRowControlsListener(listener) {
|
||||
layerState._rowControlsListener =
|
||||
typeof listener === 'function' ? listener : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Layer stats for the data panel. Degraded feed states surface through
|
||||
* `error` (established qa-failstate pattern: a dead feed must never look
|
||||
@@ -84,7 +218,9 @@ export function createQueries({
|
||||
});
|
||||
}
|
||||
return {
|
||||
// Shown (filtered) detections; `total` is the loaded payload.
|
||||
count: layerState._count,
|
||||
total: layerState._totalCount,
|
||||
cells: layerState._cellCount,
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
loading: layerState._loading,
|
||||
@@ -176,8 +312,12 @@ export function createQueries({
|
||||
_bindInteractionForTest(viewer, fires = []) {
|
||||
layerState._viewer = viewer;
|
||||
layerState._enabled = true;
|
||||
layerState._allFires = fires;
|
||||
layerState._allFiresByFrp = [...fires];
|
||||
layerState._fires = fires;
|
||||
layerState._firesByFrp = [...fires];
|
||||
layerState._count = fires.length;
|
||||
layerState._totalCount = fires.length;
|
||||
layerState._pickIndexById.clear();
|
||||
layerState._labelLodDistance = 1e7;
|
||||
layerState._labelCandidates = fires.map((fire) => {
|
||||
|
||||
@@ -144,7 +144,7 @@ export function createRendering({
|
||||
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 color = components.model.heatColor(cell.maxFrp, alpha);
|
||||
const centerLon = cell.lonCell + lod.gridDegrees / 2;
|
||||
const centerLat = cell.latCell + lod.gridDegrees / 2;
|
||||
const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0);
|
||||
@@ -170,13 +170,12 @@ export function createRendering({
|
||||
});
|
||||
|
||||
// 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),
|
||||
accent: components.model.cellAccent(cell.maxFrp),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { DEFAULT_FIRE_FILTER } from '../../data/firmsIntensity.js';
|
||||
|
||||
export function createFirmsState({ services, config }) {
|
||||
const layerState = {};
|
||||
@@ -39,12 +40,39 @@ export function createFirmsState({ services, config }) {
|
||||
|
||||
layerState._error = null;
|
||||
|
||||
/** Every adapted detection in the current payload, before the filter. */
|
||||
|
||||
layerState._allFires = [];
|
||||
|
||||
/** `_allFires` sorted by FRP descending, once per payload. */
|
||||
|
||||
layerState._allFiresByFrp = [];
|
||||
|
||||
/**
|
||||
* The detections that pass the intensity filter — the ONLY set rendering,
|
||||
* selection, cells, the top-fires query and the analyst records ever walk.
|
||||
*/
|
||||
|
||||
layerState._fires = [];
|
||||
|
||||
layerState._firesByFrp = [];
|
||||
|
||||
/** Shown (filtered) detection count. */
|
||||
|
||||
layerState._count = 0;
|
||||
|
||||
/** Loaded detection count before the filter ("N of M shown"). */
|
||||
|
||||
layerState._totalCount = 0;
|
||||
|
||||
/** Minimum FRP (MW) and confidence mode; see firmsIntensity.js. */
|
||||
|
||||
layerState._filter = { ...DEFAULT_FIRE_FILTER };
|
||||
|
||||
/** Panel callback: repaint the row controls (counter) after a load. */
|
||||
|
||||
layerState._rowControlsListener = null;
|
||||
|
||||
layerState._cellCount = 0;
|
||||
|
||||
layerState._lastUpdate = null;
|
||||
|
||||
Reference in new issue
Block a user