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

+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',
});
});