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

+19 -3
View File
@@ -68,11 +68,27 @@ test('buildFireCard: power that rounds to 10 MW drops the decimal', () => {
assert.equal(selected.title, 'FIRE · 10 MW');
});
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => {
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is blue', () => {
const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW);
assert.match(card.details[0], /SNPP$/);
assert.equal(card.title, '▲ 0.8 MW');
assert.notEqual(card.accent, accentForSeverity('red'));
assert.equal(card.accent, accentForSeverity('blue'));
});
test('buildFireCard: accent steps through the FRP ramp at 5 · 20 · 50 · 100 · 300 MW', () => {
const accentAt = (frp) =>
buildFireCard({ fire: fire({ frp }), position: {} }, NOW).accent;
assert.equal(accentAt(4.9), accentForSeverity('blue'));
assert.equal(accentAt(5), accentForSeverity('teal'));
assert.equal(accentAt(20), accentForSeverity('lime'));
assert.equal(accentAt(50), accentForSeverity('amber'));
assert.equal(accentAt(100), accentForSeverity('orange'));
assert.equal(accentAt(300), accentForSeverity('red'));
// Confidence never moves the color: a low-confidence 864 MW fire stays red.
assert.equal(
buildFireCard({ fire: fire({ frp: 864, confidence: 0.3 }), position: {} }, NOW).accent,
accentForSeverity('red'),
);
});
test('buildSelectedFireCard: full detail card with coords, selected flag, no fade', () => {
@@ -125,7 +141,7 @@ test('buildCellCard: singular noun and missing-age omission', () => {
const card = buildCellCard(candidate, NOW);
assert.equal(card.title, '1 FIRE');
assert.equal(card.details[0], 'max 9.9 MW');
assert.equal(card.accent, accentForSeverity('yellow'), 'missing accent defaults to yellow');
assert.equal(card.accent, accentForSeverity('blue'), 'missing accent defaults to the coldest stop');
});
test('FIRMS host policy keeps ambient cards bounded and selected cards protected', () => {
+209
View File
@@ -0,0 +1,209 @@
/**
* @module firmsIntensity
* @description Fire intensity policy shared by the Active Fires layer and the
* agent fire tool: the six-stop cold → hot FRP color ramp, the log-scale
* slider that doubles as its legend, and the one filter every consumer
* (markers, cards, heat cells, the top-fires query, `get_active_fires`) runs
* before anything is rendered or selected. Pure — no Cesium, no DOM.
*
* Hue order follows the Portolan FIRMS explorer (`FRP_RAMP`, stepped, not
* interpolated). The thresholds are GEV's own, set on a real week's
* percentiles so every color appears: Portolan's 0/10/50/200/500/1000 MW
* leaves one red fire a week (HAL-675).
*/
/**
* Stepped FRP ramp, coldest first. A detection takes the LAST stop whose
* `minFrp` it reaches. Names key the glow-sprite cache and the card accents.
*/
export const FRP_RAMP = Object.freeze([
Object.freeze({ name: 'blue', minFrp: 0, hex: '#4a5bd4' }),
Object.freeze({ name: 'teal', minFrp: 5, hex: '#39a0a8' }),
Object.freeze({ name: 'lime', minFrp: 20, hex: '#c9cf4a' }),
Object.freeze({ name: 'amber', minFrp: 50, hex: '#f2b134' }),
Object.freeze({ name: 'orange', minFrp: 100, hex: '#e8722c' }),
Object.freeze({ name: 'red', minFrp: 300, hex: '#d1382a' }),
]);
/** Slider ticks: the ramp thresholds, so slider, legend and markers agree. */
export const FRP_SLIDER_TICKS = Object.freeze(
FRP_RAMP.map((stop) => stop.minFrp),
);
/** Minimum-FRP values the share codec accepts, in whole megawatts. */
export const MIN_FRP_MW_MIN = 0;
export const MIN_FRP_MW_MAX = 10_000;
/** Confidence switch positions; VIIRS reports only low / nominal / high. */
export const FIRMS_CONFIDENCE_MODES = Object.freeze(['all', 'nominal', 'high']);
/**
* Normalized-confidence floor for a switch position. The thresholds match
* `confidenceBucket` in the layer model (0.3 / 0.6 / 0.9 after firmsAdapt).
*/
const CONFIDENCE_FLOOR = Object.freeze({ all: 0, nominal: 0.45, high: 0.75 });
export const DEFAULT_FIRE_FILTER = Object.freeze({
minFrp: 0,
confidence: 'all',
});
/**
* The ramp stop for a fire radiative power.
* @param {number} frp Megawatts; non-finite or negative reads as 0.
* @returns {{name: string, minFrp: number, hex: string}}
*/
export function frpRampStop(frp) {
const value = Number.isFinite(frp) ? Math.max(0, frp) : 0;
let stop = FRP_RAMP[0];
for (const candidate of FRP_RAMP) {
if (value >= candidate.minFrp) stop = candidate;
else break;
}
return stop;
}
/** `#rrggbb` → `[r, g, b]` in 0..255. */
export function hexToRgb(hex) {
const text = String(hex).replace('#', '');
return [0, 2, 4].map((offset) =>
Number.parseInt(text.slice(offset, offset + 2), 16),
);
}
/**
* Log-scale slider axis: position 0 means "no minimum" (0 MW), and the rest
* of the track runs 1 MW → SLIDER_MAX_MW logarithmically. The axis floor is 1
* MW because the median detection is ~6 MW and a sub-megawatt floor hides
* nothing worth a tick.
*/
export const SLIDER_MAX_MW = 1000;
const LOG_MAX = Math.log10(SLIDER_MAX_MW);
/**
* Track position in 0..1 for a minimum FRP.
* @param {number} minFrp Megawatts.
* @returns {number}
*/
export function sliderPositionForFrp(minFrp) {
if (!Number.isFinite(minFrp) || minFrp < 1) return 0;
return Math.min(1, Math.log10(minFrp) / LOG_MAX);
}
/**
* Minimum FRP for a track position, rounded to whole megawatts so a slider
* value and its share-link form are the same number.
* @param {number} position 0..1.
* @returns {number}
*/
export function frpForSliderPosition(position) {
const p = Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0;
if (p <= 0) return 0;
const frp = Math.pow(10, p * LOG_MAX);
return Math.max(1, Math.round(frp));
}
/**
* CSS `linear-gradient` painting the stepped ramp along the log axis, so the
* slider track IS the legend: the color under the thumb is the color of the
* weakest fire still shown.
* @returns {string}
*/
export function rampTrackGradient() {
const parts = [];
FRP_RAMP.forEach((stop, index) => {
const start = sliderPositionForFrp(stop.minFrp) * 100;
const next = FRP_RAMP[index + 1];
const end = next ? sliderPositionForFrp(next.minFrp) * 100 : 100;
parts.push(`${stop.hex} ${start.toFixed(2)}% ${end.toFixed(2)}%`);
});
return `linear-gradient(90deg, ${parts.join(', ')})`;
}
/**
* Minimum FRP → whole megawatts inside the codec bounds, or null.
* @param {*} value
* @returns {?number}
*/
export function normalizeMinFrp(value) {
const number =
typeof value === 'number'
? value
: typeof value === 'string' && value.trim() !== ''
? Number(value)
: NaN;
if (!Number.isFinite(number)) return null;
const rounded = Math.round(number);
return rounded >= MIN_FRP_MW_MIN && rounded <= MIN_FRP_MW_MAX
? rounded
: null;
}
/**
* Confidence mode → one of FIRMS_CONFIDENCE_MODES, or null.
* @param {*} value
* @returns {?string}
*/
export function normalizeConfidenceMode(value) {
const text = String(value ?? '')
.trim()
.toLowerCase();
return FIRMS_CONFIDENCE_MODES.includes(text) ? text : null;
}
/**
* Sanitize a partial filter against the current one; unknown or invalid
* fields keep their current value.
* @param {Object} [params]
* @param {{minFrp: number, confidence: string}} [current]
* @returns {{minFrp: number, confidence: string}}
*/
export function normalizeFireFilter(
params = {},
current = DEFAULT_FIRE_FILTER,
) {
const minFrp = normalizeMinFrp(params?.minFrp);
const confidence = normalizeConfidenceMode(params?.confidence);
return {
minFrp: minFrp === null ? current.minFrp : minFrp,
confidence: confidence === null ? current.confidence : confidence,
};
}
/**
* Whether one fire passes the filter. `confidence` is the normalized 0..1
* value firmsAdapt produces; `frp` is megawatts.
* @param {{frp: number, confidence: number}} fire
* @param {{minFrp: number, confidence: string}} filter
* @returns {boolean}
*/
export function firePassesFilter(fire, filter = DEFAULT_FIRE_FILTER) {
const frp = Number.isFinite(fire?.frp) ? fire.frp : 0;
if (frp < (filter.minFrp || 0)) return false;
const floor = CONFIDENCE_FLOOR[filter.confidence] ?? 0;
if (floor <= 0) return true;
const confidence = Number.isFinite(fire?.confidence) ? fire.confidence : 0;
return confidence >= floor;
}
/**
* The fires that pass the filter, in input order. Returns the SAME array when
* the filter is the default, so callers can tell "nothing hidden" cheaply.
* @template T
* @param {Array<T>} fires Internal records (normalized confidence).
* @param {{minFrp: number, confidence: string}} filter
* @returns {Array<T>}
*/
export function filterFires(fires, filter = DEFAULT_FIRE_FILTER) {
if (!Array.isArray(fires)) return [];
if (isDefaultFireFilter(filter)) return fires;
return fires.filter((fire) => firePassesFilter(fire, filter));
}
/** True when the filter hides nothing. */
export function isDefaultFireFilter(filter) {
return (
!(filter?.minFrp > 0) &&
(filter?.confidence === 'all' || filter?.confidence == null)
);
}
+207
View File
@@ -0,0 +1,207 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DEFAULT_FIRE_FILTER,
FIRMS_CONFIDENCE_MODES,
FRP_RAMP,
FRP_SLIDER_TICKS,
filterFires,
firePassesFilter,
frpForSliderPosition,
frpRampStop,
hexToRgb,
isDefaultFireFilter,
normalizeConfidenceMode,
normalizeFireFilter,
normalizeMinFrp,
rampTrackGradient,
sliderPositionForFrp,
} from './firmsIntensity.js';
test('the ramp has six cold → hot stops at 0 · 5 · 20 · 50 · 100 · 300 MW in Portolan hue order', () => {
assert.deepEqual(
FRP_RAMP.map((stop) => [stop.name, stop.minFrp, stop.hex]),
[
['blue', 0, '#4a5bd4'],
['teal', 5, '#39a0a8'],
['lime', 20, '#c9cf4a'],
['amber', 50, '#f2b134'],
['orange', 100, '#e8722c'],
['red', 300, '#d1382a'],
],
);
assert.deepEqual([...FRP_SLIDER_TICKS], [0, 5, 20, 50, 100, 300]);
assert.ok(Object.isFrozen(FRP_RAMP));
});
test('a detection takes the last stop it reaches; garbage reads as 0 MW', () => {
assert.equal(frpRampStop(0).name, 'blue');
assert.equal(frpRampStop(4.99).name, 'blue');
assert.equal(frpRampStop(5).name, 'teal');
assert.equal(frpRampStop(19.9).name, 'teal');
assert.equal(frpRampStop(20).name, 'lime');
assert.equal(frpRampStop(50).name, 'amber');
assert.equal(frpRampStop(100).name, 'orange');
assert.equal(frpRampStop(299.9).name, 'orange');
assert.equal(frpRampStop(300).name, 'red');
assert.equal(frpRampStop(1246).name, 'red');
assert.equal(frpRampStop(NaN).name, 'blue');
assert.equal(frpRampStop(-3).name, 'blue');
assert.equal(frpRampStop(undefined).name, 'blue');
});
test('hex stops convert to the "r, g, b" accents the cards paint', () => {
assert.deepEqual(hexToRgb('#4a5bd4'), [74, 91, 212]);
assert.deepEqual(hexToRgb('d1382a'), [209, 56, 42]);
});
test('the log slider pins 0 MW at the left and runs 1 → 1000 MW; values round-trip', () => {
assert.equal(sliderPositionForFrp(0), 0);
assert.equal(sliderPositionForFrp(0.5), 0);
assert.equal(sliderPositionForFrp(1), 0);
assert.ok(Math.abs(sliderPositionForFrp(10) - 1 / 3) < 1e-9);
assert.ok(Math.abs(sliderPositionForFrp(100) - 2 / 3) < 1e-9);
assert.equal(sliderPositionForFrp(1000), 1);
assert.equal(sliderPositionForFrp(5000), 1, 'clamped past the axis end');
assert.equal(frpForSliderPosition(0), 0);
assert.equal(frpForSliderPosition(1), 1000);
assert.equal(frpForSliderPosition(-1), 0);
assert.equal(frpForSliderPosition(2), 1000);
for (const frp of FRP_SLIDER_TICKS) {
assert.equal(
frpForSliderPosition(sliderPositionForFrp(frp)),
frp,
`tick ${frp} MW round-trips through the axis`,
);
}
// Ticks spread along the track instead of bunching at one end.
const positions = FRP_SLIDER_TICKS.map(sliderPositionForFrp);
for (let i = 1; i < positions.length; i += 1) {
assert.ok(positions[i] - positions[i - 1] >= 0.09, `tick gap ${i}`);
}
});
test('the track gradient is the stepped ramp on the log axis, so it doubles as the legend', () => {
const gradient = rampTrackGradient();
assert.match(gradient, /^linear-gradient\(90deg, /);
assert.match(gradient, /#4a5bd4 0\.00% 23\.3\d%/);
assert.match(gradient, /#39a0a8 23\.3\d% 43\.3\d%/);
assert.match(gradient, /#d1382a 82\.5\d% 100\.00%\)$/);
assert.equal(gradient.match(/#/g).length, FRP_RAMP.length);
});
test('minimum FRP normalizes to whole megawatts inside the codec bounds', () => {
assert.equal(normalizeMinFrp(0), 0);
assert.equal(normalizeMinFrp(19.6), 20);
assert.equal(normalizeMinFrp('100'), 100);
assert.equal(normalizeMinFrp(10000), 10000);
assert.equal(normalizeMinFrp(10001), null);
assert.equal(normalizeMinFrp(-1), null);
assert.equal(normalizeMinFrp('abc'), null);
assert.equal(normalizeMinFrp(''), null);
assert.equal(normalizeMinFrp(null), null);
assert.equal(normalizeMinFrp(NaN), null);
});
test('confidence modes are exactly all / nominal / high', () => {
assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']);
assert.equal(normalizeConfidenceMode(' High '), 'high');
assert.equal(normalizeConfidenceMode('nominal'), 'nominal');
assert.equal(normalizeConfidenceMode('low'), null);
assert.equal(normalizeConfidenceMode(0.9), null);
assert.equal(normalizeConfidenceMode(undefined), null);
});
test('a partial filter patches the current one and invalid fields keep their value', () => {
assert.deepEqual(normalizeFireFilter(), DEFAULT_FIRE_FILTER);
assert.deepEqual(normalizeFireFilter({ minFrp: 20 }), {
minFrp: 20,
confidence: 'all',
});
assert.deepEqual(
normalizeFireFilter(
{ confidence: 'high' },
{ minFrp: 50, confidence: 'all' },
),
{ minFrp: 50, confidence: 'high' },
);
assert.deepEqual(
normalizeFireFilter(
{ minFrp: 'nope', confidence: 'medium' },
{ minFrp: 50, confidence: 'nominal' },
),
{ minFrp: 50, confidence: 'nominal' },
);
assert.equal(isDefaultFireFilter(DEFAULT_FIRE_FILTER), true);
assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'all' }), true);
assert.equal(isDefaultFireFilter({ minFrp: 1, confidence: 'all' }), false);
assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'high' }), false);
});
test('the filter keeps a fire at or above the floor and at or above the confidence mode', () => {
const low = { frp: 864, confidence: 0.3 };
const nominal = { frp: 20, confidence: 0.6 };
const high = { frp: 2, confidence: 0.9 };
assert.equal(firePassesFilter(low), true, 'default shows everything');
assert.equal(
firePassesFilter(nominal, { minFrp: 20, confidence: 'all' }),
true,
);
assert.equal(
firePassesFilter(nominal, { minFrp: 21, confidence: 'all' }),
false,
);
assert.equal(
firePassesFilter(low, { minFrp: 0, confidence: 'nominal' }),
false,
);
assert.equal(
firePassesFilter(nominal, { minFrp: 0, confidence: 'nominal' }),
true,
);
assert.equal(
firePassesFilter(high, { minFrp: 0, confidence: 'nominal' }),
true,
);
assert.equal(
firePassesFilter(nominal, { minFrp: 0, confidence: 'high' }),
false,
);
assert.equal(firePassesFilter(high, { minFrp: 0, confidence: 'high' }), true);
assert.equal(
firePassesFilter(high, { minFrp: 5, confidence: 'high' }),
false,
);
// Missing numbers read as 0, never as a pass-through.
assert.equal(firePassesFilter({}, { minFrp: 1, confidence: 'all' }), false);
assert.equal(
firePassesFilter({ frp: 10 }, { minFrp: 0, confidence: 'nominal' }),
false,
);
});
test('filterFires returns the same array for the default filter and preserves order otherwise', () => {
const fires = [
{ frp: 864, confidence: 0.3 },
{ frp: 20, confidence: 0.6 },
{ frp: 2, confidence: 0.9 },
{ frp: 120, confidence: 0.9 },
];
assert.equal(filterFires(fires), fires);
assert.equal(filterFires(fires, { minFrp: 0, confidence: 'all' }), fires);
assert.deepEqual(
filterFires(fires, { minFrp: 20, confidence: 'all' }).map((f) => f.frp),
[864, 20, 120],
);
assert.deepEqual(
filterFires(fires, { minFrp: 0, confidence: 'high' }).map((f) => f.frp),
[2, 120],
);
assert.deepEqual(
filterFires(fires, { minFrp: 100, confidence: 'nominal' }).map(
(f) => f.frp,
),
[120],
);
assert.deepEqual(filterFires(null, { minFrp: 1, confidence: 'all' }), []);
});
+14 -9
View File
@@ -4,6 +4,8 @@
* dedicated card canvas moved into the shared world-overlay host.
*/
import { FRP_RAMP, hexToRgb } from './firmsIntensity.js';
/** Shipped ambient FIRMS card ceiling. Selected fires bypass this cohort. */
export const FIRMS_AMBIENT_COHORT_LIMIT = 18;
export const FIRMS_OVERLAY_SOURCE_ID = 'firms';
@@ -23,20 +25,23 @@ export function fireDetectionKey(fire) {
return `firms:${lat}:${lon}:${acq}:${source}`;
}
/** Severity accent palette — matches the FIRMS glow-sprite color stops. */
const ACCENT_RGB = Object.freeze({
red: '224, 82, 82',
orange: '240, 178, 62',
yellow: '244, 227, 108',
});
/**
* Severity accent palette — the FIRMS glow-sprite color stops (the six-stop
* FRP ramp in firmsIntensity.js) as "r, g, b" strings for card accents.
*/
const ACCENT_RGB = Object.freeze(
Object.fromEntries(
FRP_RAMP.map((stop) => [stop.name, hexToRgb(stop.hex).join(', ')]),
),
);
/**
* Severity stop name → "r, g, b" accent string (defaults to yellow).
* @param {string} stopName - 'red' | 'orange' | 'yellow'.
* Severity stop name → "r, g, b" accent string (defaults to the coldest stop).
* @param {string} stopName - A FRP_RAMP stop name ('blue' … 'red').
* @returns {string}
*/
export function accentForSeverity(stopName) {
return ACCENT_RGB[stopName] || ACCENT_RGB.yellow;
return ACCENT_RGB[stopName] || ACCENT_RGB[FRP_RAMP[0].name];
}
/**
+10 -5
View File
@@ -9,11 +9,16 @@ import {
formatFrpMw,
} from './firmsLabels.js';
test('FIRMS formatting helpers retain the shipped severity palette', () => {
assert.equal(accentForSeverity('red'), '224, 82, 82');
assert.equal(accentForSeverity('orange'), '240, 178, 62');
assert.equal(accentForSeverity('yellow'), '244, 227, 108');
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow'));
test('FIRMS card accents are the six FRP ramp stops, coldest as the fallback', () => {
// Portolan hue order; blue #4a5bd4 … red #d1382a (HAL-675).
assert.equal(accentForSeverity('blue'), '74, 91, 212');
assert.equal(accentForSeverity('teal'), '57, 160, 168');
assert.equal(accentForSeverity('lime'), '201, 207, 74');
assert.equal(accentForSeverity('amber'), '242, 177, 52');
assert.equal(accentForSeverity('orange'), '232, 114, 44');
assert.equal(accentForSeverity('red'), '209, 56, 42');
assert.equal(accentForSeverity('yellow'), accentForSeverity('blue'));
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('blue'));
});
test('FIRMS power that rounds to 10 MW drops the decimal', () => {
+20 -1
View File
@@ -298,6 +298,20 @@ function satelliteFilterOption() {
}
const OPTION_GROUPS = Object.freeze({
// Active Fires intensity filter (HAL-676). `minFrp` is whole megawatts on
// the codec side; the panel's log slider rounds to it. Both default to
// "show everything": many of the strongest fires are rated low confidence.
// The bounds and modes mirror MIN_FRP_MW_MAX / FIRMS_CONFIDENCE_MODES in
// firmsIntensity.js (pinned by layerState.test); spelled out here because
// this file runs in isolation in the token-ledger tests.
'local-firms': Object.freeze([
boundedIntegerOption('minFrp', 'f', 0, { min: 0, max: 10000 }),
enumOption('confidence', 'c', 'all', ['all', 'nominal', 'high'], {
all: 'a',
nominal: 'n',
high: 'h',
}),
]),
traffic: Object.freeze([
enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], {
tomtom: 't',
@@ -621,7 +635,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
token: 'd',
disposition: 'enabled-only',
}),
Object.freeze({ id: 'local-firms', token: 'w', disposition: 'enabled-only' }),
Object.freeze({
id: 'local-firms',
token: 'w',
disposition: 'enabled+options',
optionOwner: 'local-firms',
}),
Object.freeze({
id: 'military',
token: 'm',
+82 -1
View File
@@ -29,6 +29,7 @@ import radioLayer from './radio.js';
import { stampInitialShareGesture } from '../navigationPolicy.js';
import { MAX_SINCE_DAYS } from '../layers/streetLevel/policy.js';
import { SINCE_STOPS } from '../ui/streetLevelPresentation.js';
import { FIRMS_CONFIDENCE_MODES, MIN_FRP_MW_MAX } from './firmsIntensity.js';
function deferred() {
let resolve;
@@ -2984,9 +2985,89 @@ test('Clear All cancels a saved restore still waiting to start', async () => {
gate.resolve();
const results = await restore;
for (const id of ['earthquakes', 'flights']) {
assert.equal(results.find((result) => result.layerId === id).succeeded, false);
assert.equal(
results.find((result) => result.layerId === id).succeeded,
false,
);
}
assert.deepEqual([...manager.getEnabledLayerIds()], []);
assert.equal(enabledLayerParam(sink), '');
coordinator.destroy();
});
/** Active Fires options decoded from a v2 link with these `lo` assignments. */
function firmsOptions(lo) {
const params = new URLSearchParams([
['v', '2'],
['l', 'w'],
]);
if (lo) params.set('lo', lo);
return decodeLayerStateParams(params).options['local-firms'];
}
test('Active Fires: the intensity filter rides share token w and round-trips', () => {
const state = createDefaultLayerState();
assert.deepEqual(state.options['local-firms'], {
minFrp: 0,
confidence: 'all',
});
state.enabledLayerIds = ['local-firms'];
state.options['local-firms'] = { minFrp: 20, confidence: 'high' };
const encoded = encode(state);
assert.equal(new URLSearchParams(encoded).get('l'), 'w');
// Other owners write their own explicit defaults into `lo` too (flights 3D,
// wind overlay); the fire assignments ride beside them.
const assignments = new URLSearchParams(encoded).get('lo').split('_');
assert.ok(assignments.includes('w.f.20'), assignments.join(' '));
assert.ok(assignments.includes('w.c.h'), assignments.join(' '));
const decoded = decodeLayerStateParams(new URLSearchParams(encoded));
assert.deepEqual(decoded.options['local-firms'], {
minFrp: 20,
confidence: 'high',
});
assert.deepEqual(
parseStoredLayerState(serializeStoredLayerState(state)).options[
'local-firms'
],
state.options['local-firms'],
);
// Defaults are omitted from the link: a plain `l=w` is "show everything".
const plain = createDefaultLayerState();
plain.enabledLayerIds = ['local-firms'];
assert.equal(
new URLSearchParams(encode(plain))
.get('lo')
.split('_')
.filter((assignment) => assignment.startsWith('w.')).length,
0,
);
assert.deepEqual(firmsOptions(''), { minFrp: 0, confidence: 'all' });
});
test('Active Fires: the codec accepts whole megawatts in bounds and the three confidence codes', () => {
// The codec spells its bounds out (it runs in isolation in the ledger
// tests); they must stay the layer's.
assert.equal(MIN_FRP_MW_MAX, 10000);
assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']);
assert.equal(firmsOptions('w.f.300').minFrp, 300);
assert.equal(firmsOptions('w.f.10000').minFrp, 10000);
assert.equal(firmsOptions('w.f.10001').minFrp, 0, 'out of bounds → default');
assert.equal(firmsOptions('w.f.-5').minFrp, 0);
assert.equal(
firmsOptions('w.f.2.5').minFrp,
0,
'a third segment is rejected',
);
assert.equal(firmsOptions('w.c.a').confidence, 'all');
assert.equal(firmsOptions('w.c.n').confidence, 'nominal');
assert.equal(firmsOptions('w.c.h').confidence, 'high');
assert.equal(
firmsOptions('w.c.x').confidence,
'all',
'unknown code → default',
);
assert.deepEqual(firmsOptions('w.c.n_w.f.5'), {
minFrp: 5,
confidence: 'nominal',
});
});
+13 -1
View File
@@ -3001,5 +3001,17 @@
"Your choice applies now, but could not be saved.": "Tu elección se aplica ahora, pero no se pudo guardar.",
"Voice replies appear here.": "Las respuestas de voz aparecen aquí.",
"Preview is off.": "La vista previa está desactivada.",
"Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz."
"Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.",
"fire confidence filter|All": "Todas",
"fire confidence filter|Nominal+": "Nominal+",
"fire confidence filter|High": "Alta",
"Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos tienen confianza baja",
"Nominal and high confidence detections": "Detecciones de confianza nominal y alta",
"High confidence detections only": "Solo detecciones de confianza alta",
"Min FRP": "FRP mín.",
"Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios",
"{value} MW": "{value} MW",
"fire intensity filter|All": "Todo",
"Detection confidence": "Confianza de la detección",
"{shown} of {total} shown": "{shown} de {total} mostrados"
}
+13 -1
View File
@@ -3070,5 +3070,17 @@
"Your choice applies now, but could not be saved.": "Ваш выбор применён, но сохранить его не удалось.",
"Voice replies appear here.": "Здесь появляются голосовые ответы.",
"Preview is off.": "Предпросмотр отключён.",
"Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения."
"Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.",
"fire confidence filter|All": "Все",
"fire confidence filter|Nominal+": "Средняя+",
"fire confidence filter|High": "Высокая",
"Every detection — many of the strongest fires are rated low confidence": "Все детекции — у многих сильнейших пожаров достоверность низкая",
"Nominal and high confidence detections": "Детекции средней и высокой достоверности",
"High confidence detections only": "Только детекции высокой достоверности",
"Min FRP": "Мин. FRP",
"Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты",
"{value} MW": "{value} МВт",
"fire intensity filter|All": "Все",
"Detection confidence": "Достоверность детекции",
"{shown} of {total} shown": "показано {shown} из {total}"
}
+13 -1
View File
@@ -2863,5 +2863,17 @@
"{count} placemarks": {
"other": "{count} 个地标"
},
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。"
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。",
"fire confidence filter|All": "全部",
"fire confidence filter|Nominal+": "标称+",
"fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低",
"Nominal and high confidence detections": "标称和高置信度的检测",
"High confidence detections only": "仅高置信度的检测",
"Min FRP": "最小 FRP",
"Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)",
"{value} MW": "{value} MW",
"fire intensity filter|All": "全部",
"Detection confidence": "检测置信度",
"{shown} of {total} shown": "显示 {shown} / {total}"
}
+4 -1
View File
@@ -98,6 +98,7 @@
"WX",
"audio {state}",
"dB",
"fire confidence filter|Nominal+",
"fire confidence|nominal",
"ft",
"hPa",
@@ -120,6 +121,7 @@
"{value} KM",
"{value} KM/H",
"{value} KM/S",
"{value} MW",
"ΔE",
"ΔN",
"▲ {frp} MW"
@@ -168,9 +170,9 @@
"MM",
"NVG",
"REC",
"SRC {value}",
"S2 GhostMaps",
"S2 · {name}",
"SRC {value}",
"Sentinel-2",
"Techno",
"Trance",
@@ -195,6 +197,7 @@
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
"{value} KM/S",
"{value} MW",
"ΔE",
"ΔN",
"▲ {frp} MW"
+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;
+1
View File
@@ -255,6 +255,7 @@ export const SHARE_VIEW_FLAGS = freezeAll([
* written explicitly.
*/
export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({
'local-firms': Object.freeze({ minFrp: 0, confidence: 'all' }),
traffic: Object.freeze({ roadMode: null }),
'weather-lightning': Object.freeze({ opacity: 'strong' }),
'weather-radar': Object.freeze({ opacity: 'strong' }),
+60 -4
View File
@@ -4,6 +4,14 @@ import { suggestView } from '../views.js';
import { defineTool, ToolError } from '../catalog.js';
import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js';
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
import {
FIRMS_CONFIDENCE_MODES,
MIN_FRP_MW_MAX,
MIN_FRP_MW_MIN,
firePassesFilter,
normalizeFireFilter,
} from '../../data/firmsIntensity.js';
import { normalizeConfidence } from '../../data/firmsAdapt.js';
function acquiredMsUtc(acqDate, acqTime) {
if (typeof acqDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(acqDate))
@@ -87,10 +95,30 @@ export const getActiveFires = defineTool({
title: 'Active fires',
description:
'Satellite fire detections from NASA FIRMS in the last 24 hours within an ' +
'area, most intense (fire radiative power) first.',
'area, most intense (fire radiative power) first. Optionally limited to a ' +
'minimum fire radiative power in megawatts and a confidence level — the ' +
"same filter as the Active Fires layer's intensity controls.",
inputSchema: {
type: 'object',
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
properties: {
area: AREA_SCHEMA,
min_frp_mw: {
type: 'number',
minimum: MIN_FRP_MW_MIN,
maximum: MIN_FRP_MW_MAX,
description:
'Hide detections weaker than this fire radiative power (MW). ' +
'A typical detection is ~6 MW; 20 MW keeps the top tenth, 100 MW the top percent.',
},
confidence: {
type: 'string',
enum: [...FIRMS_CONFIDENCE_MODES],
description:
'"all" (default), "nominal" (nominal and high), or "high". Many of the ' +
'strongest fires are rated low confidence, so prefer "all" unless asked.',
},
limit: LIMIT_SCHEMA,
},
required: ['area'],
additionalProperties: false,
},
@@ -103,9 +131,31 @@ export const getActiveFires = defineTool({
'unavailable',
'Fire detections need a NASA FIRMS key configured for this server',
);
const filter = normalizeFireFilter({
minFrp: args.min_frp_mw,
confidence: args.confidence,
});
const fires = snapshot.fires
.filter((fire) => areaContains(area, fire))
.filter(
(fire) =>
areaContains(area, fire) &&
firePassesFilter(
{
frp: Number(fire.frp),
confidence: normalizeConfidence(fire.confidence),
},
filter,
),
)
.sort((a, b) => (b.frp ?? 0) - (a.frp ?? 0));
const filterNotes = [
...(filter.minFrp > 0 ? [`≥ ${filter.minFrp} MW`] : []),
...(filter.confidence !== 'all'
? [
`${filter.confidence === 'high' ? 'high' : 'nominal or high'} confidence`,
]
: []),
];
const result = capRows(fires, args.limit);
// The proxy serves its last good snapshot when a refresh fails, and marks
// satellites whose download failed.
@@ -119,10 +169,16 @@ export const getActiveFires = defineTool({
];
return {
summary:
`${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours` +
`${countNoun(fires.length, 'fire detection')}` +
(filterNotes.length ? ` (${filterNotes.join(', ')})` : '') +
` in ${area.label} in the last 24 hours` +
(notes.length ? ` (${notes.join('; ')}).` : '.'),
data: {
view: suggestView(services, { area, layers: ['local-firms'] }),
filter: {
min_frp_mw: filter.minFrp,
confidence: filter.confidence,
},
stale,
missing_sources: missing,
fetched_at: isoTime(snapshot.fetchedAt),
+36
View File
@@ -44,3 +44,39 @@ test('fire acquisition timestamps preserve valid leap days and UTC times', async
assert.equal(result.data.rows[0].acquired, expected);
}
});
const WEEK = [
{ lat: 24.1, lon: 121.1, frp: 864, confidence: 'l', acqDate: '2026-10-01', acqTime: '0100' },
{ lat: 24.2, lon: 121.2, frp: 120, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' },
{ lat: 24.3, lon: 121.3, frp: 20, confidence: 'n', acqDate: '2026-10-01', acqTime: '0100' },
{ lat: 24.4, lon: 121.4, frp: 2, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' },
{ lat: 30, lon: 100, frp: 500, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' },
];
const TAIWAN = { area: { bbox: [120, 23, 122, 25] } };
test('get_active_fires shows every detection by default and reports the filter it used', async () => {
const result = await catalogWith(WEEK).call('get_active_fires', TAIWAN);
assert.deepEqual(result.data.rows.map((row) => row.frp_mw), [864, 120, 20, 2]);
assert.deepEqual(result.data.filter, { min_frp_mw: 0, confidence: 'all' });
assert.match(result.summary, /^4 fire detections in /);
});
test('get_active_fires applies the same minimum-FRP and confidence filter as the layer', async () => {
const catalog = catalogWith(WEEK);
const strong = await catalog.call('get_active_fires', { ...TAIWAN, min_frp_mw: 20 });
assert.deepEqual(strong.data.rows.map((row) => row.frp_mw), [864, 120, 20]);
assert.match(strong.summary, /^3 fire detections \(≥ 20 MW\) in /);
const nominal = await catalog.call('get_active_fires', { ...TAIWAN, confidence: 'nominal' });
assert.deepEqual(nominal.data.rows.map((row) => row.frp_mw), [120, 20, 2], 'the 864 MW low-confidence fire drops');
assert.match(nominal.summary, /\(nominal or high confidence\)/);
const both = await catalog.call('get_active_fires', {
...TAIWAN,
min_frp_mw: 19.6,
confidence: 'high',
});
assert.deepEqual(both.data.rows.map((row) => row.frp_mw), [120]);
assert.deepEqual(both.data.filter, { min_frp_mw: 20, confidence: 'high' });
assert.match(both.summary, /\(≥ 20 MW, high confidence\)/);
});
+48 -6
View File
@@ -1,4 +1,5 @@
import { syncChipGroup } from './chipGroup.js';
import { syncSegmentedGroup, syncSliderBlock } from './rowFilterControls.js';
import { syncRowList } from './rowList.js';
import { layerFeedState } from '../data/feedState.js';
export { layerFeedState } from '../data/feedState.js';
@@ -388,18 +389,42 @@ export class LayerPanel {
const controls = document.createElement('div');
controls.className = 'data-toggle-controls';
this._bind(controls, 'click', (event) => {
const button = event.target?.closest?.('.data-toggle-chip');
const button =
event.target?.closest?.('.data-toggle-chip') ||
event.target?.closest?.('.data-toggle-segment');
if (!button || button.disabled) return;
// Re-read the live descriptor rather than trusting the rendered
// chip, so a stale row can never apply an inverted toggle.
const chip = this._rowControlsFor(layer.id)?.chips?.find(
(entry) => entry.id === button.dataset.chipId,
);
const live = this._rowControlsFor(layer.id);
const chip =
live?.chips?.find(
(entry) => entry.id === button.dataset.chipId,
) ||
live?.segmented?.options?.find(
(entry) => entry.id === button.dataset.chipId,
);
if (!chip || chip.disabled || !this.isEnabled(layer.id)) return;
if (typeof chip.onClick === 'function') chip.onClick();
else if (chip.params)
this.setLayerParams(layer.id, chip.params, { origin: 'user' });
});
// A row slider (e.g. the Active Fires minimum-FRP filter) maps its
// position to params through the live descriptor on every tick.
this._bind(controls, 'input', (event) => {
const input = event.target;
if (!input?.matches?.('.data-toggle-slider-input')) return;
const slider = this._rowControlsFor(layer.id)?.slider;
if (
!slider ||
slider.id !== input.dataset.sliderId ||
typeof slider.params !== 'function' ||
!this.isEnabled(layer.id)
)
return;
this.setLayerParams(layer.id, slider.params(input.value), {
origin: 'user',
});
});
row.appendChild(controls);
// An ordered list below the chips, for a layer whose row carries a
// sequence (turn-by-turn directions). Its own delegated listener, its
@@ -521,13 +546,30 @@ export class LayerPanel {
const chips = controls?.chips || [];
const legend = controls?.legend || [];
const infoText = controls?.info;
const slider = controls?.slider || null;
const segmented = controls?.segmented || null;
this._syncRowList(listContainer, controls?.list || null);
container.hidden = chips.length === 0 && legend.length === 0 && !infoText;
container.hidden =
chips.length === 0 &&
legend.length === 0 &&
!infoText &&
!slider &&
!segmented;
const info = container._rowControlsInfo || null;
const firstLegend = container.querySelector('.data-toggle-legend-item');
syncChipGroup(container, chips, { before: firstLegend || info });
// Order: chips, switch, slider, legend, info.
syncSegmentedGroup(container, segmented, { before: firstLegend || info });
const switchNode = container.querySelector('.data-toggle-segmented');
syncSliderBlock(container, slider, {
before: switchNode?.nextSibling || firstLegend || info,
});
const firstControl =
switchNode || container.querySelector('.data-toggle-slider');
syncChipGroup(container, chips, {
before: firstControl || firstLegend || info,
});
const legendSignature = JSON.stringify(
legend.map(({ label, color, count, blurb }) => [
+169
View File
@@ -0,0 +1,169 @@
import { createSlider } from './slider.js';
import { syncSegmentedControl } from './segmentedControl.js';
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
};
const attribute = (node, key, value) => {
if (node.getAttribute(key) !== value) node.setAttribute(key, value);
};
// Custom properties need setProperty; test fixtures carry a plain style bag.
const styleVar = (node, name, value) => {
if (typeof node.style?.setProperty === 'function') {
if (value) node.style.setProperty(name, value);
else node.style.removeProperty(name);
} else if (node.style) node.style[name] = value || '';
};
/**
* Reconcile a layer row's quantitative slider block in place (keyed by the
* descriptor id), so a refresh triggered by the slider's own input never
* replaces the node under the pointer. The caller owns dispatch: it listens
* for `input` on the row container and reads `.data-toggle-slider-input`.
*
* Descriptor: `{ id, label, ariaLabel, valueText, min, max, step, value,
* track, ticks: [{ at, label }] }` — `track` is a CSS background painting the
* runnable track (a color ramp doubles as the legend), `at` is 0..1.
* @param {HTMLElement} container The row's `.data-toggle-controls` node.
* @param {?object} slider Descriptor, or null to remove the block.
* @param {{before?: ?Node}} [options] Insertion anchor for a new block.
* @returns {?HTMLElement} The block, when rendered.
*/
export function syncSliderBlock(container, slider, { before = null } = {}) {
if (!container) return null;
const document = container.ownerDocument ?? globalThis.document;
const existing = [...container.children].find(
(node) => node.dataset?.sliderId,
);
if (!slider) {
existing?.remove();
return null;
}
let block = existing;
if (!block || block.dataset.sliderId !== slider.id) {
existing?.remove();
block = document.createElement('div');
block.className = 'data-toggle-slider';
block.dataset.sliderId = slider.id;
const head = document.createElement('div');
head.className = 'data-toggle-slider-head';
const label = document.createElement('span');
label.className = 'data-toggle-slider-label';
const value = document.createElement('span');
value.className = 'data-toggle-slider-value gev-slider-value';
head.append(label, value);
const input = createSlider({
documentRef: document,
className: 'data-toggle-slider-input',
min: slider.min,
max: slider.max,
step: slider.step,
value: slider.value,
});
input.dataset.sliderId = slider.id;
const ticks = document.createElement('div');
ticks.className = 'data-toggle-slider-ticks';
block.append(head, input, ticks);
block._parts = { label, value, input, ticks };
container.insertBefore(block, before);
}
const { label, value, input, ticks } = block._parts;
set(label, 'textContent', slider.label ?? '');
set(value, 'textContent', slider.valueText ?? '');
set(input, 'min', String(slider.min));
set(input, 'max', String(slider.max));
set(input, 'step', String(slider.step ?? 1));
// Never move the thumb under an active drag: the layer's rounded value can
// sit a hair off the pointer, and a refresh runs on every input tick.
if (document.activeElement !== input)
set(input, 'value', String(slider.value));
if (slider.ariaLabel) attribute(input, 'aria-label', slider.ariaLabel);
if (slider.valueText) attribute(input, 'aria-valuetext', slider.valueText);
const track = slider.track || '';
if ((block.dataset.track || '') !== track) {
styleVar(input, '--slider-track', track);
block.dataset.track = track;
}
const signature = JSON.stringify(slider.ticks || []);
if (block.dataset.ticks !== signature) {
for (const node of [...ticks.children]) node.remove();
for (const tick of slider.ticks || []) {
const node = document.createElement('span');
node.className = 'data-toggle-slider-tick';
styleVar(node, '--at', `${(tick.at * 100).toFixed(2)}%`);
node.textContent = tick.label;
ticks.appendChild(node);
}
block.dataset.ticks = signature;
}
return block;
}
/**
* Reconcile a one-of-many switch (`.gev-segmented` radiogroup) in place.
* Buttons carry `data-chip-id` like chips so the row's delegated click handler
* can resolve them; the caller matches `.data-toggle-segment`.
*
* Descriptor: `{ id, ariaLabel, options: [{ id, label, title, active,
* disabled, params }] }`.
* @param {HTMLElement} container The row's `.data-toggle-controls` node.
* @param {?object} segmented Descriptor, or null to remove the group.
* @param {{before?: ?Node}} [options] Insertion anchor for a new group.
* @returns {?HTMLElement} The group, when rendered.
*/
export function syncSegmentedGroup(
container,
segmented,
{ before = null } = {},
) {
if (!container) return null;
const document = container.ownerDocument ?? globalThis.document;
const existing = [...container.children].find(
(node) => node.dataset?.segmentedId,
);
if (!segmented) {
existing?.remove();
return null;
}
let group = existing;
if (!group || group.dataset.segmentedId !== segmented.id) {
existing?.remove();
group = document.createElement('div');
group.className = 'gev-segmented data-toggle-segmented';
group.dataset.segmentedId = segmented.id;
group.setAttribute('role', 'radiogroup');
container.insertBefore(group, before);
}
if (segmented.ariaLabel) attribute(group, 'aria-label', segmented.ariaLabel);
const stale = new Map();
for (const node of [...group.children])
if (node.dataset?.chipId) stale.set(node.dataset.chipId, node);
const ordered = [];
for (const option of segmented.options || []) {
let button = stale.get(option.id);
stale.delete(option.id);
if (!button) {
button = document.createElement('button');
button.type = 'button';
button.className = 'gev-segment data-toggle-segment';
button.dataset.chipId = option.id;
button.setAttribute('role', 'radio');
}
set(button, 'textContent', option.label);
set(button, 'title', option.title || '');
set(button, 'disabled', Boolean(option.disabled));
ordered.push(button);
}
for (const node of stale.values()) node.remove();
ordered.forEach((node, index) => {
if (group.children[index] !== node)
group.insertBefore(node, group.children[index] || null);
});
const active = (segmented.options || []).find((option) => option.active);
syncSegmentedControl(ordered, active?.id, {
valueOf: (button) => button.dataset.chipId,
activeClass: 'active',
rovingFocus: true,
});
return group;
}
+207
View File
@@ -0,0 +1,207 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { syncSegmentedGroup, syncSliderBlock } from './rowFilterControls.js';
import { railFixture } from './railTestFixture.mjs';
const sliderDescriptor = (overrides = {}) => ({
id: 'min-frp',
label: 'MIN FRP',
ariaLabel: 'Minimum fire radiative power',
valueText: 'All',
min: 0,
max: 1000,
step: 1,
value: 0,
track: 'linear-gradient(90deg, #4a5bd4 0% 23%, #d1382a 82% 100%)',
ticks: [
{ at: 0, label: '0' },
{ at: 0.233, label: '5' },
{ at: 0.826, label: '300' },
],
params: (value) => ({ minFrp: Number(value) }),
...overrides,
});
const segmentedDescriptor = (active = 'all') => ({
id: 'confidence',
ariaLabel: 'Detection confidence',
options: ['all', 'nominal', 'high'].map((mode) => ({
id: `confidence-${mode}`,
label: mode.toUpperCase(),
title: `${mode} title`,
active: mode === active,
params: { confidence: mode },
})),
});
test('the slider block renders label, value, painted track and ticks, and is reconciled in place', () => {
const f = railFixture();
const container = f.document.createElement('div');
const block = syncSliderBlock(container, sliderDescriptor());
assert.equal(block.className, 'data-toggle-slider');
assert.equal(block.dataset.sliderId, 'min-frp');
const input = block.querySelector('.data-toggle-slider-input');
assert.equal(input.type, 'range');
assert.equal(input.max, '1000');
assert.equal(input.value, '0');
assert.equal(
input.getAttribute('aria-label'),
'Minimum fire radiative power',
);
assert.equal(input.getAttribute('aria-valuetext'), 'All');
assert.match(input.style['--slider-track'], /^linear-gradient/);
assert.equal(
block.querySelector('.data-toggle-slider-label').textContent,
'MIN FRP',
);
assert.equal(
block.querySelector('.data-toggle-slider-value').textContent,
'All',
);
const ticks = block.querySelector('.data-toggle-slider-ticks');
assert.deepEqual(
ticks.children.map((node) => [node.textContent, node.style['--at']]),
[
['0', '0.00%'],
['5', '23.30%'],
['300', '82.60%'],
],
);
const again = syncSliderBlock(
container,
sliderDescriptor({ value: 434, valueText: '20 MW' }),
);
assert.equal(again, block, 'same node — a refresh never drops the thumb');
assert.equal(input.value, '434');
assert.equal(
block.querySelector('.data-toggle-slider-value').textContent,
'20 MW',
);
assert.equal(container.children.length, 1);
// A thumb under the pointer is never moved by a refresh.
input.focus();
syncSliderBlock(container, sliderDescriptor({ value: 0 }));
assert.equal(input.value, '434');
syncSliderBlock(container, null);
assert.equal(container.children.length, 0);
});
test('the segmented group is a radiogroup whose active option reads checked, reconciled by id', () => {
const f = railFixture();
const container = f.document.createElement('div');
const group = syncSegmentedGroup(container, segmentedDescriptor('all'));
assert.equal(group.getAttribute('role'), 'radiogroup');
assert.equal(group.getAttribute('aria-label'), 'Detection confidence');
assert.ok(group.className.split(' ').includes('gev-segmented'));
const buttons = group.children;
assert.deepEqual(
buttons.map((b) => [
b.dataset.chipId,
b.textContent,
b.getAttribute('aria-checked'),
b.getAttribute('tabindex'),
]),
[
['confidence-all', 'ALL', 'true', '0'],
['confidence-nominal', 'NOMINAL', 'false', '-1'],
['confidence-high', 'HIGH', 'false', '-1'],
],
);
assert.equal(buttons[0].getAttribute('role'), 'radio');
assert.equal(buttons[1].title, 'nominal title');
syncSegmentedGroup(container, segmentedDescriptor('high'));
assert.equal(group.children[0], buttons[0], 'buttons are reused');
assert.equal(buttons[0].getAttribute('aria-checked'), 'false');
assert.equal(buttons[2].getAttribute('aria-checked'), 'true');
assert.ok(buttons[2].classList.contains('active'));
assert.equal(container.children.length, 1);
syncSegmentedGroup(container, null);
assert.equal(container.children.length, 0);
});
test('a layer row dispatches slider input and switch clicks as user params', async () => {
const { LayerPanel } = await import('./layerPanel.js');
const f = railFixture();
const previousDocument = globalThis.document;
globalThis.document = f.document;
const calls = [];
let controls = {
slider: sliderDescriptor(),
segmented: segmentedDescriptor('all'),
info: '12 of 40 shown',
};
const panel = new LayerPanel({
getLayers: () => [
{
id: 'local-firms',
name: 'Active Fires',
icon: '▲',
source: 'NASA FIRMS',
enabled: true,
showInTogglePanel: true,
stats: {},
},
],
isEnabled: () => true,
setEnabled: () => {},
setLayerParams: (...args) => calls.push(args),
hasRowControls: () => true,
subscribeRowControls: () => {},
getRowControls: () => controls,
});
try {
panel.mount(f.container);
const row = f.find((n) => n.dataset.layerId === 'local-firms');
const container = row.querySelector('.data-toggle-controls');
assert.equal(container.hidden, false);
assert.equal(
row.querySelector('.data-toggle-controls-info').textContent,
'12 of 40 shown',
);
// Order: switch, slider, info.
assert.deepEqual(
container.children.map((node) => node.className.split(' ')[0]),
['gev-segmented', 'data-toggle-slider', 'data-toggle-controls-info'],
);
const input = row.querySelector('.data-toggle-slider-input');
input.value = '434';
const inputEvent = new Event('input');
Object.defineProperty(inputEvent, 'target', { value: input });
container.dispatchEvent(inputEvent);
assert.deepEqual(calls.at(-1), [
'local-firms',
{ minFrp: 434 },
{ origin: 'user' },
]);
const high = row
.querySelector('.data-toggle-segmented')
.children.find((b) => b.dataset.chipId === 'confidence-high');
high.click();
assert.deepEqual(calls.at(-1), [
'local-firms',
{ confidence: 'high' },
{ origin: 'user' },
]);
// A stale descriptor never applies: the live one decides.
controls = { ...controls, segmented: segmentedDescriptor('high') };
// The fixture DOM has no attribute selectors, so sync the row directly.
panel._syncRowControls(container, { id: 'local-firms', enabled: true });
assert.equal(high.getAttribute('aria-checked'), 'true');
assert.equal(
row.querySelector('.data-toggle-slider-input'),
input,
'the slider survives a refresh',
);
} finally {
panel.destroy();
globalThis.document = previousDocument;
}
});
+102
View File
@@ -1035,6 +1035,108 @@ html:not([lang='en']) .data-row-list-text {
padding-top: 3px;
overflow-wrap: anywhere;
}
/* Optional per-layer one-of-many switch (Active Fires confidence). The shared
.gev-segmented surface, sized to the chip row. */
.data-toggle-controls .data-toggle-segmented {
flex-basis: 100%;
max-width: 220px;
}
.data-toggle-controls .data-toggle-segment {
min-height: 20px;
padding: 3px 6px;
font-size: 8px;
letter-spacing: 0.8px;
}
/* Optional per-layer quantitative slider (Active Fires minimum FRP). The
track is painted by the layer (`--slider-track`, a stepped color ramp), so
the slider doubles as the marker legend; ticks sit under their thresholds. */
.data-toggle-slider {
flex-basis: 100%;
display: grid;
gap: 2px;
padding-top: 2px;
}
.data-toggle-slider-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 1.2px;
color: var(--text-dim);
text-transform: uppercase;
}
.data-toggle-slider-value {
min-width: 0;
font-size: 9px;
letter-spacing: 0.5px;
text-transform: none;
}
.data-toggle-slider-input {
height: 14px;
}
.data-toggle-slider-input::-webkit-slider-runnable-track {
height: 5px;
border-radius: 3px;
background: var(--slider-track, rgba(255, 255, 255, 0.08));
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
}
.data-toggle-slider-input::-moz-range-track,
.data-toggle-slider-input::-moz-range-progress {
height: 5px;
border-radius: 3px;
background: var(--slider-track, rgba(255, 255, 255, 0.08));
}
.data-toggle-slider-input::-webkit-slider-thumb {
width: 11px;
height: 11px;
margin-top: -3px;
background: var(--text-primary);
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.6),
0 0 6px rgba(255, 255, 255, 0.35);
}
.data-toggle-slider-input::-moz-range-thumb {
width: 11px;
height: 11px;
background: var(--text-primary);
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.6),
0 0 6px rgba(255, 255, 255, 0.35);
}
.data-toggle-slider-ticks {
position: relative;
height: 10px;
margin: 0 5px;
font-family: var(--font-mono);
font-size: 7px;
letter-spacing: 0.4px;
color: var(--text-dim);
}
.data-toggle-slider-tick {
position: absolute;
top: 0;
left: var(--at);
transform: translateX(-50%);
white-space: nowrap;
}
.data-toggle-slider-tick:first-child {
transform: none;
}
/* ── Local RTL-SDR card (FM + Local ADS-B receiver) ── */
.sdr-radio-card {
display: grid;