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
@@ -1117,5 +1117,9 @@
|
||||
"src/testSupport/shareViewFixtures.mjs",
|
||||
"src/ui/shareLinks.test.mjs",
|
||||
"src/ui/shareButton.test.mjs",
|
||||
"src/share/shareLinkPath.test.mjs"
|
||||
"src/share/shareLinkPath.test.mjs",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsIntensity.test.mjs",
|
||||
"src/ui/rowFilterControls.js",
|
||||
"src/ui/rowFilterControls.test.mjs"
|
||||
]
|
||||
@@ -495,6 +495,8 @@
|
||||
"src/ui/railCards.js",
|
||||
"src/ui/railCardBlocks.js",
|
||||
"src/ui/slider.js",
|
||||
"src/ui/rowFilterControls.js",
|
||||
"src/ui/segmentedControl.js",
|
||||
"src/ui/chipGroup.js",
|
||||
"src/ui/rowList.js",
|
||||
"src/ui/railTimeline.js",
|
||||
@@ -670,6 +672,7 @@
|
||||
"src/data/detectionPolicy.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
@@ -734,6 +737,7 @@
|
||||
"src/ui/effects.js",
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/rowFilterControls.js",
|
||||
"src/ui/weatherPanel.js",
|
||||
"src/ui/railCards.js",
|
||||
"src/ui/railCardBlocks.js",
|
||||
@@ -954,6 +958,7 @@
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
@@ -1007,6 +1012,7 @@
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
@@ -1052,6 +1058,7 @@
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
@@ -1136,6 +1143,7 @@
|
||||
"exports": ["./layers/firms"],
|
||||
"modules": [
|
||||
"src/data/firmsAdapt.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsLabels.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/layers/firms/anchors.js",
|
||||
@@ -1525,6 +1533,7 @@
|
||||
"modules": [
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/radioCountry.js",
|
||||
@@ -1848,6 +1857,7 @@
|
||||
"src/data/fireAnchors.js",
|
||||
"src/data/firmsAdapt.js",
|
||||
"src/data/firmsHeatmap.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsLabels.js",
|
||||
"src/data/flights.js",
|
||||
"src/data/flowMatch.js",
|
||||
@@ -2259,6 +2269,7 @@
|
||||
"src/ui/effects.js",
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/rowFilterControls.js",
|
||||
"src/ui/weatherPanel.js",
|
||||
"src/ui/railCards.js",
|
||||
"src/ui/railCardBlocks.js",
|
||||
@@ -2687,6 +2698,7 @@
|
||||
"src/data/detectionDraw.js",
|
||||
"src/data/detectionPolicy.js",
|
||||
"src/data/firmsAdapt.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsLabels.js",
|
||||
"src/data/flowMatch.js",
|
||||
"src/data/focusDeemphasis.js",
|
||||
@@ -3283,6 +3295,7 @@
|
||||
"src/i18n/marker.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
@@ -3899,6 +3912,7 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/view/index.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/idSwitches.js",
|
||||
@@ -3975,6 +3989,8 @@
|
||||
"src/tools/queries/brief.js",
|
||||
"src/tools/queries/maritime.js",
|
||||
"src/tools/queries/share.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsAdapt.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/data/layerState.js",
|
||||
@@ -4107,6 +4123,7 @@
|
||||
"modules": [
|
||||
"src/view/index.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json"
|
||||
],
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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 }) => [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user