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:
German KhokhlovandClaude Fable 5.1 committed 2026-10-27 13:09:32 +04:00
1 parent 15de9d738c
commit 38d29f2beb
27 files changed
+1500 -70

No files matched your search

+27 -5
View File
@@ -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 };
}
+4
View File
@@ -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
View File
@@ -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,
+13 -6
View File
@@ -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
View File
@@ -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) => {
+2 -3
View File
@@ -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),
});
}
+28
View File
@@ -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;