From 38d29f2beb22b5200b794751b32f1f21a0409e25 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 13:09:32 +0400 Subject: [PATCH 01/24] feat(fires): FRP intensity filter, confidence switch and six-stop color ramp (HAL-676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/format-scope.json | 6 +- scripts/package-boundaries.json | 17 +++ src/data/firmsCards.test.mjs | 22 ++- src/data/firmsIntensity.js | 209 +++++++++++++++++++++++++++++ src/data/firmsIntensity.test.mjs | 207 ++++++++++++++++++++++++++++ src/data/firmsLabels.js | 23 ++-- src/data/firmsLabels.test.mjs | 15 ++- src/data/layerState.js | 21 ++- src/data/layerState.test.mjs | 83 +++++++++++- src/i18n/locales/es.json | 14 +- src/i18n/locales/ru.json | 14 +- src/i18n/locales/zh-CN.json | 14 +- src/i18n/same-as-english.json | 5 +- src/layers/firms/ingestion.js | 32 ++++- src/layers/firms/lifecycle.js | 4 + src/layers/firms/model.js | 57 +++++--- src/layers/firms/policy.js | 19 ++- src/layers/firms/queries.js | 142 +++++++++++++++++++- src/layers/firms/rendering.js | 5 +- src/layers/firms/state.js | 28 ++++ src/share/viewCodec.js | 1 + src/tools/queries/hazards.js | 64 ++++++++- src/tools/queries/hazards.test.mjs | 36 +++++ src/ui/layerPanel.js | 54 +++++++- src/ui/rowFilterControls.js | 169 +++++++++++++++++++++++ src/ui/rowFilterControls.test.mjs | 207 ++++++++++++++++++++++++++++ src/ui/styles/radio.css | 102 ++++++++++++++ 27 files changed, 1500 insertions(+), 70 deletions(-) create mode 100644 src/data/firmsIntensity.js create mode 100644 src/data/firmsIntensity.test.mjs create mode 100644 src/ui/rowFilterControls.js create mode 100644 src/ui/rowFilterControls.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ee23b8b6a..c7ae4e3cf 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b42d90fdc..ca2ecb542 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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" ], diff --git a/src/data/firmsCards.test.mjs b/src/data/firmsCards.test.mjs index a0ee83c01..f82b8da80 100644 --- a/src/data/firmsCards.test.mjs +++ b/src/data/firmsCards.test.mjs @@ -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', () => { diff --git a/src/data/firmsIntensity.js b/src/data/firmsIntensity.js new file mode 100644 index 000000000..417566346 --- /dev/null +++ b/src/data/firmsIntensity.js @@ -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} fires Internal records (normalized confidence). + * @param {{minFrp: number, confidence: string}} filter + * @returns {Array} + */ +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) + ); +} diff --git a/src/data/firmsIntensity.test.mjs b/src/data/firmsIntensity.test.mjs new file mode 100644 index 000000000..558068e67 --- /dev/null +++ b/src/data/firmsIntensity.test.mjs @@ -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' }), []); +}); diff --git a/src/data/firmsLabels.js b/src/data/firmsLabels.js index b9e9d1765..3af6a15a5 100644 --- a/src/data/firmsLabels.js +++ b/src/data/firmsLabels.js @@ -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]; } /** diff --git a/src/data/firmsLabels.test.mjs b/src/data/firmsLabels.test.mjs index 8573d7330..62234bb2d 100644 --- a/src/data/firmsLabels.test.mjs +++ b/src/data/firmsLabels.test.mjs @@ -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', () => { diff --git a/src/data/layerState.js b/src/data/layerState.js index 089c8c08b..c68a67e67 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -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', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index d05fb9a95..d343fe20f 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -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', + }); +}); diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 0285917b0..ffee7fb87 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b1c1be229..1cd88757d 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -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}" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9bf76700c..3044c45f8 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -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}" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 05f5d3dee..5f1b14f8a 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -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" diff --git a/src/layers/firms/ingestion.js b/src/layers/firms/ingestion.js index 494ab5f1e..215c35da3 100644 --- a/src/layers/firms/ingestion.js +++ b/src/layers/firms/ingestion.js @@ -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 }; } diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 7e0b42a00..1138504dd 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -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; diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 14b875bf8..a90066cc6 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -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, diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 673aab871..18ea88238 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -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), + }), +); diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 5f663cc97..220012133 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -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) => { diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 8ae445f91..757fd4cf7 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -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), }); } diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index 48ae11db2..d84ee2026 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -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; diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index 84ba8fbc1..18f64e0aa 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -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' }), diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index 2b11c639c..e671db426 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -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), diff --git a/src/tools/queries/hazards.test.mjs b/src/tools/queries/hazards.test.mjs index fa17158dd..c1d3eb43a 100644 --- a/src/tools/queries/hazards.test.mjs +++ b/src/tools/queries/hazards.test.mjs @@ -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\)/); +}); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 18b71dabf..dc6566848 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -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 }) => [ diff --git a/src/ui/rowFilterControls.js b/src/ui/rowFilterControls.js new file mode 100644 index 000000000..b0299d24b --- /dev/null +++ b/src/ui/rowFilterControls.js @@ -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; +} diff --git a/src/ui/rowFilterControls.test.mjs b/src/ui/rowFilterControls.test.mjs new file mode 100644 index 000000000..9cc8fb1d4 --- /dev/null +++ b/src/ui/rowFilterControls.test.mjs @@ -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; + } +}); diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 483321992..5047ae6f4 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -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; From 22a87500c6b3296da1003a4101ca0ed80ba1f992 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 18:30:58 +0400 Subject: [PATCH 02/24] docs(fires): current-state note and changelog fragment for the intensity filter (#1426) Describe the six-stop FRP ramp, the slider / confidence switch / counter, the single filter point, the share-token options and the tool arguments in docs/CURRENT-STATE.md, and add changelog.d/1426-fire-intensity-filter.md as CONTRIBUTING asks for runtime changes. Co-Authored-By: Claude Fable 5.1 --- changelog.d/1426-fire-intensity-filter.md | 8 ++++++++ docs/CURRENT-STATE.md | 24 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 changelog.d/1426-fire-intensity-filter.md diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md new file mode 100644 index 000000000..fad9051e9 --- /dev/null +++ b/changelog.d/1426-fire-intensity-filter.md @@ -0,0 +1,8 @@ +- Color every Active Fires detection by fire radiative power on a six-stop + cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · + 50 · 100 · 300 MW), and add a log-scale minimum-FRP slider whose track is the + ramp, an All / Nominal+ / High confidence switch and an "N of M shown" + counter to the layer row. The filter applies once before rendering, so + markers, cards, heat cells, context and the top-fires query agree; share + links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching + `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0cb46940c..61699bda9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2054,6 +2054,30 @@ Generated style sliders use the same name as their visible parameter label. The first-run suppression checkbox keeps its native wrapping label, so its accessible name remains "Don't show this again". Control behavior is unchanged. +## Active Fires intensity filter and color ramp + +Every FIRMS detection is colored by fire radiative power on a six-stop cold → +hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from +100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Heat +cells, glow sprites and card accents read the same ramp; confidence and +brightness do not affect the color. + +The Active Fires row carries a minimum-FRP slider on a log scale whose track is +painted with the ramp and ticked at the same six values, an All / Nominal+ / +High confidence switch, and an "N of M shown" counter. The default is 0 MW and +All, because many of the strongest detections are rated low confidence. The +filter runs once in `ingestion.applyFilter()` before rendering, so markers, +cards, heat cells, the shared context store, the top-fires query and the +analyst records all see one filtered set; a selected fire that no longer passes +is deselected. The layer state keeps both options under the `w` (`local-firms`) +share token as `f` (whole megawatts, 0–10,000) and `c` (`a`, `n`, `h`), +including the short share codec; a link without them restores the defaults. + +The `get_active_fires` tool accepts its own `min_frp_mw` and `confidence` +(`all`, `nominal`, `high`) arguments, independent of the panel's setting, and +names the applied criteria in its notes. Stale-snapshot and missing-source +reporting are unchanged. + ## FIRMS source status The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. From 1dad6b658a77b80465204cd10c5247cb4c2c084c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 18:50:03 +0400 Subject: [PATCH 03/24] feat(fires): caption the confidence switch and name the slider "Fire intensity (FRP)" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch had no visible word saying what ALL / NOMINAL+ / HIGH refer to, and "Min FRP" assumed the reader knows the acronym. The segmented row descriptor gains an optional visible `label` (a caption above the group); the fires row uses "Confidence" and "Fire intensity (FRP)", and the slider value reads "≥ N MW" so the threshold is still clear. Locales updated. Co-Authored-By: Claude Fable 5.1 --- docs/CURRENT-STATE.md | 7 ++++--- src/i18n/locales/es.json | 5 +++-- src/i18n/locales/ru.json | 5 +++-- src/i18n/locales/zh-CN.json | 5 +++-- src/i18n/same-as-english.json | 4 ++-- src/layers/firms/queries.js | 5 +++-- src/ui/rowFilterControls.js | 24 ++++++++++++++++++++++-- src/ui/rowFilterControls.test.mjs | 23 ++++++++++++++++++++--- src/ui/styles/radio.css | 10 ++++++++++ 9 files changed, 70 insertions(+), 18 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 61699bda9..2132a910f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2062,9 +2062,10 @@ hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from cells, glow sprites and card accents read the same ramp; confidence and brightness do not affect the color. -The Active Fires row carries a minimum-FRP slider on a log scale whose track is -painted with the ramp and ticked at the same six values, an All / Nominal+ / -High confidence switch, and an "N of M shown" counter. The default is 0 MW and +The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale +whose track is painted with the ramp and ticked at the same six values (its +value reads "All" or "≥ N MW"), a "Confidence" switch with All / Nominal+ / +High, and an "N of M shown" counter. The default is 0 MW and All, because many of the strongest detections are rated low confidence. The filter runs once in `ingestion.applyFilter()` before rendering, so markers, cards, heat cells, the shared context store, the top-fires query and the diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ffee7fb87..babea8bfb 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3008,9 +3008,10 @@ "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.", + "Fire intensity (FRP)": "Intensidad (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "Confianza", "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" diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 1cd88757d..6289ad38c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3077,9 +3077,10 @@ "Every detection — many of the strongest fires are rated low confidence": "Все детекции — у многих сильнейших пожаров достоверность низкая", "Nominal and high confidence detections": "Детекции средней и высокой достоверности", "High confidence detections only": "Только детекции высокой достоверности", - "Min FRP": "Мин. FRP", + "Fire intensity (FRP)": "Интенсивность (FRP)", + "≥ {value} MW": "≥ {value} МВт", + "Confidence": "Достоверность", "Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты", - "{value} MW": "{value} МВт", "fire intensity filter|All": "Все", "Detection confidence": "Достоверность детекции", "{shown} of {total} shown": "показано {shown} из {total}" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3044c45f8..110f7a77c 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2870,9 +2870,10 @@ "Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低", "Nominal and high confidence detections": "标称和高置信度的检测", "High confidence detections only": "仅高置信度的检测", - "Min FRP": "最小 FRP", + "Fire intensity (FRP)": "火势强度 (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "置信度", "Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)", - "{value} MW": "{value} MW", "fire intensity filter|All": "全部", "Detection confidence": "检测置信度", "{shown} of {total} shown": "显示 {shown} / {total}" diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 5f1b14f8a..5d0313965 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -121,7 +121,7 @@ "{value} KM", "{value} KM/H", "{value} KM/S", - "{value} MW", + "≥ {value} MW", "ΔE", "ΔN", "▲ {frp} MW" @@ -197,7 +197,7 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", - "{value} MW", + "≥ {value} MW", "ΔE", "ΔN", "▲ {frp} MW" diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 220012133..44ce8573d 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -135,11 +135,11 @@ export function createQueries({ return { slider: { id: 'min-frp', - label: t('Min FRP'), + label: t('Fire intensity (FRP)'), ariaLabel: t('Minimum fire radiative power, megawatts'), valueText: minFrp > 0 - ? t('{value} MW', { value: frpLabel(minFrp) }) + ? t('≥ {value} MW', { value: frpLabel(minFrp) }) : t('All', { context: 'fire intensity filter' }), min: 0, max: SLIDER_STEPS, @@ -156,6 +156,7 @@ export function createQueries({ }, segmented: { id: 'confidence', + label: t('Confidence'), ariaLabel: t('Detection confidence'), options: FIRMS_CONFIDENCE_MODES.map((mode) => ({ id: `confidence-${mode}`, diff --git a/src/ui/rowFilterControls.js b/src/ui/rowFilterControls.js index b0299d24b..87a3e0bc6 100644 --- a/src/ui/rowFilterControls.js +++ b/src/ui/rowFilterControls.js @@ -104,8 +104,9 @@ export function syncSliderBlock(container, slider, { before = null } = {}) { * 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 }] }`. + * Descriptor: `{ id, label, ariaLabel, options: [{ id, label, title, active, + * disabled, params }] }` — `label` is an optional visible caption above the + * buttons (the group itself is a flex item, so the caption is a sibling). * @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. @@ -121,19 +122,38 @@ export function syncSegmentedGroup( const existing = [...container.children].find( (node) => node.dataset?.segmentedId, ); + const existingCaption = [...container.children].find( + (node) => node.dataset?.segmentedCaptionFor, + ); if (!segmented) { existing?.remove(); + existingCaption?.remove(); return null; } let group = existing; if (!group || group.dataset.segmentedId !== segmented.id) { existing?.remove(); + existingCaption?.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); } + let caption = [...container.children].find( + (node) => node.dataset?.segmentedCaptionFor === segmented.id, + ); + if (segmented.label) { + if (!caption) { + caption = document.createElement('span'); + caption.className = 'data-toggle-segmented-label'; + caption.dataset.segmentedCaptionFor = segmented.id; + container.insertBefore(caption, group); + } + set(caption, 'textContent', segmented.label); + } else { + caption?.remove(); + } if (segmented.ariaLabel) attribute(group, 'aria-label', segmented.ariaLabel); const stale = new Map(); for (const node of [...group.children]) diff --git a/src/ui/rowFilterControls.test.mjs b/src/ui/rowFilterControls.test.mjs index 9cc8fb1d4..ac5d2c1d0 100644 --- a/src/ui/rowFilterControls.test.mjs +++ b/src/ui/rowFilterControls.test.mjs @@ -24,6 +24,7 @@ const sliderDescriptor = (overrides = {}) => ({ const segmentedDescriptor = (active = 'all') => ({ id: 'confidence', + label: 'Confidence', ariaLabel: 'Detection confidence', options: ['all', 'nominal', 'high'].map((mode) => ({ id: `confidence-${mode}`, @@ -96,6 +97,9 @@ test('the segmented group is a radiogroup whose active option reads checked, rec assert.equal(group.getAttribute('role'), 'radiogroup'); assert.equal(group.getAttribute('aria-label'), 'Detection confidence'); assert.ok(group.className.split(' ').includes('gev-segmented')); + const caption = container.children[container.children.indexOf(group) - 1]; + assert.equal(caption.className, 'data-toggle-segmented-label'); + assert.equal(caption.textContent, 'Confidence'); const buttons = group.children; assert.deepEqual( buttons.map((b) => [ @@ -118,10 +122,18 @@ test('the segmented group is a radiogroup whose active option reads checked, rec 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, 2, 'caption and group'); + + syncSegmentedGroup(container, { ...segmentedDescriptor('high'), label: '' }); + assert.equal( + container.querySelector('.data-toggle-segmented-label'), + null, + 'an empty label removes the caption', + ); assert.equal(container.children.length, 1); syncSegmentedGroup(container, null); - assert.equal(container.children.length, 0); + assert.equal(container.children.length, 0, 'null removes group and caption'); }); test('a layer row dispatches slider input and switch clicks as user params', async () => { @@ -163,10 +175,15 @@ test('a layer row dispatches slider input and switch clicks as user params', asy row.querySelector('.data-toggle-controls-info').textContent, '12 of 40 shown', ); - // Order: switch, slider, info. + // Order: switch caption, switch, slider, info. assert.deepEqual( container.children.map((node) => node.className.split(' ')[0]), - ['gev-segmented', 'data-toggle-slider', 'data-toggle-controls-info'], + [ + 'data-toggle-segmented-label', + 'gev-segmented', + 'data-toggle-slider', + 'data-toggle-controls-info', + ], ); const input = row.querySelector('.data-toggle-slider-input'); diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 5047ae6f4..e005c5288 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -1043,6 +1043,16 @@ html:not([lang='en']) .data-row-list-text { max-width: 220px; } +.data-toggle-segmented-label { + flex-basis: 100%; + padding-top: 2px; + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 1.2px; + color: var(--text-dim); + text-transform: uppercase; +} + .data-toggle-controls .data-toggle-segment { min-height: 20px; padding: 3px 6px; From 887b261195fe4a425e9cd8dfdd5027172b7c5342 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 19:19:40 +0400 Subject: [PATCH 04/24] fix(fires): paint aggregated heat cells warm by density, not by strongest FRP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coloring each far-zoom cell with the ramp stop of its strongest detection turned a burning region into a blue / teal / lime mosaic: neighbouring cells flipped hue on one outlier and the cold stops did not read as fire. Cells say where it burns, so they go back to the heat-normalized score (density, night share, max FRP) on the warm end of the ramp — lime, amber, red — with the cell card accent matching. Single detections keep the full six-stop FRP ramp. Co-Authored-By: Claude Fable 5.1 --- changelog.d/1426-fire-intensity-filter.md | 3 +- docs/CURRENT-STATE.md | 9 ++++-- src/layers/firms/model.js | 37 +++++++++++++++++------ src/layers/firms/rendering.js | 5 +-- 4 files changed, 39 insertions(+), 15 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index fad9051e9..09e3c3a7d 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -2,7 +2,8 @@ cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · 50 · 100 · 300 MW), and add a log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / High confidence switch and an "N of M shown" - counter to the layer row. The filter applies once before rendering, so + counter to the layer row. Far-zoom heat cells keep a warm density fill + (lime · amber · red). The filter applies once before rendering, so markers, cards, heat cells, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2132a910f..7e29d8675 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2058,9 +2058,12 @@ accessible name remains "Don't show this again". Control behavior is unchanged. Every FIRMS detection is colored by fire radiative power on a six-stop cold → hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from -100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Heat -cells, glow sprites and card accents read the same ramp; confidence and -brightness do not affect the color. +100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Glow +sprites and fire-card accents read that ramp; confidence and brightness do +not affect the color. Aggregated heat cells (far zoom) instead take the warm +end of the ramp — lime, amber or red — from their heat-normalized score +(density, night share and strongest FRP), so a region reads as one field +rather than a per-cell mosaic; the cell card's accent matches its fill. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale whose track is painted with the ramp and ticked at the same six values (its diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index a90066cc6..83f573c98 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -137,15 +137,33 @@ export function createModel({ } /** - * 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. + * Severity stop for an aggregated heat cell from its heat-normalized score + * (density, night share and strongest FRP together). Cells say *where* it + * burns, so they stay on the warm end of the ramp — lime, amber, red — and + * read as one field instead of a per-cell mosaic; the cold stops belong to + * single detections, which say *how hard* each one burns. + * @param {number} normalized - 0..1 heat score (sqrt-normalized to the view). + * @returns {{name: string, minFrp: number, color: Cesium.Color}} + */ + + function cellColorStop(normalized) { + const name = + normalized > 0.72 ? 'red' : normalized > 0.42 ? 'amber' : 'lime'; + return ( + DETECTION_COLOR_STOPS.find((stop) => stop.name === name) || + DETECTION_COLOR_STOPS[0] + ); + } + + /** + * Fill color for an aggregated heat cell. + * @param {number} normalized - 0..1 heat score (see {@link cellColorStop}). * @param {number} alpha - Fill alpha (heat-normalized by the caller). * @returns {Cesium.Color} */ - function heatColor(maxFrp, alpha) { - return frpColorStop(maxFrp).color.withAlpha(alpha); + function heatColor(normalized, alpha) { + return cellColorStop(normalized).color.withAlpha(alpha); } /** @@ -476,14 +494,14 @@ export function createModel({ } /** - * Cell card accent: the ramp stop of the cell's strongest detection, + * Cell card accent: the warm severity stop of the cell's heat score, * mirroring heatColor. - * @param {number} maxFrp - Strongest FRP in the cell, megawatts. + * @param {number} normalized - 0..1 heat score. * @returns {string} "r, g, b" accent string. */ - function cellAccent(maxFrp) { - return accentForSeverity(frpColorStop(maxFrp).name); + function cellAccent(normalized) { + return accentForSeverity(cellColorStop(normalized).name); } /** Coordinate line like "30.512°N 75.831°E". */ @@ -590,6 +608,7 @@ export function createModel({ cellIntersectsBounds, heatScore, heatColor, + cellColorStop, frpColorStop, detectionColorStop, frpPixelSize, diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 757fd4cf7..8ae445f91 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -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(cell.maxFrp, alpha); + const color = components.model.heatColor(normalized, alpha); const centerLon = cell.lonCell + lod.gridDegrees / 2; const centerLat = cell.latCell + lod.gridDegrees / 2; const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0); @@ -170,12 +170,13 @@ 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(cell.maxFrp), + accent: components.model.cellAccent(normalized), }); } From b778a43f1d7538a10d29f89b0611c29e3dabfe6e Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:05:14 +0400 Subject: [PATCH 05/24] feat(fires): Snap Map-style heat raster and its globe imagery drape Detections are binned on a 2048x1024 equirectangular grid weighted 1 + log10(1 + FRP), Gaussian-blurred (longitude wraps), normalized on a log scale against the 99.5th percentile and mapped blue -> red with an alpha ramp that is fully transparent below the floor. heatImagery drapes the bytes as one full-globe SingleTileImageryProvider on the active imagery host. Co-Authored-By: Claude Opus 5.5 --- src/data/firmsHeatRaster.js | 176 ++++++++++++++++++++++++++ src/data/firmsHeatRaster.test.mjs | 73 +++++++++++ src/layers/firms/heatImagery.js | 71 +++++++++++ src/layers/firms/heatImagery.test.mjs | 87 +++++++++++++ 4 files changed, 407 insertions(+) create mode 100644 src/data/firmsHeatRaster.js create mode 100644 src/data/firmsHeatRaster.test.mjs create mode 100644 src/layers/firms/heatImagery.js create mode 100644 src/layers/firms/heatImagery.test.mjs diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js new file mode 100644 index 000000000..52a020671 --- /dev/null +++ b/src/data/firmsHeatRaster.js @@ -0,0 +1,176 @@ +/** + * Snap Map–style heat raster for the Active Fires far-zoom bands: detections + * are binned on an equirectangular grid (row 0 = north, column 0 = 180°W), + * blurred, normalized on a log scale and mapped through HEAT_PALETTE. Pure: + * no Cesium, no DOM, so the whole pipeline is unit-tested in node. + */ + +/** Below this normalized heat nothing is painted: empty land stays empty. */ +export const HEAT_FLOOR = 0.03; + +export const HEAT_PALETTE = Object.freeze([ + { at: 0.0, rgb: [43, 108, 255], alpha: 0 }, + { at: 0.08, rgb: [43, 108, 255], alpha: 0.35 }, + { at: 0.25, rgb: [25, 195, 255], alpha: 0.55 }, + { at: 0.45, rgb: [47, 224, 122], alpha: 0.65 }, + { at: 0.65, rgb: [245, 230, 66], alpha: 0.75 }, + { at: 0.82, rgb: [255, 138, 31], alpha: 0.82 }, + { at: 1.0, rgb: [255, 42, 26], alpha: 0.88 }, +]); + +export function heatColorAt(t) { + if (!(t > HEAT_FLOOR)) return [0, 0, 0, 0]; + const v = Math.min(1, t); + let i = 1; + while (i < HEAT_PALETTE.length - 1 && HEAT_PALETTE[i].at < v) i += 1; + const a = HEAT_PALETTE[i - 1]; + const b = HEAT_PALETTE[i]; + const k = (v - a.at) / (b.at - a.at || 1); + const mix = (p, q) => Math.round(p + (q - p) * k); + return [ + mix(a.rgb[0], b.rgb[0]), + mix(a.rgb[1], b.rgb[1]), + mix(a.rgb[2], b.rgb[2]), + Math.round((a.alpha + (b.alpha - a.alpha) * k) * 255), + ]; +} + +const defaultWeight = (fire) => + 1 + Math.log10(1 + Math.max(0, Number(fire.frp) || 0)); + +export function binFires(fires, { width, height, weight = defaultWeight }) { + const grid = new Float32Array(width * height); + for (const fire of fires) { + if (!Number.isFinite(fire.lat) || !Number.isFinite(fire.lon)) continue; + const x = Math.min( + width - 1, + Math.max(0, Math.floor(((fire.lon + 180) / 360) * width)), + ); + const y = Math.min( + height - 1, + Math.max(0, Math.floor(((90 - fire.lat) / 180) * height)), + ); + grid[y * width + x] += weight(fire); + } + return grid; +} + +function kernel(sigma) { + const radius = Math.max(1, Math.ceil(sigma * 3)); + const k = new Float32Array(radius * 2 + 1); + let sum = 0; + for (let i = -radius; i <= radius; i++) { + k[i + radius] = Math.exp(-(i * i) / (2 * sigma * sigma)); + sum += k[i + radius]; + } + for (let i = 0; i < k.length; i++) k[i] /= sum; + return { k, radius }; +} + +export function blurGaussian(grid, width, height, sigma) { + const { k, radius } = kernel(sigma); + const tmp = new Float32Array(grid.length); + const out = new Float32Array(grid.length); + for (let y = 0; y < height; y++) { + const row = y * width; + for (let x = 0; x < width; x++) { + const v = grid[row + x]; + if (v === 0) continue; + for (let i = -radius; i <= radius; i++) { + const xx = (((x + i) % width) + width) % width; // longitude wraps + tmp[row + xx] += v * k[i + radius]; + } + } + } + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const v = tmp[y * width + x]; + if (v === 0) continue; + for (let i = -radius; i <= radius; i++) { + const yy = Math.min(height - 1, Math.max(0, y + i)); // poles clamp + out[yy * width + x] += v * k[i + radius]; + } + } + } + return out; +} + +/** Bins of the log-density histogram used to find the reference quantile. */ +const QUANTILE_BINS = 4096; + +/** + * The p-quantile of the non-zero values, from a histogram of log1p(value): + * one linear pass instead of sorting ~350k burning pixels (a sort cost + * 200 ms+ and dominated the build). Bin width on the log axis is + * max/4096, far finer than the 256-step palette it feeds. + */ +function percentileOfNonZero(values, p) { + let count = 0; + let max = 0; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + if (v > 0) { + count += 1; + if (v > max) max = v; + } + } + if (!count) return 0; + const logMax = Math.log1p(max); + const bins = new Uint32Array(QUANTILE_BINS); + const toBin = (QUANTILE_BINS - 1) / logMax; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + if (v > 0) bins[Math.floor(Math.log1p(v) * toBin)] += 1; + } + const target = Math.floor(p * (count - 1)); + let seen = 0; + for (let b = 0; b < QUANTILE_BINS; b++) { + seen += bins[b]; + if (seen > target) return Math.expm1((b + 1) / toBin); + } + return max; +} + +/** HEAT_PALETTE sampled at 256 steps, so the per-pixel walk is one lookup. */ +const LUT_STEPS = 256; +let paletteLut = null; +function heatLut() { + if (!paletteLut) { + paletteLut = new Uint8ClampedArray(LUT_STEPS * 4); + for (let i = 0; i < LUT_STEPS; i++) { + paletteLut.set(heatColorAt(i / (LUT_STEPS - 1)), i * 4); + } + } + return paletteLut; +} + +export function buildHeatRaster( + fires, + { width, height, sigmaPx, percentile = 0.995 }, +) { + const density = blurGaussian( + binFires(fires, { width, height }), + width, + height, + sigmaPx, + ); + const reference = percentileOfNonZero(density, percentile); + const rgba = new Uint8ClampedArray(width * height * 4); + if (reference > 0) { + const lut = heatLut(); + const scale = (LUT_STEPS - 1) / Math.log1p(reference); + for (let i = 0; i < density.length; i++) { + const d = density[i]; + if (d <= 0) continue; + const step = Math.min(LUT_STEPS - 1, Math.round(Math.log1p(d) * scale)); + const l = step * 4; + if (lut[l + 3] === 0) continue; + const o = i * 4; + rgba[o] = lut[l]; + rgba[o + 1] = lut[l + 1]; + rgba[o + 2] = lut[l + 2]; + rgba[o + 3] = lut[l + 3]; + } + } + return { width, height, rgba, reference }; +} diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs new file mode 100644 index 000000000..704c83427 --- /dev/null +++ b/src/data/firmsHeatRaster.test.mjs @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + HEAT_PALETTE, + heatColorAt, + binFires, + blurGaussian, + buildHeatRaster, +} from './firmsHeatRaster.js'; + +const fire = (lat, lon, frp = 10) => ({ lat, lon, frp }); + +test('palette runs cold to hot and starts fully transparent', () => { + assert.equal(HEAT_PALETTE[0].at, 0); + assert.equal(HEAT_PALETTE.at(-1).at, 1); + assert.deepEqual(heatColorAt(0), [0, 0, 0, 0]); + assert.equal(heatColorAt(0.01)[3], 0, 'below the floor nothing is painted'); + const low = heatColorAt(0.15); + const high = heatColorAt(1); + assert.ok(low[2] > low[0], 'low heat is blue'); + assert.ok(high[0] > high[2], 'the core is red'); + assert.ok(high[3] > low[3], 'hotter is more opaque'); +}); + +test('binFires drops each detection into its equirectangular pixel', () => { + const grid = binFires([fire(89.9, -179.9, 0), fire(-89.9, 179.9, 0)], { + width: 4, + height: 2, + }); + assert.equal(grid[0], 1, 'north-west corner'); + assert.equal(grid[7], 1, 'south-east corner'); + assert.equal( + grid.reduce((a, b) => a + b, 0), + 2, + ); +}); + +test('binFires weights strong fires slightly more', () => { + const grid = binFires([fire(0.1, 0.1, 999)], { width: 4, height: 2 }); + assert.ok(grid[2] > 3 && grid[2] < 5, String(grid[2])); +}); + +test('blurGaussian keeps mass and wraps across the antimeridian', () => { + const w = 16; + const h = 8; + const grid = new Float32Array(w * h); + grid[3 * w + 0] = 1; + const out = blurGaussian(grid, w, h, 1.5); + const sum = out.reduce((a, b) => a + b, 0); + assert.ok(Math.abs(sum - 1) < 1e-3, `mass ${sum}`); + assert.ok(out[3 * w + (w - 1)] > 0, 'spills to the east edge'); +}); + +test('buildHeatRaster is transparent where there are no fires', () => { + const r = buildHeatRaster([fire(40, -120), fire(40.1, -120.1)], { + width: 360, + height: 180, + sigmaPx: 2, + }); + assert.equal(r.rgba.length, 360 * 180 * 4); + const alphaAt = (lat, lon) => { + const x = Math.floor(((lon + 180) / 360) * 360); + const y = Math.floor(((90 - lat) / 180) * 180); + return r.rgba[(y * 360 + x) * 4 + 3]; + }; + assert.ok(alphaAt(40, -120) > 0, 'blob over the fires'); + assert.equal(alphaAt(-30, 60), 0, 'empty far away'); +}); + +test('buildHeatRaster with no fires is fully transparent', () => { + const r = buildHeatRaster([], { width: 8, height: 4, sigmaPx: 1 }); + assert.ok(r.rgba.every((v) => v === 0)); +}); diff --git a/src/layers/firms/heatImagery.js b/src/layers/firms/heatImagery.js new file mode 100644 index 000000000..f2f99fbe6 --- /dev/null +++ b/src/layers/firms/heatImagery.js @@ -0,0 +1,71 @@ +/** + * Drapes the Active Fires heat raster over the globe as one full-extent + * imagery tile on the active imagery host (globe or 3D-tiles), like the wind + * layer's scalar field. Owns the canvas and the imagery layer; nothing else + * in the fires layer touches imagery. + */ + +const defaultCanvas = (width, height) => { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + return canvas; +}; + +export function createHeatImagery({ + cesium, + getHost, + createCanvas = defaultCanvas, +}) { + let raster = null; + let layer = null; + let collection = null; + let show = true; + + function detach() { + if (layer && collection) collection.remove(layer, true); + layer = null; + collection = null; + } + + function attach() { + detach(); + if (!raster) return; + const host = getHost?.() ?? { collection: null, kind: 'none' }; + if (!host.collection) return; + const canvas = createCanvas(raster.width, raster.height); + const ctx = canvas.getContext('2d'); + const image = ctx.createImageData(raster.width, raster.height); + image.data.set(raster.rgba); + ctx.putImageData(image, 0, 0); + const provider = new cesium.SingleTileImageryProvider({ + url: canvas.toDataURL('image/png'), + tileWidth: raster.width, + tileHeight: raster.height, + rectangle: cesium.Rectangle.MAX_VALUE, + }); + collection = host.collection; + layer = collection.addImageryProvider(provider); + layer.show = show; + collection.raiseToTop?.(layer); + } + + return { + setRaster(next) { + raster = next; + attach(); + }, + setShow(value) { + show = Boolean(value); + if (layer) layer.show = show; + }, + rehome() { + const host = getHost?.(); + if ((host?.collection ?? null) !== collection) attach(); + }, + destroy() { + detach(); + raster = null; + }, + }; +} diff --git a/src/layers/firms/heatImagery.test.mjs b/src/layers/firms/heatImagery.test.mjs new file mode 100644 index 000000000..0fca7b97f --- /dev/null +++ b/src/layers/firms/heatImagery.test.mjs @@ -0,0 +1,87 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHeatImagery } from './heatImagery.js'; + +function harness() { + const layers = []; + const removed = []; + const collection = { + addImageryProvider(provider) { + const layer = { provider, show: true, alpha: 1 }; + layers.push(layer); + return layer; + }, + remove(layer, destroy) { + layers.splice(layers.indexOf(layer), 1); + removed.push({ layer, destroy }); + }, + raiseToTop() {}, + }; + let host = { collection, kind: 'globe' }; + const cesium = { + Rectangle: { MAX_VALUE: { max: true } }, + SingleTileImageryProvider: class { + constructor(options) { + this.options = options; + } + }, + }; + const createCanvas = (w, h) => ({ + width: w, + height: h, + getContext: () => ({ + createImageData: (cw, ch) => ({ + data: new Uint8ClampedArray(cw * ch * 4), + }), + putImageData() {}, + }), + toDataURL: () => 'data:image/png;base64,AAAA', + }); + const imagery = createHeatImagery({ + cesium, + getHost: () => host, + createCanvas, + }); + return { imagery, layers, removed, collection, setHost: (h) => (host = h) }; +} + +const raster = { width: 4, height: 2, rgba: new Uint8ClampedArray(32) }; + +test('setRaster drapes one full-globe tile and replaces it on the next build', () => { + const h = harness(); + h.imagery.setRaster(raster); + assert.equal(h.layers.length, 1); + assert.deepEqual(h.layers[0].provider.options.rectangle, { max: true }); + assert.equal(h.layers[0].provider.options.tileWidth, 4); + h.imagery.setRaster(raster); + assert.equal(h.layers.length, 1, 'never two heat layers'); + assert.equal(h.removed[0].destroy, true); +}); + +test('setShow toggles without rebuilding', () => { + const h = harness(); + h.imagery.setRaster(raster); + const layer = h.layers[0]; + h.imagery.setShow(false); + assert.equal(layer.show, false); + h.imagery.setShow(true); + assert.equal(h.layers[0], layer); + assert.equal(layer.show, true); +}); + +test('no imagery host keeps nothing draped; rehome brings it back', () => { + const h = harness(); + h.setHost({ collection: null, kind: 'none' }); + h.imagery.setRaster(raster); + assert.equal(h.layers.length, 0); + h.setHost({ collection: h.collection, kind: 'globe' }); + h.imagery.rehome(); + assert.equal(h.layers.length, 1); +}); + +test('destroy removes the layer', () => { + const h = harness(); + h.imagery.setRaster(raster); + h.imagery.destroy(); + assert.equal(h.layers.length, 0); +}); From de87aaf86acfbf3fd358833c6478e93d9718d3f8 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:11:32 +0400 Subject: [PATCH 06/24] feat(fires): far zoom shows a Snap Map-style heatmap instead of heat squares The global and regional LOD bands drape the heat raster on the active imagery host and draw no rectangle entities; the 2/1 degree aggregation now only feeds the "N FIRES" cards, whose accent is the strongest fire's FRP color. Markers below ~750 km hide the heatmap, as do disable and destroy. A stale raster is rebuilt immediately on first paint and with a 150 ms trailing debounce afterwards, so a slider drag does not re-encode on every step. The shell hands the layer its imagery host, so the heatmap follows globe and 3D-tiles map stacks. Co-Authored-By: Claude Opus 5.5 --- src/data/firmsInteraction.test.mjs | 75 ++++++++++++++++++ src/layers/firms/heatImagery.js | 4 + src/layers/firms/ingestion.js | 7 +- src/layers/firms/lifecycle.js | 24 ++++++ src/layers/firms/model.js | 48 ++---------- src/layers/firms/policy.js | 32 ++++++-- src/layers/firms/queries.js | 15 +++- src/layers/firms/rendering.js | 119 +++++++++++++++++++---------- src/layers/firms/state.js | 19 +++++ src/ui/layerBindings.js | 6 ++ 10 files changed, 257 insertions(+), 92 deletions(-) diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index d735df3f8..39f0382c8 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -392,3 +392,78 @@ test('disable cancels a pending source and a late response cannot repopulate the assert.equal(h.layer.getStats().loading, false); } finally { h.layer.destroy(h.viewer); h.cleanup(); } }); + +test('far zoom drapes one heatmap and draws no rectangles; markers and disable hide it', () => { + // A canvas stub for the heat drape and the detection glow sprites. + const priorDocument = globalThis.document; + const restoreDocument = () => { + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + }; + globalThis.document = { + createElement: () => ({ + getContext: () => ({ + createImageData: (w, hh) => ({ + data: new Uint8ClampedArray(w * hh * 4), + }), + putImageData() {}, + createRadialGradient: () => ({ addColorStop() {} }), + fillRect() {}, + }), + toDataURL: () => 'data:image/png;base64,AAAA', + }), + }; + const h = harness({ + fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })], + withDataSource: true, + cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000), + }); + const added = []; + const removed = []; + h.viewer.scene.primitives = { + add() {}, + remove() {}, + raiseToTop() {}, + contains: () => true, + }; + h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D }; + try { + const collection = { + addImageryProvider: (provider) => { + const layer = { provider, show: true }; + added.push(layer); + return layer; + }, + remove: (layer, destroy) => removed.push({ layer, destroy }), + raiseToTop() {}, + }; + h.layer.attachShellServices({ + imageryHost: () => ({ kind: 'globe', collection }), + }); + h.layer._renderLodForTest('global'); + assert.equal(added.length, 1, 'one heat imagery layer'); + assert.equal(added[0].show, true); + assert.equal(h.layer._entityCountForTest(), 0, 'no rectangle entities'); + assert.ok(h.published.length > 0, 'the far band still paints cards'); + + h.layer._renderLodForTest('regional'); + assert.equal(added.length, 1, 'switching far bands reuses the raster'); + + h.layer._renderLodForTest('close'); + assert.equal(added[0].show, false, 'the markers band hides the heatmap'); + + h.layer._renderLodForTest('global'); + assert.equal(added[0].show, true); + h.layer.disable(); + assert.equal(added[0].show, false, 'disable hides the heatmap'); + + h.layer.destroy(h.viewer); + assert.deepEqual( + removed.map((entry) => [entry.layer, entry.destroy]), + [[added[0], true]], + ); + } finally { + h.cleanup(); + restoreDocument(); + } +}); diff --git a/src/layers/firms/heatImagery.js b/src/layers/firms/heatImagery.js index f2f99fbe6..31fbf1f69 100644 --- a/src/layers/firms/heatImagery.js +++ b/src/layers/firms/heatImagery.js @@ -55,6 +55,10 @@ export function createHeatImagery({ raster = next; attach(); }, + /** True once a raster was handed over (painted or waiting for a host). */ + hasRaster() { + return raster !== null; + }, setShow(value) { show = Boolean(value); if (layer) layer.show = show; diff --git a/src/layers/firms/ingestion.js b/src/layers/firms/ingestion.js index 215c35da3..cb9dbd72f 100644 --- a/src/layers/firms/ingestion.js +++ b/src/layers/firms/ingestion.js @@ -89,14 +89,15 @@ 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. + * the heatmap, the context store, the top-fires query and the analyst + * records, so none of them can disagree about which fires exist. Cells and + * the heat raster are built per shown set, so their caches go with it. */ function applyFilter() { layerState._fires = filterFires(layerState._allFires, layerState._filter); layerState._cellCacheByGrid.clear(); + layerState._heatRaster = null; layerState._firesByFrp = filterFires( layerState._allFiresByFrp, layerState._filter, diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 1138504dd..72434ad46 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; +import { LOD_LEVELS } from './policy.js'; export function createLifecycle({ layerState, @@ -14,6 +15,20 @@ export function createLifecycle({ const { id, overlayHost } = config; const methods = { + /** + * Shell services: `imageryHost()` resolves where draped imagery goes on + * the active map stack (globe or 3D tiles, src/maps/imageryHost.js). The + * heatmap moves with it; without a host it drapes on viewer.imageryLayers. + * @param {{imageryHost?: () => {collection: ?Object, kind: string}}|null} services + */ + attachShellServices(services) { + layerState._imageryHost = + typeof services?.imageryHost === 'function' + ? services.imageryHost + : null; + layerState._heatImagery?.rehome(); + }, + init(viewer) { if (layerState._destroyed) return; layerState._viewer = viewer; @@ -40,6 +55,8 @@ export function createLifecycle({ layerState._billboards.show = true; } overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true); + if (LOD_LEVELS[layerState._currentLodIndex]?.mode === 'heat') + layerState._heatImagery?.setShow(true); components.viewport.installLodWatcher(); components.viewport.installMoveEndWatcher(); components.selection.installClickHandler(); @@ -64,6 +81,7 @@ export function createLifecycle({ components.selection.clearFireSelection(); if (layerState._dataSource) layerState._dataSource.show = false; if (layerState._billboards) layerState._billboards.show = false; + layerState._heatImagery?.setShow(false); overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID); overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); clearSelectedEntityContextForLayer(id); @@ -96,6 +114,12 @@ export function createLifecycle({ layerState._fires = []; layerState._firesByFrp = []; layerState._cellCacheByGrid.clear(); + clearTimeout(layerState._heatRebuildTimer); + layerState._heatRebuildTimer = null; + layerState._heatImagery?.destroy(); + layerState._heatImagery = null; + layerState._heatRaster = null; + layerState._imageryHost = null; layerState._count = 0; layerState._totalCount = 0; layerState._rowControlsListener = null; diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 83f573c98..c3d3be71a 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -136,36 +136,6 @@ export function createModel({ ); } - /** - * Severity stop for an aggregated heat cell from its heat-normalized score - * (density, night share and strongest FRP together). Cells say *where* it - * burns, so they stay on the warm end of the ramp — lime, amber, red — and - * read as one field instead of a per-cell mosaic; the cold stops belong to - * single detections, which say *how hard* each one burns. - * @param {number} normalized - 0..1 heat score (sqrt-normalized to the view). - * @returns {{name: string, minFrp: number, color: Cesium.Color}} - */ - - function cellColorStop(normalized) { - const name = - normalized > 0.72 ? 'red' : normalized > 0.42 ? 'amber' : 'lime'; - return ( - DETECTION_COLOR_STOPS.find((stop) => stop.name === name) || - DETECTION_COLOR_STOPS[0] - ); - } - - /** - * Fill color for an aggregated heat cell. - * @param {number} normalized - 0..1 heat score (see {@link cellColorStop}). - * @param {number} alpha - Fill alpha (heat-normalized by the caller). - * @returns {Cesium.Color} - */ - - function heatColor(normalized, alpha) { - return cellColorStop(normalized).color.withAlpha(alpha); - } - /** * Pick a sprite color stop for one detection from its FRP on the shared ramp. * @param {Object} fire - Detection record. @@ -432,8 +402,8 @@ export function createModel({ /** * Card model for an aggregated heat cell, e.g. title "14 FIRES", detail - * "max 210 MW · new 3h". Accent comes from the candidate (heat-normalized - * score is only known at renderCells time). Exported for unit tests. + * "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets + * it with {@link cellCardAccent}). Exported for unit tests. * @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate * @param {number} nowMs - Current epoch milliseconds. * @returns {Object} firmsLabels entry. @@ -494,14 +464,14 @@ export function createModel({ } /** - * Cell card accent: the warm severity stop of the cell's heat score, - * mirroring heatColor. - * @param {number} normalized - 0..1 heat score. + * Cell card accent: the cell's strongest fire on the FRP ramp, the same + * color its markers take once the camera is close enough to show them. + * @param {{maxFrp?: number}} cell - Aggregated cell. * @returns {string} "r, g, b" accent string. */ - function cellAccent(normalized) { - return accentForSeverity(cellColorStop(normalized).name); + function cellCardAccent(cell) { + return accentForSeverity(frpColorStop(cell?.maxFrp ?? 0).name); } /** Coordinate line like "30.512°N 75.831°E". */ @@ -607,8 +577,6 @@ export function createModel({ boundsContainPoint, cellIntersectsBounds, heatScore, - heatColor, - cellColorStop, frpColorStop, detectionColorStop, frpPixelSize, @@ -623,7 +591,7 @@ export function createModel({ buildFireCard, buildCellCard, applyFirmsOverlayPolicy, - cellAccent, + cellCardAccent, formatLatLon, formatFrp, formatAge, diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 18ea88238..eb235bc81 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -7,9 +7,10 @@ import * as Cesium from 'cesium'; export const REFRESH_INTERVAL_MS = 600_000; /** - * LOD bands keyed by camera height. `cells` bands render aggregated - * ground-clamped heat rectangles; `detections` bands (< ~750km plus the - * local band) render individual fire detections as glow-sprite billboards. + * LOD bands keyed by camera height. `heat` bands (above ~3,000 km) drape a + * Snap Map–style heatmap over the globe (firmsHeatRaster.js); their + * `gridDegrees` aggregation only feeds the "N FIRES" cards. `detections` + * bands render individual fire detections as glow-sprite billboards. * Labels in every band go through the shared screen-space greedy declutter * (hard cap MAX_AMBIENT_LABELS) — there are no per-band label knobs. */ @@ -18,7 +19,7 @@ export const LOD_LEVELS = [ { id: 'global', minHeight: 9000000, - mode: 'cells', + mode: 'heat', gridDegrees: 2.0, maxCells: 1800, labelDistance: 12000000, @@ -26,7 +27,7 @@ export const LOD_LEVELS = [ { id: 'regional', minHeight: 3000000, - mode: 'cells', + mode: 'heat', gridDegrees: 1.0, maxCells: 3600, labelDistance: 8500000, @@ -47,6 +48,25 @@ export const LOD_LEVELS = [ }, ]; +/** + * Global heat raster for the `heat` bands: ≈0.18° per pixel, Gaussian σ in + * pixels. Built once per data refresh or filter change, never per camera move. + */ + +export const HEAT_RASTER = Object.freeze({ + width: 2048, + height: 1024, + sigmaPx: 3, +}); + +/** + * Trailing delay before a stale heatmap is rebuilt while one is already on + * screen: a slider drag fires many filter changes, and a build plus PNG + * encode costs a few hundred milliseconds. The first paint is immediate. + */ + +export const HEAT_REBUILD_MS = 150; + export const LOD_CHECK_MS = 650; /** +/-10% hysteresis on LOD band edges so slow zooms don't thrash rebuilds. */ @@ -90,7 +110,7 @@ export const CULL_LIFT_THRESHOLD_M = 10; export const CULL_LIFT_M = 12; /** - * Color stops shared by cell heat fills and detection glow sprites: the + * Color stops for detection glow sprites and card accents: 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. */ diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 44ce8573d..797734597 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -9,7 +9,7 @@ import { sliderPositionForFrp, } from '../../data/firmsIntensity.js'; import { formatNumber, t } from '../../i18n/index.js'; -import { REFRESH_INTERVAL_MS } from './policy.js'; +import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js'; /** Slider resolution: whole-percent positions along the log axis. */ const SLIDER_STEPS = 1000; @@ -309,6 +309,19 @@ export function createQueries({ return result; }, + /** Test seam: render one named LOD band against a near-global view. */ + _renderLodForTest(lodId) { + const lod = LOD_LEVELS.find((level) => level.id === lodId); + if (lod.mode === 'detections') + components.rendering.renderDetections(lod, null); + else components.rendering.renderHeat(lod, null); + }, + + /** Test seam: entities drawn by the layer's data source. */ + _entityCountForTest() { + return layerState._dataSource?.entities?.values?.length ?? 0; + }, + /** Test seam that binds the production click path and indexes. */ _bindInteractionForTest(viewer, fires = []) { layerState._viewer = viewer; diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 8ae445f91..84a6eed36 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -1,7 +1,14 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; import { horizonOccluder } from '../../data/iconOrientation.js'; -import { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js'; +import { buildHeatRaster } from '../../data/firmsHeatRaster.js'; +import { + LOD_LEVELS, + CONTEXT_TOP_N, + HEAT_RASTER, + HEAT_REBUILD_MS, +} from './policy.js'; +import { createHeatImagery } from './heatImagery.js'; export function createRendering({ layerState, @@ -29,8 +36,11 @@ export function createRendering({ !layerState._dataSource || !layerState._fires.length || !layerState._viewer - ) + ) { + // A filter that leaves nothing shown must not leave the old heat behind. + if (!layerState._fires.length) layerState._heatImagery?.setShow(false); return false; + } const lodIndex = components.viewport.selectLodIndex( components.viewport.cameraHeight(), ); @@ -54,7 +64,7 @@ export function createRendering({ if (lod.mode === 'detections') { renderDetections(lod, bounds); } else { - renderCells(aggregateFires(lod, bounds), lod, bounds); + renderHeat(lod, bounds); } return true; } @@ -118,65 +128,89 @@ export function createRendering({ return clipped; } + /** The heat imagery adapter, created on first use. */ + + function heatImagery() { + if (!layerState._heatImagery) { + layerState._heatImagery = createHeatImagery({ + cesium: Cesium, + getHost: () => + layerState._imageryHost?.() ?? { + collection: layerState._viewer?.imageryLayers ?? null, + kind: 'globe', + }, + }); + } + return layerState._heatImagery; + } + + /** Build the heat raster from the shown set and drape it. */ + + function rebuildHeatRaster() { + clearTimeout(layerState._heatRebuildTimer); + layerState._heatRebuildTimer = null; + if (layerState._destroyed) return; + layerState._heatRaster = buildHeatRaster(layerState._fires, HEAT_RASTER); + heatImagery().setRaster(layerState._heatRaster); + } + /** - * Render aggregated heat cells (global/regional bands) as ground-clamped - * rectangles. Cell labels do NOT live on the entities anymore — they go - * through the same unclamped, screen-space-decluttered label pipeline as - * detections (see {@link rebuildAmbientLabels}). - * @param {Array} cells - Aggregated cells, heat-sorted descending. + * Make sure the draped heatmap matches the shown set. The first paint is + * immediate; later rebuilds (filter drags, refreshes) trail by + * HEAT_REBUILD_MS and keep the previous heatmap on screen meanwhile. + */ + + function ensureHeatRaster() { + if (layerState._heatRaster) return; + if (!heatImagery().hasRaster()) { + rebuildHeatRaster(); + return; + } + clearTimeout(layerState._heatRebuildTimer); + layerState._heatRebuildTimer = setTimeout( + rebuildHeatRaster, + HEAT_REBUILD_MS, + ); + } + + /** + * Far bands (global/regional): a Snap Map–style heatmap draped over the + * globe, transparent where nothing burns, plus the decluttered "N FIRES" + * cards from the coarse aggregation. No entities and no sprites are drawn; + * cards go through the same screen-space label pipeline as detections + * (see {@link rebuildAmbientLabels}). * @param {Object} lod - Active LOD descriptor. * @param {?Object} bounds - Padded view bounds in degrees, or null. */ - function renderCells(cells, lod, bounds) { + function renderHeat(lod, bounds) { ensureDetectionCollections(); layerState._dataSource.entities.removeAll(); if (layerState._billboards) layerState._billboards.removeAll(); layerState._pickIndexById.clear(); layerState._cullPositions.length = 0; - layerState._cellCount = cells.length; - const maxScore = Math.max(1, ...cells.map(components.model.heatScore)); + const imagery = heatImagery(); + // The map stack may have changed since the last far render. + imagery.rehome(); + ensureHeatRaster(); + imagery.setShow(layerState._enabled); + + const cells = aggregateFires(lod, bounds); + layerState._cellCount = cells.length; layerState._labelCandidates = []; layerState._labelLodDistance = lod.labelDistance; + // Cells arrive heat-sorted, so candidate order doubles as label priority. for (const cell of cells) { - 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 centerLon = cell.lonCell + lod.gridDegrees / 2; const centerLat = cell.latCell + lod.gridDegrees / 2; - const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0); - - layerState._dataSource.entities.add({ - rectangle: { - coordinates: Cesium.Rectangle.fromDegrees( - cell.lonCell, - cell.latCell, - cell.lonCell + lod.gridDegrees, - cell.latCell + lod.gridDegrees, - ), - material: new Cesium.ColorMaterialProperty(color), - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - }, - position, - properties: { - count: cell.count, - intensity: score, - maxFrp: cell.maxFrp, - night: cell.night, - }, - }); - - // 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, + position: Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0), cullPosition: components.model.cellCullPosition(centerLon, centerLat), fire: null, cell, - accent: components.model.cellAccent(normalized), + accent: components.model.cellCardAccent(cell), }); } @@ -202,6 +236,7 @@ export function createRendering({ */ function renderDetections(lod, bounds) { + layerState._heatImagery?.setShow(false); ensureDetectionCollections(); layerState._dataSource.entities.removeAll(); if (!layerState._billboards) return; @@ -345,7 +380,7 @@ export function createRendering({ return { renderCurrentLod, aggregateFires, - renderCells, + renderHeat, renderDetections, ensureDetectionCollections, removeDetectionCollections, diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index d84ee2026..9a0b031b2 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -131,6 +131,25 @@ export function createFirmsState({ services, config }) { layerState._cellCacheByGrid = new Map(); + /** + * Heat raster for the far bands (firmsHeatRaster.js); null = stale, rebuilt + * on the next far render. Cleared with the cell cache on every new shown set. + */ + + layerState._heatRaster = null; + + /** Pending debounced heat rebuild (see HEAT_REBUILD_MS). */ + + layerState._heatRebuildTimer = null; + + /** Imagery adapter (heatImagery.js), created on the first far render. */ + + layerState._heatImagery = null; + + /** Shell-provided imagery host resolver; falls back to viewer.imageryLayers. */ + + layerState._imageryHost = null; + /** Camera idle snapshot so the throttled LOD check is ~free when parked. */ layerState._camSnapValid = false; diff --git a/src/ui/layerBindings.js b/src/ui/layerBindings.js index fb8f29119..60128f8e0 100644 --- a/src/ui/layerBindings.js +++ b/src/ui/layerBindings.js @@ -141,6 +141,12 @@ export class LayerBindings { }); this._weatherShellModules.push(layer); } + // Active Fires drapes its far-zoom heatmap on the same imagery host. + const fires = this._dataManager?.layers?.get('local-firms')?.module; + if (typeof fires?.attachShellServices === 'function') { + fires.attachShellServices({ imageryHost: this.services.imageryHost }); + this._weatherShellModules.push(fires); + } } _persistAwarenessSelection(event, cleared = false) { From 52e428955f4c56249bec1573a542169026561ed2 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:17:06 +0400 Subject: [PATCH 07/24] docs(fires): describe the far-zoom heatmap Co-Authored-By: Claude Opus 5.5 --- changelog.d/1426-fire-intensity-filter.md | 6 +++--- docs/CURRENT-STATE.md | 19 ++++++++++++++----- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index 09e3c3a7d..188f964ad 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -2,8 +2,8 @@ cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · 50 · 100 · 300 MW), and add a log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / High confidence switch and an "N of M shown" - counter to the layer row. Far-zoom heat cells keep a warm density fill - (lime · amber · red). The filter applies once before rendering, so - markers, cards, heat cells, context and the top-fires query agree; share + counter to the layer row. Far zoom shows a Snap Map–style heatmap instead + of squares, empty where nothing burns. The filter applies once before + rendering, so markers, cards, the heatmap, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7e29d8675..336b32132 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2060,10 +2060,19 @@ Every FIRMS detection is colored by fire radiative power on a six-stop cold → hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from 100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Glow sprites and fire-card accents read that ramp; confidence and brightness do -not affect the color. Aggregated heat cells (far zoom) instead take the warm -end of the ramp — lime, amber or red — from their heat-normalized score -(density, night share and strongest FRP), so a region reads as one field -rather than a per-cell mosaic; the cell card's accent matches its fill. +not affect the color. + +At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style +heatmap instead of individual markers: the shown detections are binned on a +global 2048 × 1024 grid, each weighted `1 + log10(1 + FRP)`, Gaussian-blurred, +normalized on a log scale against the 99.5th percentile and mapped from blue +through cyan, green, yellow and orange to red. Nothing is painted where +nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery +tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt +on each data refresh or filter change (the first paint at once, later ones +150 ms after the last change) and hidden once markers take over below +~750 km. "N FIRES" cards from a 2° / 1° aggregation still label the hottest +areas; their accent is the strongest fire's FRP color. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale whose track is painted with the ramp and ticked at the same six values (its @@ -2071,7 +2080,7 @@ value reads "All" or "≥ N MW"), a "Confidence" switch with All / Nominal+ / High, and an "N of M shown" counter. The default is 0 MW and All, because many of the strongest detections are rated low confidence. The filter runs once in `ingestion.applyFilter()` before rendering, so markers, -cards, heat cells, the shared context store, the top-fires query and the +cards, the heatmap, the shared context store, the top-fires query and the analyst records all see one filtered set; a selected fire that no longer passes is deselected. The layer state keeps both options under the `w` (`local-firms`) share token as `f` (whole megawatts, 0–10,000) and `c` (`a`, `n`, `h`), From 2c422d2f3820707cf86de8c0f0618d57aade8a84 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:26:23 +0400 Subject: [PATCH 08/24] build(boundaries): register the fires heat raster and imagery modules Co-Authored-By: Claude Opus 5.5 --- scripts/package-boundaries.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 17772b58e..894e70a68 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1174,6 +1174,7 @@ "modules": [ "src/data/firmsAdapt.js", "src/data/firmsIntensity.js", + "src/data/firmsHeatRaster.js", "src/data/firmsLabels.js", "src/data/iconOrientation.js", "src/layers/firms/anchors.js", @@ -1185,6 +1186,7 @@ "src/layers/firms/policy.js", "src/layers/firms/queries.js", "src/layers/firms/rendering.js", + "src/layers/firms/heatImagery.js", "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", @@ -1916,6 +1918,7 @@ "src/data/firmsAdapt.js", "src/data/firmsHeatmap.js", "src/data/firmsIntensity.js", + "src/data/firmsHeatRaster.js", "src/data/firmsLabels.js", "src/data/flights.js", "src/data/flowMatch.js", @@ -2076,6 +2079,7 @@ "src/layers/firms/policy.js", "src/layers/firms/queries.js", "src/layers/firms/rendering.js", + "src/layers/firms/heatImagery.js", "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", @@ -2760,6 +2764,7 @@ "src/data/detectionPolicy.js", "src/data/firmsAdapt.js", "src/data/firmsIntensity.js", + "src/data/firmsHeatRaster.js", "src/data/firmsLabels.js", "src/data/flowMatch.js", "src/data/focusDeemphasis.js", @@ -2890,6 +2895,7 @@ "src/layers/firms/policy.js", "src/layers/firms/queries.js", "src/layers/firms/rendering.js", + "src/layers/firms/heatImagery.js", "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", From d32a4f62d33ef6dfe78d8e2b6f9373625c14e8e0 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:36:09 +0400 Subject: [PATCH 09/24] i18n(zh-Hant): fire filter strings follow zh-CN wording (s2tw) Co-Authored-By: Claude Opus 5.5 --- src/i18n/locales/zh-Hant.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c71924233..9dadb3a50 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2868,16 +2868,16 @@ "routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多", "route proxy error": "路線代理出錯", "fire confidence filter|All": "全部", - "fire confidence filter|Nominal+": "中+", + "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": "僅高可信度的探測", + "Every detection — many of the strongest fires are rated low confidence": "所有檢測——許多最強的火點置信度為低", + "Nominal and high confidence detections": "標稱和高置信度的檢測", + "High confidence detections only": "僅高置信度的檢測", "Fire intensity (FRP)": "火勢強度 (FRP)", "≥ {value} MW": "≥ {value} MW", - "Confidence": "可信度", - "Minimum fire radiative power, megawatts": "最小火輻射功率(百萬瓦)", + "Confidence": "置信度", + "Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)", "fire intensity filter|All": "全部", - "Detection confidence": "探測可信度", + "Detection confidence": "檢測置信度", "{shown} of {total} shown": "顯示 {shown} / {total}" } From bef47ee7c7cca963c3eeb145da5aad0bb08263bf Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 18:25:33 +0400 Subject: [PATCH 10/24] fix(fires): weight the far-zoom heatmap by fire radiative power Heat was a detection count, so raising the minimum-FRP slider removed most detections and the hot spots faded to blue even where strong fires still burned. Each detection now adds its FRP in megawatts (0.5 MW floor), so weak fires carry little heat and filtering them out keeps strong-fire areas hot. On the live payload, hot spots that stay yellow or hotter at >=100 MW rise from 7% to 17% and their median heat from 0.10 to 0.38. --- docs/CURRENT-STATE.md | 4 +++- src/data/firmsHeatRaster.js | 11 ++++++++++- src/data/firmsHeatRaster.test.mjs | 33 +++++++++++++++++++++++++++---- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b34636004..1bd9c82cd 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2096,7 +2096,9 @@ not affect the color. At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style heatmap instead of individual markers: the shown detections are binned on a -global 2048 × 1024 grid, each weighted `1 + log10(1 + FRP)`, Gaussian-blurred, +global 2048 × 1024 grid, each weighted by its FRP in megawatts (so heat is +radiated power, and raising the FRP slider does not dim areas where strong fires +burn), Gaussian-blurred, normalized on a log scale against the 99.5th percentile and mapped from blue through cyan, green, yellow and orange to red. Nothing is painted where nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js index 52a020671..516c7676f 100644 --- a/src/data/firmsHeatRaster.js +++ b/src/data/firmsHeatRaster.js @@ -35,8 +35,17 @@ export function heatColorAt(t) { ]; } +/** Weight of a detection with no or zero FRP, in megawatts. */ +export const HEAT_MIN_WEIGHT_MW = 0.5; + +/** + * Heat is radiated power, not a head count: each detection adds its FRP in + * megawatts. Weak fires carry little power, so raising the minimum-FRP slider + * removes most detections without dimming the areas where strong fires burn + * (a count-based heat collapsed there and the hot spots faded). + */ const defaultWeight = (fire) => - 1 + Math.log10(1 + Math.max(0, Number(fire.frp) || 0)); + Math.max(HEAT_MIN_WEIGHT_MW, Number(fire.frp) || 0); export function binFires(fires, { width, height, weight = defaultWeight }) { const grid = new Float32Array(width * height); diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs index 704c83427..2b51785e6 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + HEAT_MIN_WEIGHT_MW, HEAT_PALETTE, heatColorAt, binFires, @@ -23,7 +24,7 @@ test('palette runs cold to hot and starts fully transparent', () => { }); test('binFires drops each detection into its equirectangular pixel', () => { - const grid = binFires([fire(89.9, -179.9, 0), fire(-89.9, 179.9, 0)], { + const grid = binFires([fire(89.9, -179.9, 1), fire(-89.9, 179.9, 1)], { width: 4, height: 2, }); @@ -35,9 +36,33 @@ test('binFires drops each detection into its equirectangular pixel', () => { ); }); -test('binFires weights strong fires slightly more', () => { - const grid = binFires([fire(0.1, 0.1, 999)], { width: 4, height: 2 }); - assert.ok(grid[2] > 3 && grid[2] < 5, String(grid[2])); +test('binFires weights each detection by its radiative power', () => { + const grid = binFires( + [fire(0.1, 0.1, 250), fire(0.2, 0.2, 0), fire(0.3, 0.3, Number.NaN)], + { width: 4, height: 2 }, + ); + assert.equal(grid[2], 250 + 2 * HEAT_MIN_WEIGHT_MW); +}); + +test('filtering out weak fires keeps a strong fire hot', () => { + const strong = fire(0.1, 0.1, 300); + const weak = Array.from({ length: 40 }, () => fire(0.1, 0.1, 2)); + const reference = Array.from({ length: 10 }, () => fire(20.1, 20.1, 100)); + const options = { width: 360, height: 180, sigmaPx: 2 }; + const pixel = ((90 - 0.1) | 0) * 360 + ((0.1 + 180) | 0); + const red = (r) => r.rgba[pixel * 4]; + const alpha = (r) => r.rgba[pixel * 4 + 3]; + const unfiltered = buildHeatRaster([strong, ...weak, ...reference], options); + const filtered = buildHeatRaster([strong, ...reference], options); + assert.ok(alpha(filtered) > 0); + assert.ok( + alpha(filtered) >= alpha(unfiltered) * 0.9, + `alpha ${alpha(unfiltered)} -> ${alpha(filtered)}`, + ); + assert.ok( + red(filtered) >= red(unfiltered) * 0.9, + `red ${red(unfiltered)} -> ${red(filtered)}`, + ); }); test('blurGaussian keeps mass and wraps across the antimeridian', () => { From 096383b43266fb6b13d3cc471be4b80f74f7876a Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 30 Oct 2026 21:00:13 +0400 Subject: [PATCH 11/24] Translate the fire filter strings into de, fr and ja --- src/i18n/locales/de.json | 15 ++++++++++++++- src/i18n/locales/fr.json | 15 ++++++++++++++- src/i18n/locales/ja.json | 15 ++++++++++++++- src/i18n/same-as-english.json | 3 +++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 8e383aded..f18b5ca67 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2926,5 +2926,18 @@ "Malformed cyclone snapshot": "Fehlerhafter Wirbelsturm-Snapshot", "STALE · Roads: {source} · Flow: cached TomTom tiles": "VERALTET · Straßen: {source} · Verkehrsfluss: TomTom-Kacheln aus dem Cache", "routing busy — too many routes at once": "Routing-Dienst ausgelastet – zu viele Routen gleichzeitig", - "route proxy error": "Fehler des Routen-Proxys" + "route proxy error": "Fehler des Routen-Proxys", + "fire confidence filter|All": "Alle", + "fire confidence filter|Nominal+": "Mittel+", + "fire confidence filter|High": "Hoch", + "Every detection — many of the strongest fires are rated low confidence": "Alle Erkennungen — viele der stärksten Brände haben eine niedrige Konfidenz", + "Nominal and high confidence detections": "Erkennungen mit mittlerer und hoher Konfidenz", + "High confidence detections only": "Nur Erkennungen mit hoher Konfidenz", + "Fire intensity (FRP)": "Intensität (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "Konfidenz", + "Minimum fire radiative power, megawatts": "Minimale Strahlungsleistung des Brandes in Megawatt", + "fire intensity filter|All": "Alle", + "Detection confidence": "Konfidenz der Erkennung", + "{shown} of {total} shown": "{shown} von {total} angezeigt" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 931e7c3ff..59641d143 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2996,5 +2996,18 @@ "ESC to dismiss": "ESC pour fermer", "Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte.", "Malformed cyclone snapshot": "Instantané cyclonique mal formé", - "Cyclone request timed out": "Délai dépassé pour la requête cyclonique" + "Cyclone request timed out": "Délai dépassé pour la requête cyclonique", + "fire confidence filter|All": "Toutes", + "fire confidence filter|Nominal+": "Nominale+", + "fire confidence filter|High": "Élevée", + "Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — beaucoup des feux les plus intenses ont une confiance faible", + "Nominal and high confidence detections": "Détections de confiance nominale et élevée", + "High confidence detections only": "Détections de confiance élevée uniquement", + "Fire intensity (FRP)": "Intensité (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "Confiance", + "Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts", + "fire intensity filter|All": "Toutes", + "Detection confidence": "Confiance de la détection", + "{shown} of {total} shown": "{shown} sur {total} affichées" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5a48dc886..85757c1dd 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2856,5 +2856,18 @@ "routing busy — too many routes at once": "ルート検索サービスが混雑しています。同時に要求されたルートが多すぎます", "route proxy error": "ルート検索プロキシでエラーが発生しました", "Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です", - "Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました" + "Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました", + "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": "信頼度が高の検知のみ", + "Fire intensity (FRP)": "強度(FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "信頼度", + "Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)", + "fire intensity filter|All": "すべて", + "Detection confidence": "検知の信頼度", + "{shown} of {total} shown": "{total}件中{shown}件を表示" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index dd0df9990..390b56e58 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -163,6 +163,7 @@ "{value} KM/S", "ΔN", "Ⅱ PAUSE", + "≥ {value} MW", "▲ {frp} MW" ], "es": [ @@ -469,6 +470,7 @@ "ΔE", "ΔN", "Ⅱ PAUSE", + "≥ {value} MW", "▲ {frp} MW" ], "it": [ @@ -688,6 +690,7 @@ "{type} SPD: {speed} HDG: {heading}", "ΔE", "ΔN", + "≥ {value} MW", "▲ {frp} MW" ], "pt": [ From 18d55f58b9a756cb758996eb2593e6b2967a956d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 2 Nov 2026 13:48:28 +0400 Subject: [PATCH 12/24] Color fires from ember to white-hot instead of blue to red About 90% of detections are under 20 MW, so the old blue and teal stops painted most of the map in cold colors that do not read as fire. The ramp now runs ember, red, flame, orange, gold and white-hot at the same thresholds, each stop lighter than the one below, so the brightest marks are the strongest fires. The heatmap uses the same colors. --- changelog.d/1426-fire-intensity-filter.md | 12 ++--- docs/CURRENT-STATE.md | 17 ++++--- src/data/firmsCards.test.mjs | 26 +++++----- src/data/firmsHeatRaster.js | 18 ++++--- src/data/firmsHeatRaster.test.mjs | 10 ++-- src/data/firmsIntensity.js | 27 +++++----- src/data/firmsIntensity.test.mjs | 62 ++++++++++++++--------- src/data/firmsLabels.js | 4 +- src/data/firmsLabels.test.mjs | 20 ++++---- src/layers/firms/model.js | 2 +- src/layers/firms/policy.js | 2 +- src/ui/layerPanel/layerRows.test.mjs | 2 +- 12 files changed, 115 insertions(+), 87 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index 188f964ad..c1d06643e 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -1,9 +1,9 @@ - Color every Active Fires detection by fire radiative power on a six-stop - cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · - 50 · 100 · 300 MW), and add a log-scale minimum-FRP slider whose track is the - ramp, an All / Nominal+ / High confidence switch and an "N of M shown" - counter to the layer row. Far zoom shows a Snap Map–style heatmap instead - of squares, empty where nothing burns. The filter applies once before - rendering, so markers, cards, the heatmap, context and the top-fires query agree; share + ember → white-hot ramp (ember · red · flame · orange · gold · white-hot at + 0 · 5 · 20 · 50 · 100 · 300 MW, brighter as power rises), and add a + log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / + High confidence switch and an "N of M shown" counter to the layer row. Far + zoom shows a Snap Map–style heatmap in the same colors instead of squares, + empty where nothing burns. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a7d941001..5644960bc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2175,19 +2175,22 @@ accessible name remains "Don't show this again". Control behavior is unchanged. ## Active Fires intensity filter and color ramp -Every FIRMS detection is colored by fire radiative power on a six-stop cold → -hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from -100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Glow -sprites and fire-card accents read that ramp; confidence and brightness do -not affect the color. +Every FIRMS detection is colored by fire radiative power on a six-stop ember → +white-hot ramp: dark ember from 0 MW, red from 5, flame from 20, orange from +50, gold from 100 and pale white-hot from 300 MW (`FRP_RAMP` in +`src/data/firmsIntensity.js`). Each stop is lighter than the one below and +none is a cold hue, so the brightest marks are the strongest fires; ~90% of +detections are under 20 MW and stay dim red. Glow sprites and fire-card +accents read that ramp; confidence and brightness do not affect the color. At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style heatmap instead of individual markers: the shown detections are binned on a global 2048 × 1024 grid, each weighted by its FRP in megawatts (so heat is radiated power, and raising the FRP slider does not dim areas where strong fires burn), Gaussian-blurred, -normalized on a log scale against the 99.5th percentile and mapped from blue -through cyan, green, yellow and orange to red. Nothing is painted where +normalized on a log scale against the 99.5th percentile and mapped through the +same colors, from a faint ember edge through red, orange and gold to a pale +white-hot core. Nothing is painted where nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt on each data refresh or filter change (the first paint at once, later ones diff --git a/src/data/firmsCards.test.mjs b/src/data/firmsCards.test.mjs index d0ca7bd70..9d6157324 100644 --- a/src/data/firmsCards.test.mjs +++ b/src/data/firmsCards.test.mjs @@ -45,7 +45,7 @@ test('buildFireCard: title carries FRP, detail carries conf/age/satellite', () = assert.equal(card.details.length, 1); assert.equal(card.details[0], 'high · 2h · N20'); assert.equal(card.selected, false); - assert.equal(card.accent, accentForSeverity('red'), 'FRP 1520 is red-hot'); + assert.equal(card.accent, accentForSeverity('white-hot'), 'FRP 1520 is white-hot'); assert.deepEqual(card.position, { x: 1, y: 2, z: 3 }, 'uses the candidate position untouched'); }); @@ -69,26 +69,26 @@ 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 blue', () => { +test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is an ember', () => { 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.equal(card.accent, accentForSeverity('blue')); + assert.equal(card.accent, accentForSeverity('ember')); }); 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')); + assert.equal(accentAt(4.9), accentForSeverity('ember')); + assert.equal(accentAt(5), accentForSeverity('red')); + assert.equal(accentAt(20), accentForSeverity('flame')); + assert.equal(accentAt(50), accentForSeverity('orange')); + assert.equal(accentAt(100), accentForSeverity('gold')); + assert.equal(accentAt(300), accentForSeverity('white-hot')); // 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'), + accentForSeverity('white-hot'), ); }); @@ -109,12 +109,12 @@ test('buildCellCard: plural noun, max FRP and newest age, accent passthrough', ( const candidate = { cell: { count: 14, maxFrp: 210.4, newestAcqMs: NOW - 3 * H, night: 2 }, position: { x: 0, y: 0, z: 0 }, - accent: accentForSeverity('orange'), + accent: accentForSeverity('gold'), }; const card = buildCellCard(candidate, NOW); assert.equal(card.title, '14 FIRES'); assert.equal(card.details[0], 'max 210 MW · new 3h'); - assert.equal(card.accent, accentForSeverity('orange')); + assert.equal(card.accent, accentForSeverity('gold')); }); test('buildCellCard: the count picks the plural form of the language', () => { @@ -166,7 +166,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('blue'), 'missing accent defaults to the coldest stop'); + assert.equal(card.accent, accentForSeverity('ember'), 'missing accent defaults to the weakest stop'); }); test('FIRMS host policy keeps ambient cards bounded and selected cards protected', () => { diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js index 516c7676f..985699d7e 100644 --- a/src/data/firmsHeatRaster.js +++ b/src/data/firmsHeatRaster.js @@ -8,14 +8,18 @@ /** Below this normalized heat nothing is painted: empty land stays empty. */ export const HEAT_FLOOR = 0.03; +/** + * The FRP marker ramp as a continuous glow: faint ember at the edges, a + * pale white-hot core where the most power burns. + */ export const HEAT_PALETTE = Object.freeze([ - { at: 0.0, rgb: [43, 108, 255], alpha: 0 }, - { at: 0.08, rgb: [43, 108, 255], alpha: 0.35 }, - { at: 0.25, rgb: [25, 195, 255], alpha: 0.55 }, - { at: 0.45, rgb: [47, 224, 122], alpha: 0.65 }, - { at: 0.65, rgb: [245, 230, 66], alpha: 0.75 }, - { at: 0.82, rgb: [255, 138, 31], alpha: 0.82 }, - { at: 1.0, rgb: [255, 42, 26], alpha: 0.88 }, + { at: 0.0, rgb: [158, 42, 27], alpha: 0 }, + { at: 0.08, rgb: [158, 42, 27], alpha: 0.35 }, + { at: 0.25, rgb: [210, 64, 31], alpha: 0.55 }, + { at: 0.45, rgb: [240, 106, 40], alpha: 0.65 }, + { at: 0.65, rgb: [251, 154, 46], alpha: 0.75 }, + { at: 0.82, rgb: [253, 200, 74], alpha: 0.82 }, + { at: 1.0, rgb: [255, 243, 176], alpha: 0.9 }, ]); export function heatColorAt(t) { diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs index 2b51785e6..a5cf7eccf 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -11,16 +11,20 @@ import { const fire = (lat, lon, frp = 10) => ({ lat, lon, frp }); -test('palette runs cold to hot and starts fully transparent', () => { +test('palette runs ember to white-hot and starts fully transparent', () => { assert.equal(HEAT_PALETTE[0].at, 0); assert.equal(HEAT_PALETTE.at(-1).at, 1); assert.deepEqual(heatColorAt(0), [0, 0, 0, 0]); assert.equal(heatColorAt(0.01)[3], 0, 'below the floor nothing is painted'); const low = heatColorAt(0.15); const high = heatColorAt(1); - assert.ok(low[2] > low[0], 'low heat is blue'); - assert.ok(high[0] > high[2], 'the core is red'); + assert.ok(low[0] > low[1] && low[1] > low[2], 'low heat is an ember red'); + assert.ok(high[1] > low[1] && high[2] > low[2], 'the core is paler'); assert.ok(high[3] > low[3], 'hotter is more opaque'); + for (let i = 1; i <= 100; i++) { + const [r, , b] = heatColorAt(i / 100); + assert.ok(r >= b, `no cold hue at ${i}%`); + } }); test('binFires drops each detection into its equirectangular pixel', () => { diff --git a/src/data/firmsIntensity.js b/src/data/firmsIntensity.js index 417566346..0b14dd3bd 100644 --- a/src/data/firmsIntensity.js +++ b/src/data/firmsIntensity.js @@ -1,28 +1,31 @@ /** * @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 + * agent fire tool: the six-stop ember → white-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). + * The ramp (`FRP_RAMP`, stepped, not interpolated) runs like a fire cooling + * down: a dim ember for weak fires through red, orange and gold to a pale + * white-hot core, lightness rising with power, so the brightest marks are the + * strongest fires. No cold hues: ~90% of detections are under 20 MW and a + * blue start painted most of the map blue. 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 hottest fire a week (HAL-675). */ /** - * Stepped FRP ramp, coldest first. A detection takes the LAST stop whose + * Stepped FRP ramp, weakest 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' }), + Object.freeze({ name: 'ember', minFrp: 0, hex: '#9e2a1b' }), + Object.freeze({ name: 'red', minFrp: 5, hex: '#d2401f' }), + Object.freeze({ name: 'flame', minFrp: 20, hex: '#f06a28' }), + Object.freeze({ name: 'orange', minFrp: 50, hex: '#fb9a2e' }), + Object.freeze({ name: 'gold', minFrp: 100, hex: '#fdc84a' }), + Object.freeze({ name: 'white-hot', minFrp: 300, hex: '#fff3b0' }), ]); /** Slider ticks: the ramp thresholds, so slider, legend and markers agree. */ diff --git a/src/data/firmsIntensity.test.mjs b/src/data/firmsIntensity.test.mjs index 558068e67..c1d22948b 100644 --- a/src/data/firmsIntensity.test.mjs +++ b/src/data/firmsIntensity.test.mjs @@ -18,40 +18,54 @@ import { sliderPositionForFrp, } from './firmsIntensity.js'; -test('the ramp has six cold → hot stops at 0 · 5 · 20 · 50 · 100 · 300 MW in Portolan hue order', () => { +test('the ramp has six ember → white-hot stops at 0 · 5 · 20 · 50 · 100 · 300 MW', () => { 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'], + ['ember', 0, '#9e2a1b'], + ['red', 5, '#d2401f'], + ['flame', 20, '#f06a28'], + ['orange', 50, '#fb9a2e'], + ['gold', 100, '#fdc84a'], + ['white-hot', 300, '#fff3b0'], ], ); assert.deepEqual([...FRP_SLIDER_TICKS], [0, 5, 20, 50, 100, 300]); assert.ok(Object.isFrozen(FRP_RAMP)); }); +test('every stop is warm and each is lighter than the one below', () => { + const luma = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b; + let previous = -1; + for (const stop of FRP_RAMP) { + const rgb = hexToRgb(stop.hex); + assert.ok(rgb[0] >= rgb[2], `${stop.name} has no cold hue`); + assert.ok( + luma(rgb) > previous, + `${stop.name} is lighter than the stop below`, + ); + previous = luma(rgb); + } +}); + 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'); + assert.equal(frpRampStop(0).name, 'ember'); + assert.equal(frpRampStop(4.99).name, 'ember'); + assert.equal(frpRampStop(5).name, 'red'); + assert.equal(frpRampStop(19.9).name, 'red'); + assert.equal(frpRampStop(20).name, 'flame'); + assert.equal(frpRampStop(50).name, 'orange'); + assert.equal(frpRampStop(100).name, 'gold'); + assert.equal(frpRampStop(299.9).name, 'gold'); + assert.equal(frpRampStop(300).name, 'white-hot'); + assert.equal(frpRampStop(1246).name, 'white-hot'); + assert.equal(frpRampStop(NaN).name, 'ember'); + assert.equal(frpRampStop(-3).name, 'ember'); + assert.equal(frpRampStop(undefined).name, 'ember'); }); test('hex stops convert to the "r, g, b" accents the cards paint', () => { - assert.deepEqual(hexToRgb('#4a5bd4'), [74, 91, 212]); + assert.deepEqual(hexToRgb('#9e2a1b'), [158, 42, 27]); assert.deepEqual(hexToRgb('d1382a'), [209, 56, 42]); }); @@ -84,9 +98,9 @@ test('the log slider pins 0 MW at the left and runs 1 → 1000 MW; values round- 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.match(gradient, /#9e2a1b 0\.00% 23\.3\d%/); + assert.match(gradient, /#d2401f 23\.3\d% 43\.3\d%/); + assert.match(gradient, /#fff3b0 82\.5\d% 100\.00%\)$/); assert.equal(gradient.match(/#/g).length, FRP_RAMP.length); }); diff --git a/src/data/firmsLabels.js b/src/data/firmsLabels.js index 3af6a15a5..bf97982c2 100644 --- a/src/data/firmsLabels.js +++ b/src/data/firmsLabels.js @@ -36,8 +36,8 @@ const ACCENT_RGB = Object.freeze( ); /** - * Severity stop name → "r, g, b" accent string (defaults to the coldest stop). - * @param {string} stopName - A FRP_RAMP stop name ('blue' … 'red'). + * Severity stop name → "r, g, b" accent string (defaults to the weakest stop). + * @param {string} stopName - A FRP_RAMP stop name ('ember' … 'white-hot'). * @returns {string} */ export function accentForSeverity(stopName) { diff --git a/src/data/firmsLabels.test.mjs b/src/data/firmsLabels.test.mjs index 62234bb2d..a141d0d17 100644 --- a/src/data/firmsLabels.test.mjs +++ b/src/data/firmsLabels.test.mjs @@ -9,16 +9,16 @@ import { formatFrpMw, } from './firmsLabels.js'; -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 card accents are the six FRP ramp stops, weakest as the fallback', () => { + // Ember #9e2a1b … white-hot #fff3b0, lightness rising with power. + assert.equal(accentForSeverity('ember'), '158, 42, 27'); + assert.equal(accentForSeverity('red'), '210, 64, 31'); + assert.equal(accentForSeverity('flame'), '240, 106, 40'); + assert.equal(accentForSeverity('orange'), '251, 154, 46'); + assert.equal(accentForSeverity('gold'), '253, 200, 74'); + assert.equal(accentForSeverity('white-hot'), '255, 243, 176'); + assert.equal(accentForSeverity('blue'), accentForSeverity('ember')); + assert.equal(accentForSeverity('chartreuse'), accentForSeverity('ember')); }); test('FIRMS power that rounds to 10 MW drops the decimal', () => { diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 330d237e1..aefb94b21 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -121,7 +121,7 @@ export function createModel({ } /** - * The ramp stop for a fire radiative power (stepped, coldest first). + * The ramp stop for a fire radiative power (stepped, weakest 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. diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index eb235bc81..faa55bbc3 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -111,7 +111,7 @@ export const CULL_LIFT_M = 12; /** * Color stops for detection glow sprites and card accents: the - * six-stop cold → hot FRP ramp (firmsIntensity.js), coldest first, as Cesium + * six-stop ember → white-hot FRP ramp (firmsIntensity.js), weakest first, as Cesium * colors. Stop names key the glow-sprite cache and the card accents. */ diff --git a/src/ui/layerPanel/layerRows.test.mjs b/src/ui/layerPanel/layerRows.test.mjs index 5e1d543d0..966c41cb6 100644 --- a/src/ui/layerPanel/layerRows.test.mjs +++ b/src/ui/layerPanel/layerRows.test.mjs @@ -75,7 +75,7 @@ const fireRow = (value, active = 'all') => ({ max: 1000, step: 1, value, - track: 'linear-gradient(90deg, #4a5bd4, #d1382a)', + track: 'linear-gradient(90deg, #9e2a1b, #fff3b0)', ticks: [ { at: 0, label: '0' }, { at: 0.5, label: '30' }, From 314292a006e6fda186f55c8434c9fdf97ff2cb54 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 2 Nov 2026 14:10:57 +0400 Subject: [PATCH 13/24] Brighten the heatmap edge so weak fire regions stay visible The heat scale is set by the hottest regions, so most of the world sits at the low end. The ember edge was a dark red at 35% opacity and nearly vanished into forest and savanna imagery: over the Americas the spots were there but hard to see. The edge is now a bright red starting at 58% opacity, which reads at least as well as the old blue did. --- docs/CURRENT-STATE.md | 5 +++-- src/data/firmsHeatRaster.js | 21 ++++++++++++--------- src/data/firmsHeatRaster.test.mjs | 3 ++- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5644960bc..dabc074ae 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2189,8 +2189,9 @@ global 2048 × 1024 grid, each weighted by its FRP in megawatts (so heat is radiated power, and raising the FRP slider does not dim areas where strong fires burn), Gaussian-blurred, normalized on a log scale against the 99.5th percentile and mapped through the -same colors, from a faint ember edge through red, orange and gold to a pale -white-hot core. Nothing is painted where +same hues, from a bright red edge through orange and gold to a pale +white-hot core. The edge starts more than half opaque, so regions far below +the hottest ones still read over forest and savanna imagery. Nothing is painted where nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt on each data refresh or filter change (the first paint at once, later ones diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js index 985699d7e..f6aadff3b 100644 --- a/src/data/firmsHeatRaster.js +++ b/src/data/firmsHeatRaster.js @@ -9,17 +9,20 @@ export const HEAT_FLOOR = 0.03; /** - * The FRP marker ramp as a continuous glow: faint ember at the edges, a - * pale white-hot core where the most power burns. + * The FRP marker ramp as a continuous glow, from a red edge through orange + * and gold to a pale white-hot core. The edge is a bright red, not the + * markers' dark ember, and starts more than half opaque: most of the world + * sits low on a scale set by the hottest regions, and a dark translucent red + * vanishes into forest and savanna imagery. */ export const HEAT_PALETTE = Object.freeze([ - { at: 0.0, rgb: [158, 42, 27], alpha: 0 }, - { at: 0.08, rgb: [158, 42, 27], alpha: 0.35 }, - { at: 0.25, rgb: [210, 64, 31], alpha: 0.55 }, - { at: 0.45, rgb: [240, 106, 40], alpha: 0.65 }, - { at: 0.65, rgb: [251, 154, 46], alpha: 0.75 }, - { at: 0.82, rgb: [253, 200, 74], alpha: 0.82 }, - { at: 1.0, rgb: [255, 243, 176], alpha: 0.9 }, + { at: 0.0, rgb: [232, 58, 30], alpha: 0 }, + { at: 0.08, rgb: [232, 58, 30], alpha: 0.58 }, + { at: 0.25, rgb: [242, 86, 34], alpha: 0.7 }, + { at: 0.45, rgb: [248, 122, 40], alpha: 0.76 }, + { at: 0.65, rgb: [252, 166, 52], alpha: 0.82 }, + { at: 0.82, rgb: [254, 208, 92], alpha: 0.87 }, + { at: 1.0, rgb: [255, 245, 190], alpha: 0.92 }, ]); export function heatColorAt(t) { diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs index a5cf7eccf..25c8b61fb 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -18,7 +18,8 @@ test('palette runs ember to white-hot and starts fully transparent', () => { assert.equal(heatColorAt(0.01)[3], 0, 'below the floor nothing is painted'); const low = heatColorAt(0.15); const high = heatColorAt(1); - assert.ok(low[0] > low[1] && low[1] > low[2], 'low heat is an ember red'); + assert.ok(low[0] > low[1] && low[1] > low[2], 'low heat is red'); + assert.ok(low[3] >= 0.5 * 255, 'low heat stays visible on any imagery'); assert.ok(high[1] > low[1] && high[2] > low[2], 'the core is paler'); assert.ok(high[3] > low[3], 'hotter is more opaque'); for (let i = 1; i <= 100; i++) { From ad171f2854704b2cee1e50b457a96c0adde71e84 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 2 Nov 2026 22:43:51 +0400 Subject: [PATCH 14/24] Draw each far-zoom fire as a marker-sized heat spot The heat raster was 2048 px wide with a 3 px blur, so every fire spread over ~200 km and the far-zoom map read as clouds next to the markers. Double the grid to ~10 km per pixel and blur by one pixel: a lone fire is now a spot about as wide as its marker at the 3,000 km hand-off. --- changelog.d/1426-fire-intensity-filter.md | 2 +- docs/CURRENT-STATE.md | 5 +++-- src/layers/firms/heatImagery.test.mjs | 25 +++++++++++++++++++++++ src/layers/firms/policy.js | 13 +++++++----- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index c1d06643e..4c02ccf20 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -4,6 +4,6 @@ log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / High confidence switch and an "N of M shown" counter to the layer row. Far zoom shows a Snap Map–style heatmap in the same colors instead of squares, - empty where nothing burns. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share + one marker-sized spot per fire, empty where nothing burns. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dabc074ae..5be2a98dc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2185,9 +2185,10 @@ accents read that ramp; confidence and brightness do not affect the color. At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style heatmap instead of individual markers: the shown detections are binned on a -global 2048 × 1024 grid, each weighted by its FRP in megawatts (so heat is +global 4096 × 2048 grid (≈10 km per pixel), each weighted by its FRP in megawatts (so heat is radiated power, and raising the FRP slider does not dim areas where strong fires -burn), Gaussian-blurred, +burn), Gaussian-blurred by one pixel so each fire stays a spot about the size +of its marker, normalized on a log scale against the 99.5th percentile and mapped through the same hues, from a bright red edge through orange and gold to a pale white-hot core. The edge starts more than half opaque, so regions far below diff --git a/src/layers/firms/heatImagery.test.mjs b/src/layers/firms/heatImagery.test.mjs index 0fca7b97f..4b6403298 100644 --- a/src/layers/firms/heatImagery.test.mjs +++ b/src/layers/firms/heatImagery.test.mjs @@ -1,6 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createHeatImagery } from './heatImagery.js'; +import { buildHeatRaster } from '../../data/firmsHeatRaster.js'; +import { HEAT_RASTER } from './policy.js'; function harness() { const layers = []; @@ -85,3 +87,26 @@ test('destroy removes the layer', () => { h.imagery.destroy(); assert.equal(h.layers.length, 0); }); + +test('the shipped raster paints a lone weak fire as a marker-sized spot', () => { + // A hot cluster elsewhere sets the scale, as Africa does on real data. + const fires = [{ lat: 0, lon: 0, frp: 5 }]; + for (let i = 0; i < 40; i++) { + fires.push({ lat: -10 + (i % 5) * 0.1, lon: 25 + i * 0.05, frp: 300 }); + } + const r = buildHeatRaster(fires, HEAT_RASTER); + const y = Math.floor((90 / 180) * r.height); + const painted = []; + for (let x = 0; x < r.width; x++) { + const lon = ((x + 0.5) / r.width) * 360 - 180; + if (Math.abs(lon) < 5 && r.rgba[(y * r.width + x) * 4 + 3] > 0) { + painted.push(x); + } + } + const kmPerPx = 40075 / r.width; + assert.ok(painted.length > 0, 'the weak fire still shows'); + assert.ok( + painted.length * kmPerPx <= 80, + `spot ${Math.round(painted.length * kmPerPx)} km wide`, + ); +}); diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index faa55bbc3..9de42cfa4 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -49,14 +49,17 @@ export const LOD_LEVELS = [ ]; /** - * Global heat raster for the `heat` bands: ≈0.18° per pixel, Gaussian σ in - * pixels. Built once per data refresh or filter change, never per camera move. + * Global heat raster for the `heat` bands: ≈0.09° (≈10 km) per pixel and a + * one-pixel Gaussian σ, so a lone fire is a spot about as wide as its marker + * at the 3,000 km hand-off and neighbours merge only when they really touch + * (2048 px with σ 3 smeared each fire over ~200 km). Built once per data + * refresh or filter change, never per camera move. */ export const HEAT_RASTER = Object.freeze({ - width: 2048, - height: 1024, - sigmaPx: 3, + width: 4096, + height: 2048, + sigmaPx: 1, }); /** From 5ab8ddb4ca3cd848017bc3435ff3784c4bf53c31 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 2 Nov 2026 23:12:38 +0400 Subject: [PATCH 15/24] Double the far-zoom heat spots so they read as spots At 10 km per pixel each fire was a marker-sized spot that faded into the imagery from the globe view. A 2048 px grid with the same one-pixel blur makes every spot exactly twice as wide (~60 km for a lone weak fire), still separate, and the build is three times faster. --- changelog.d/1426-fire-intensity-filter.md | 2 +- docs/CURRENT-STATE.md | 6 +++--- src/layers/firms/heatImagery.test.mjs | 4 ++-- src/layers/firms/policy.js | 15 ++++++++------- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index 4c02ccf20..6f2d11a33 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -4,6 +4,6 @@ log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / High confidence switch and an "N of M shown" counter to the layer row. Far zoom shows a Snap Map–style heatmap in the same colors instead of squares, - one marker-sized spot per fire, empty where nothing burns. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share + one distinct spot per fire, empty where nothing burns. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5be2a98dc..4d1b69849 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2185,10 +2185,10 @@ accents read that ramp; confidence and brightness do not affect the color. At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style heatmap instead of individual markers: the shown detections are binned on a -global 4096 × 2048 grid (≈10 km per pixel), each weighted by its FRP in megawatts (so heat is +global 2048 × 1024 grid (≈20 km per pixel), each weighted by its FRP in megawatts (so heat is radiated power, and raising the FRP slider does not dim areas where strong fires -burn), Gaussian-blurred by one pixel so each fire stays a spot about the size -of its marker, +burn), Gaussian-blurred by one pixel so each fire stays a distinct spot about +twice the size of its marker, normalized on a log scale against the 99.5th percentile and mapped through the same hues, from a bright red edge through orange and gold to a pale white-hot core. The edge starts more than half opaque, so regions far below diff --git a/src/layers/firms/heatImagery.test.mjs b/src/layers/firms/heatImagery.test.mjs index 4b6403298..5543a0691 100644 --- a/src/layers/firms/heatImagery.test.mjs +++ b/src/layers/firms/heatImagery.test.mjs @@ -88,7 +88,7 @@ test('destroy removes the layer', () => { assert.equal(h.layers.length, 0); }); -test('the shipped raster paints a lone weak fire as a marker-sized spot', () => { +test('the shipped raster paints a lone weak fire as a distinct spot', () => { // A hot cluster elsewhere sets the scale, as Africa does on real data. const fires = [{ lat: 0, lon: 0, frp: 5 }]; for (let i = 0; i < 40; i++) { @@ -106,7 +106,7 @@ test('the shipped raster paints a lone weak fire as a marker-sized spot', () => const kmPerPx = 40075 / r.width; assert.ok(painted.length > 0, 'the weak fire still shows'); assert.ok( - painted.length * kmPerPx <= 80, + painted.length * kmPerPx <= 100, `spot ${Math.round(painted.length * kmPerPx)} km wide`, ); }); diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 9de42cfa4..2af5d9f5c 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -49,16 +49,17 @@ export const LOD_LEVELS = [ ]; /** - * Global heat raster for the `heat` bands: ≈0.09° (≈10 km) per pixel and a - * one-pixel Gaussian σ, so a lone fire is a spot about as wide as its marker - * at the 3,000 km hand-off and neighbours merge only when they really touch - * (2048 px with σ 3 smeared each fire over ~200 km). Built once per data - * refresh or filter change, never per camera move. + * Global heat raster for the `heat` bands: ≈0.18° (≈20 km) per pixel and a + * one-pixel Gaussian σ, so a lone fire is a distinct spot about twice as wide + * as its marker at the 3,000 km hand-off and still reads from the + * whole-globe view; neighbours merge only when they nearly touch (σ 3 here + * smeared each fire over ~200 km). Built once per data refresh or filter + * change, never per camera move. */ export const HEAT_RASTER = Object.freeze({ - width: 4096, - height: 2048, + width: 2048, + height: 1024, sigmaPx: 1, }); From ef5c034dc9391f139918c5f3ca03f04e1fd014d3 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 14:39:02 +0400 Subject: [PATCH 16/24] Linearly bin fires so weak heat spots stay round Dropping each detection whole into its nearest pixel snapped spots by up to half a pixel and left weak lone fires as squares or plus signs once thresholded. Share each detection's weight between the four nearest pixel centres instead (linear binning, Wand 1994): spots sit at the true position and blur round, at the same build cost. --- docs/CURRENT-STATE.md | 4 ++- src/data/firmsHeatRaster.js | 41 ++++++++++++++++++++++--------- src/data/firmsHeatRaster.test.mjs | 25 +++++++++++++++---- 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4d1b69849..73ea42a0f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2184,7 +2184,9 @@ detections are under 20 MW and stay dim red. Glow sprites and fire-card accents read that ramp; confidence and brightness do not affect the color. At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style -heatmap instead of individual markers: the shown detections are binned on a +heatmap instead of individual markers: the shown detections are linearly binned +(each detection's weight shared between the four nearest pixel centres, so spots +sit at the true position and stay round) on a global 2048 × 1024 grid (≈20 km per pixel), each weighted by its FRP in megawatts (so heat is radiated power, and raising the FRP slider does not dim areas where strong fires burn), Gaussian-blurred by one pixel so each fire stays a distinct spot about diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js index f6aadff3b..6704cf6e2 100644 --- a/src/data/firmsHeatRaster.js +++ b/src/data/firmsHeatRaster.js @@ -1,8 +1,9 @@ /** * Snap Map–style heat raster for the Active Fires far-zoom bands: detections - * are binned on an equirectangular grid (row 0 = north, column 0 = 180°W), - * blurred, normalized on a log scale and mapped through HEAT_PALETTE. Pure: - * no Cesium, no DOM, so the whole pipeline is unit-tested in node. + * are linearly binned on an equirectangular grid (row 0 = north, column + * 0 = 180°W), blurred, normalized on a log scale and mapped through + * HEAT_PALETTE. Pure: no Cesium, no DOM, so the whole pipeline is unit-tested + * in node. */ /** Below this normalized heat nothing is painted: empty land stays empty. */ @@ -54,19 +55,35 @@ export const HEAT_MIN_WEIGHT_MW = 0.5; const defaultWeight = (fire) => Math.max(HEAT_MIN_WEIGHT_MW, Number(fire.frp) || 0); +/** + * Linear binning (Wand 1994): each detection's weight is shared between the + * four pixel centres around it in proportion to closeness, instead of + * dropping whole into one pixel. Spots then sit at the true position rather + * than snapping by up to half a pixel (~10 km), and a lone weak fire blurs + * into a round spot instead of a square or a plus sign. Longitude wraps; + * rows beyond the poles fold back onto the edge row. + */ export function binFires(fires, { width, height, weight = defaultWeight }) { const grid = new Float32Array(width * height); + const row = (y) => Math.min(height - 1, Math.max(0, y)) * width; + const col = (x) => ((x % width) + width) % width; for (const fire of fires) { if (!Number.isFinite(fire.lat) || !Number.isFinite(fire.lon)) continue; - const x = Math.min( - width - 1, - Math.max(0, Math.floor(((fire.lon + 180) / 360) * width)), - ); - const y = Math.min( - height - 1, - Math.max(0, Math.floor(((90 - fire.lat) / 180) * height)), - ); - grid[y * width + x] += weight(fire); + const fx = ((fire.lon + 180) / 360) * width - 0.5; + const fy = ((90 - fire.lat) / 180) * height - 0.5; + const x0 = Math.floor(fx); + const y0 = Math.floor(fy); + const tx = fx - x0; + const ty = fy - y0; + const w = weight(fire); + const top = row(y0); + const bottom = row(y0 + 1); + const left = col(x0); + const right = col(x0 + 1); + grid[top + left] += w * (1 - tx) * (1 - ty); + grid[top + right] += w * tx * (1 - ty); + grid[bottom + left] += w * (1 - tx) * ty; + grid[bottom + right] += w * tx * ty; } return grid; } diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs index 25c8b61fb..754493754 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -28,22 +28,37 @@ test('palette runs ember to white-hot and starts fully transparent', () => { } }); -test('binFires drops each detection into its equirectangular pixel', () => { - const grid = binFires([fire(89.9, -179.9, 1), fire(-89.9, 179.9, 1)], { +test('binFires puts a detection at a pixel centre wholly in that pixel', () => { + // 4×2 grid: pixel centres at lon −135/−45/45/135, lat ±45. + const grid = binFires([fire(45, -135, 1), fire(-45, 135, 1)], { width: 4, height: 2, }); - assert.equal(grid[0], 1, 'north-west corner'); - assert.equal(grid[7], 1, 'south-east corner'); + assert.equal(grid[0], 1, 'north-west pixel'); + assert.equal(grid[7], 1, 'south-east pixel'); assert.equal( grid.reduce((a, b) => a + b, 0), 2, ); }); +test('binFires shares a detection between the pixels around it', () => { + const between = binFires([fire(45, 0, 1)], { width: 4, height: 2 }); + assert.equal(between[1], 0.5, 'half to the pixel west of it'); + assert.equal(between[2], 0.5, 'half to the pixel east of it'); + const quarter = binFires([fire(45, -112.5, 1)], { width: 4, height: 2 }); + assert.equal(quarter[0], 0.75, 'closer centre takes more'); + assert.equal(quarter[1], 0.25); + const wrapped = binFires([fire(45, 180, 1)], { width: 4, height: 2 }); + assert.equal(wrapped[3], 0.5, 'the antimeridian wraps'); + assert.equal(wrapped[0], 0.5); + const polar = binFires([fire(90, -135, 1)], { width: 4, height: 2 }); + assert.equal(polar[0], 1, 'beyond the last row folds back onto it'); +}); + test('binFires weights each detection by its radiative power', () => { const grid = binFires( - [fire(0.1, 0.1, 250), fire(0.2, 0.2, 0), fire(0.3, 0.3, Number.NaN)], + [fire(45, 45, 250), fire(45, 45, 0), fire(45, 45, Number.NaN)], { width: 4, height: 2 }, ); assert.equal(grid[2], 250 + 2 * HEAT_MIN_WEIGHT_MW); From 31393696734d6a66fe7533bd58ec2261ec594299 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 14:56:40 +0400 Subject: [PATCH 17/24] Add a heatmap spot size slider to the Active Fires row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ½×–4× slider (log2 axis, 5 % steps, default 1×) under the intensity filter sets how large far-zoom heat spots are. It scales the grid, not the blur: 2× is a 1024 × 512 grid with the same one-pixel blur, so each spot grows exactly and weak lone fires don't fade as they widen. A change rebuilds only the heatmap. Share links keep it as w.s (percent, 50–400). The layer row now takes a list of sliders. --- changelog.d/1426-fire-intensity-filter.md | 9 +- docs/CURRENT-STATE.md | 13 ++- src/data/firmsHeatRaster.js | 64 +++++++++++ src/data/firmsHeatRaster.test.mjs | 61 +++++++++++ src/data/firmsInteraction.test.mjs | 63 +++++++++++ src/data/layerState.js | 5 +- src/data/layerState.test.mjs | 32 +++++- src/i18n/locales/ar.json | 3 + src/i18n/locales/de.json | 3 + src/i18n/locales/es.json | 3 + src/i18n/locales/fr.json | 3 + src/i18n/locales/it.json | 3 + src/i18n/locales/ja.json | 3 + src/i18n/locales/pt.json | 3 + src/i18n/locales/ru.json | 3 + src/i18n/locales/uk.json | 3 + src/i18n/locales/zh-CN.json | 3 + src/i18n/locales/zh-Hant.json | 3 + src/i18n/same-as-english.json | 15 ++- src/layers/firms/queries.js | 128 +++++++++++++++------- src/layers/firms/rendering.js | 12 +- src/layers/firms/state.js | 5 + src/share/viewCodec.js | 6 +- src/ui/layerPanel.js | 19 ++-- src/ui/layerPanel/LayerRows.vue | 9 +- src/ui/layerPanel/layerRows.test.mjs | 66 +++++++---- 26 files changed, 449 insertions(+), 91 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index 6f2d11a33..2126e3c7f 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -4,6 +4,9 @@ log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / High confidence switch and an "N of M shown" counter to the layer row. Far zoom shows a Snap Map–style heatmap in the same colors instead of squares, - one distinct spot per fire, empty where nothing burns. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share - links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching - `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). + one distinct spot per fire, empty where nothing burns, with a ½×–4× spot + size slider. The filter applies once before rendering, so markers, cards, + the heatmap, context and the top-fires query agree; share links keep the + filter and the spot size (`w` token: `f`, `c`, `s`), and `get_active_fires` + takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All + / 1× (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 73ea42a0f..4b06c30d5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2205,14 +2205,19 @@ areas; their accent is the strongest fire's FRP color. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale whose track is painted with the ramp and ticked at the same six values (its value reads "All" or "≥ N MW"), a "Confidence" switch with All / Nominal+ / -High, and an "N of M shown" counter. The default is 0 MW and +High, a "Heatmap spot size" slider (½× to 4× on a log2 axis, 5 % steps, +default 1×) and an "N of M shown" counter. The spot size scales the heat +grid rather than the blur (2× is a 1024 × 512 grid with the same one-pixel +blur), so every spot grows exactly and weak lone fires do not fade as they +widen; changing it rebuilds only the heatmap, not the shown set. The default is 0 MW and All, because many of the strongest detections are rated low confidence. The filter runs once in `ingestion.applyFilter()` before rendering, so markers, cards, the heatmap, the shared context store, the top-fires query and the analyst records all see one filtered set; a selected fire that no longer passes -is deselected. The layer state keeps both options under the `w` (`local-firms`) -share token as `f` (whole megawatts, 0–10,000) and `c` (`a`, `n`, `h`), -including the short share codec; a link without them restores the defaults. +is deselected. The layer state keeps the options under the `w` (`local-firms`) +share token as `f` (whole megawatts, 0–10,000), `c` (`a`, `n`, `h`) and `s` +(spot size in percent, 50–400), including the short share codec; a link +without them restores the defaults. The `get_active_fires` tool accepts its own `min_frp_mw` and `confidence` (`all`, `nominal`, `high`) arguments, independent of the panel's setting, and diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js index 6704cf6e2..85894b19e 100644 --- a/src/data/firmsHeatRaster.js +++ b/src/data/firmsHeatRaster.js @@ -43,6 +43,70 @@ export function heatColorAt(t) { ]; } +/** + * Spot size, in percent of the shipped size: the panel slider and share + * option `w.s`. Whole percents, half to four times. + */ +export const HEAT_SPOT_SIZE_MIN = 50; +export const HEAT_SPOT_SIZE_MAX = 400; +export const HEAT_SPOT_SIZE_DEFAULT = 100; + +/** + * Spot size → whole percent inside the bounds, or null. + * @param {*} value + * @returns {?number} + */ +export function normalizeHeatSpotSize(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 >= HEAT_SPOT_SIZE_MIN && rounded <= HEAT_SPOT_SIZE_MAX + ? rounded + : null; +} + +/** + * The grid for a spot size. The same one-pixel blur on a grid that many + * times coarser (or finer) scales every spot exactly; widening the blur + * instead lowers each spot's peak, so weak lone fires faded as they grew. + * @param {{width: number, height: number, sigmaPx: number}} base Shipped grid. + * @param {number} spotSize Percent of the shipped size. + * @returns {{width: number, height: number, sigmaPx: number}} + */ +export function heatGridForSpotSize(base, spotSize) { + const percent = normalizeHeatSpotSize(spotSize) ?? HEAT_SPOT_SIZE_DEFAULT; + const height = Math.round((base.height * HEAT_SPOT_SIZE_DEFAULT) / percent); + return { width: height * 2, height, sigmaPx: base.sigmaPx }; +} + +/** + * Slider axis for the spot size: log2, so ½× and 2× sit equally far from 1×. + * Values snap to 5 % steps, which is also their share-link form. + */ +const SPOT_LOG_MIN = Math.log2(HEAT_SPOT_SIZE_MIN / 100); +const SPOT_LOG_SPAN = Math.log2(HEAT_SPOT_SIZE_MAX / 100) - SPOT_LOG_MIN; + +/** Track position in 0..1 for a spot size in percent. */ +export function sliderPositionForSpotSize(spotSize) { + const percent = normalizeHeatSpotSize(spotSize) ?? HEAT_SPOT_SIZE_DEFAULT; + return (Math.log2(percent / 100) - SPOT_LOG_MIN) / SPOT_LOG_SPAN; +} + +/** Spot size in percent for a track position in 0..1. */ +export function spotSizeForSliderPosition(position) { + const p = Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0; + const percent = 100 * Math.pow(2, SPOT_LOG_MIN + p * SPOT_LOG_SPAN); + return Math.min( + HEAT_SPOT_SIZE_MAX, + Math.max(HEAT_SPOT_SIZE_MIN, Math.round(percent / 5) * 5), + ); +} + /** Weight of a detection with no or zero FRP, in megawatts. */ export const HEAT_MIN_WEIGHT_MW = 0.5; diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs index 754493754..9fc0e02c8 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -7,6 +7,10 @@ import { binFires, blurGaussian, buildHeatRaster, + heatGridForSpotSize, + normalizeHeatSpotSize, + sliderPositionForSpotSize, + spotSizeForSliderPosition, } from './firmsHeatRaster.js'; const fire = (lat, lon, frp = 10) => ({ lat, lon, frp }); @@ -116,3 +120,60 @@ test('buildHeatRaster with no fires is fully transparent', () => { const r = buildHeatRaster([], { width: 8, height: 4, sigmaPx: 1 }); assert.ok(r.rgba.every((v) => v === 0)); }); + +test('a spot size scales the grid, not the blur', () => { + const base = { width: 2048, height: 1024, sigmaPx: 1 }; + assert.deepEqual(heatGridForSpotSize(base, 100), base); + assert.deepEqual(heatGridForSpotSize(base, 200), { + width: 1024, + height: 512, + sigmaPx: 1, + }); + assert.deepEqual(heatGridForSpotSize(base, 50), { + width: 4096, + height: 2048, + sigmaPx: 1, + }); + const odd = heatGridForSpotSize(base, 150); + assert.equal(odd.width, odd.height * 2, 'the grid stays 2:1'); + assert.deepEqual(heatGridForSpotSize(base, 9999), base, 'invalid → default'); +}); + +test('twice the spot size paints a lone fire twice as wide', () => { + const base = { width: 2048, height: 1024, sigmaPx: 1 }; + const fires = [fire(0, 0, 5)]; + for (let i = 0; i < 40; i++) fires.push(fire(-10, 25 + i * 0.05, 300)); + const widthKm = (spotSize) => { + const r = buildHeatRaster(fires, heatGridForSpotSize(base, spotSize)); + const y = Math.floor(r.height / 2); + let painted = 0; + for (let x = 0; x < r.width; x++) { + const lon = ((x + 0.5) / r.width) * 360 - 180; + if (Math.abs(lon) < 10 && r.rgba[(y * r.width + x) * 4 + 3] > 0) { + painted += 1; + } + } + return (painted * 40075) / r.width; + }; + const ratio = widthKm(200) / widthKm(100); + assert.ok(ratio > 1.7 && ratio < 2.3, `ratio ${ratio.toFixed(2)}`); +}); + +test('the spot-size slider is log2 with 1x a third along and 5 % steps', () => { + assert.equal(sliderPositionForSpotSize(50), 0); + assert.equal(sliderPositionForSpotSize(400), 1); + assert.ok(Math.abs(sliderPositionForSpotSize(100) - 1 / 3) < 1e-9); + assert.ok(Math.abs(sliderPositionForSpotSize(200) - 2 / 3) < 1e-9); + assert.equal(spotSizeForSliderPosition(0), 50); + assert.equal(spotSizeForSliderPosition(1), 400); + assert.equal(spotSizeForSliderPosition(1 / 3), 100); + assert.equal(spotSizeForSliderPosition(-1), 50); + for (let i = 0; i <= 100; i++) { + const percent = spotSizeForSliderPosition(i / 100); + assert.equal(percent % 5, 0, `${percent} at ${i}%`); + assert.equal(normalizeHeatSpotSize(percent), percent); + } + assert.equal(normalizeHeatSpotSize('150'), 150); + assert.equal(normalizeHeatSpotSize(49), null); + assert.equal(normalizeHeatSpotSize(''), null); +}); diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index 39f0382c8..cd901f729 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -467,3 +467,66 @@ test('far zoom drapes one heatmap and draws no rectangles; markers and disable h restoreDocument(); } }); + +test('the spot-size slider rebuilds only the heatmap, on a proportionally coarser grid', async () => { + const priorDocument = globalThis.document; + const grids = []; + globalThis.document = { + createElement: () => ({ + getContext: () => ({ + createImageData: (w, hh) => { + grids.push([w, hh]); + return { data: new Uint8ClampedArray(w * hh * 4) }; + }, + putImageData() {}, + createRadialGradient: () => ({ addColorStop() {} }), + fillRect() {}, + }), + toDataURL: () => 'data:image/png;base64,AAAA', + }), + }; + const h = harness({ + fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })], + withDataSource: true, + cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000), + }); + h.viewer.scene.primitives = { add() {}, remove() {}, raiseToTop() {}, contains: () => true }; + h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D }; + const collection = { + addImageryProvider: (provider) => ({ provider, show: true }), + remove() {}, + raiseToTop() {}, + }; + try { + h.layer.attachShellServices({ imageryHost: () => ({ kind: 'globe', collection }) }); + h.layer._renderLodForTest('global'); + assert.deepEqual(grids.at(-1), [2048, 1024]); + assert.deepEqual( + h.layer.getRowControls().sliders.map((slider) => slider.id), + ['min-frp', 'heat-spot-size'], + ); + const spot = h.layer.getRowControls().sliders[1]; + assert.equal(spot.valueText, '1×'); + assert.deepEqual(spot.params(1000), { spotSize: 400 }); + + let repaints = 0; + h.layer.setRowControlsListener(() => repaints++); + h.layer.setParams({ spotSize: 200 }); + assert.equal(h.layer.getParams().spotSize, 200); + assert.equal(h.layer.getParams().minFrp, 0, 'the filter is untouched'); + assert.equal(h.layer.getStats().count, 2, 'every fire still shown'); + assert.equal(repaints, 1, 'the row repaints its value'); + assert.equal(h.layer.getRowControls().sliders[1].valueText, '2×'); + // The rebuild trails a drag (HEAT_REBUILD_MS) and keeps the old drape meanwhile. + await new Promise((resolve) => setTimeout(resolve, 200)); + assert.deepEqual(grids.at(-1), [1024, 512]); + + h.layer.setParams({ spotSize: 'nope' }); + assert.equal(h.layer.getParams().spotSize, 200, 'invalid input is ignored'); + } finally { + h.layer.destroy(h.viewer); + h.cleanup(); + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + } +}); diff --git a/src/data/layerState.js b/src/data/layerState.js index c68a67e67..a049a80c5 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -298,7 +298,7 @@ function satelliteFilterOption() { } const OPTION_GROUPS = Object.freeze({ - // Active Fires intensity filter (HAL-676). `minFrp` is whole megawatts on + // Active Fires intensity filter and heatmap spot size (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 @@ -311,6 +311,9 @@ const OPTION_GROUPS = Object.freeze({ nominal: 'n', high: 'h', }), + // Heatmap spot size in percent; mirrors HEAT_SPOT_SIZE_* in + // firmsHeatRaster.js. + boundedIntegerOption('spotSize', 's', 100, { min: 50, max: 400 }), ]), traffic: Object.freeze([ enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], { diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 55d40dfe2..78b36c3a6 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -30,6 +30,11 @@ 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'; +import { + HEAT_SPOT_SIZE_DEFAULT, + HEAT_SPOT_SIZE_MAX, + HEAT_SPOT_SIZE_MIN, +} from './firmsHeatRaster.js'; function deferred() { let resolve; @@ -3019,9 +3024,14 @@ test('Active Fires: the intensity filter rides share token w and round-trips', ( assert.deepEqual(state.options['local-firms'], { minFrp: 0, confidence: 'all', + spotSize: 100, }); state.enabledLayerIds = ['local-firms']; - state.options['local-firms'] = { minFrp: 20, confidence: 'high' }; + state.options['local-firms'] = { + minFrp: 20, + confidence: 'high', + spotSize: 250, + }; const encoded = encode(state); assert.equal(new URLSearchParams(encoded).get('l'), 'w'); // Other owners write their own explicit defaults into `lo` too (flights 3D, @@ -3029,10 +3039,12 @@ test('Active Fires: the intensity filter rides share token w and round-trips', ( 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(' ')); + assert.ok(assignments.includes('w.s.250'), assignments.join(' ')); const decoded = decodeLayerStateParams(new URLSearchParams(encoded)); assert.deepEqual(decoded.options['local-firms'], { minFrp: 20, confidence: 'high', + spotSize: 250, }); assert.deepEqual( parseStoredLayerState(serializeStoredLayerState(state)).options[ @@ -3050,7 +3062,11 @@ test('Active Fires: the intensity filter rides share token w and round-trips', ( .filter((assignment) => assignment.startsWith('w.')).length, 0, ); - assert.deepEqual(firmsOptions(''), { minFrp: 0, confidence: 'all' }); + assert.deepEqual(firmsOptions(''), { + minFrp: 0, + confidence: 'all', + spotSize: 100, + }); }); test('Active Fires: the codec accepts whole megawatts in bounds and the three confidence codes', () => { @@ -3078,5 +3094,17 @@ test('Active Fires: the codec accepts whole megawatts in bounds and the three co assert.deepEqual(firmsOptions('w.c.n_w.f.5'), { minFrp: 5, confidence: 'nominal', + spotSize: 100, }); }); + +test('Active Fires: the codec accepts whole-percent spot sizes from half to four times', () => { + assert.equal(HEAT_SPOT_SIZE_MIN, 50); + assert.equal(HEAT_SPOT_SIZE_MAX, 400); + assert.equal(HEAT_SPOT_SIZE_DEFAULT, 100); + assert.equal(firmsOptions('w.s.50').spotSize, 50); + assert.equal(firmsOptions('w.s.400').spotSize, 400); + assert.equal(firmsOptions('w.s.45').spotSize, 100, 'out of bounds → default'); + assert.equal(firmsOptions('w.s.401').spotSize, 100); + assert.equal(firmsOptions('w.s.x').spotSize, 100); +}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 301b60207..47d3808cc 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3229,6 +3229,9 @@ "≥ {value} MW": "{value} MW على الأقل", "Confidence": "الثقة", "Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط", + "Heatmap spot size": "حجم بقع الخريطة الحرارية", + "Far-zoom heatmap spot size": "حجم بقع الخريطة الحرارية عند العرض البعيد", + "{value}×": "{value}×", "fire intensity filter|All": "الكل", "Detection confidence": "ثقة الرصد", "{shown} of {total} shown": "المعروضة: {shown} من {total}" diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 92c843948..1449ad502 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2949,6 +2949,9 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Konfidenz", "Minimum fire radiative power, megawatts": "Minimale Strahlungsleistung des Brandes in Megawatt", + "Heatmap spot size": "Fleckengröße der Heatmap", + "Far-zoom heatmap spot size": "Fleckengröße der Heatmap in der Fernansicht", + "{value}×": "{value}×", "fire intensity filter|All": "Alle", "Detection confidence": "Konfidenz der Erkennung", "{shown} of {total} shown": "{shown} von {total} angezeigt" diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3cc9f4456..ffbc0bceb 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2992,6 +2992,9 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confianza", "Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios", + "Heatmap spot size": "Tamaño de las manchas del mapa de calor", + "Far-zoom heatmap spot size": "Tamaño de las manchas del mapa de calor en la vista lejana", + "{value}×": "{value}×", "fire intensity filter|All": "Todo", "Detection confidence": "Confianza de la detección", "{shown} of {total} shown": "{shown} de {total} mostrados", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a95b158c9..050a93b1c 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3019,6 +3019,9 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confiance", "Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts", + "Heatmap spot size": "Taille des taches de la carte de chaleur", + "Far-zoom heatmap spot size": "Taille des taches de la carte de chaleur en vue éloignée", + "{value}×": "{value}×", "fire intensity filter|All": "Toutes", "Detection confidence": "Confiance de la détection", "{shown} of {total} shown": "{shown} sur {total} affichées" diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 74d9a4a39..bf3517df6 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3019,6 +3019,9 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confidenza", "Minimum fire radiative power, megawatts": "Potenza radiativa minima dell'incendio, in megawatt", + "Heatmap spot size": "Dimensione delle macchie della mappa di calore", + "Far-zoom heatmap spot size": "Dimensione delle macchie della mappa di calore nella vista da lontano", + "{value}×": "{value}×", "fire intensity filter|All": "Tutti", "Detection confidence": "Confidenza del rilevamento", "{shown} of {total} shown": "{shown} di {total} mostrati" diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index bc1477531..8f1c4dc6b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2879,6 +2879,9 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "信頼度", "Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)", + "Heatmap spot size": "ヒートマップの点の大きさ", + "Far-zoom heatmap spot size": "遠景ヒートマップの点の大きさ", + "{value}×": "{value}×", "fire intensity filter|All": "すべて", "Detection confidence": "検知の信頼度", "{shown} of {total} shown": "{total}件中{shown}件を表示" diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 837cbad93..6df69f579 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3019,6 +3019,9 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confiança", "Minimum fire radiative power, megawatts": "Potência radiativa mínima do fogo, em megawatts", + "Heatmap spot size": "Tamanho das manchas do mapa de calor", + "Far-zoom heatmap spot size": "Tamanho das manchas do mapa de calor na vista distante", + "{value}×": "{value}×", "fire intensity filter|All": "Todas", "Detection confidence": "Confiança da detecção", "{shown} of {total} shown": "{shown} de {total} exibidas" diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index d457abd24..9afd7eb6c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3062,6 +3062,9 @@ "≥ {value} MW": "≥ {value} МВт", "Confidence": "Достоверность", "Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты", + "Heatmap spot size": "Размер пятен тепловой карты", + "Far-zoom heatmap spot size": "Размер пятен тепловой карты на дальнем масштабе", + "{value}×": "{value}×", "fire intensity filter|All": "Все", "Detection confidence": "Достоверность детекции", "{shown} of {total} shown": "показано {shown} из {total}", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 3cf7e8f9e..83b9d5871 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3060,6 +3060,9 @@ "≥ {value} MW": "≥ {value} МВт", "Confidence": "Достовірність", "Minimum fire radiative power, megawatts": "Мінімальна потужність випромінювання пожежі, мегавати", + "Heatmap spot size": "Розмір плям теплової карти", + "Far-zoom heatmap spot size": "Розмір плям теплової карти на далекому масштабі", + "{value}×": "{value}×", "fire intensity filter|All": "Усі", "Detection confidence": "Достовірність виявлення", "{shown} of {total} shown": "показано {shown} з {total}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e46c8882b..b0764c1e2 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2852,6 +2852,9 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "置信度", "Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)", + "Heatmap spot size": "热力图光斑大小", + "Far-zoom heatmap spot size": "远景热力图光斑大小", + "{value}×": "{value}×", "fire intensity filter|All": "全部", "Detection confidence": "检测置信度", "{shown} of {total} shown": "显示 {shown} / {total}", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c534272b3..84aa385e7 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2879,6 +2879,9 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "置信度", "Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)", + "Heatmap spot size": "熱力圖光斑大小", + "Far-zoom heatmap spot size": "遠景熱力圖光斑大小", + "{value}×": "{value}×", "fire intensity filter|All": "全部", "Detection confidence": "檢測置信度", "{shown} of {total} shown": "顯示 {shown} / {total}" diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 0061d2941..b1dc842a7 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -31,6 +31,7 @@ "dB", "hPa", "{source} HTTP {status}", + "{value}×", "▲ {frp} MW" ], "de": [ @@ -196,6 +197,7 @@ "{value} KM DOWNRANGE", "{value} KM/H", "{value} KM/S", + "{value}×", "ΔN", "Ⅱ PAUSE", "≥ {value} MW", @@ -325,6 +327,7 @@ "{value} KM", "{value} KM/H", "{value} KM/S", + "{value}×", "≥ {value} MW", "ΔE", "ΔN", @@ -505,6 +508,7 @@ "{value} KM", "{value} KM/H", "{value} KM/S", + "{value}×", "ΔE", "ΔN", "Ⅱ PAUSE", @@ -677,6 +681,7 @@ "{value} KM/H", "{value} KM/S", "{value} conf", + "{value}×", "≥ {value} MW", "ΔE", "ΔN", @@ -727,6 +732,7 @@ "{source} HTTP {status}", "{speed} kn", "{type} SPD: {speed} HDG: {heading}", + "{value}×", "ΔE", "ΔN", "≥ {value} MW", @@ -861,6 +867,7 @@ "{value} KM/S", "ΔN", "▲ {frp} MW", + "{value}×", "≥ {value} MW" ], "ru": [ @@ -879,7 +886,8 @@ "S2 GhostMaps", "S2 · {name}", "VS", - "WHOT/BHOT" + "WHOT/BHOT", + "{value}×" ], "uk": [ "AIS: --", @@ -892,7 +900,8 @@ "NVG", "REC", "S2 GhostMaps", - "WHOT/BHOT" + "WHOT/BHOT", + "{value}×" ], "zh-CN": [ "1090 MHz", @@ -947,6 +956,7 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", + "{value}×", "≥ {value} MW", "ΔE", "ΔN", @@ -1005,6 +1015,7 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", + "{value}×", "≥ {value} MW", "ΔE", "ΔN", diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 797734597..b9577925e 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -8,6 +8,11 @@ import { rampTrackGradient, sliderPositionForFrp, } from '../../data/firmsIntensity.js'; +import { + normalizeHeatSpotSize, + sliderPositionForSpotSize, + spotSizeForSliderPosition, +} from '../../data/firmsHeatRaster.js'; import { formatNumber, t } from '../../i18n/index.js'; import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js'; @@ -23,6 +28,16 @@ export function createQueries({ }) { const { id, name, icon, source } = config; + /** Spot sizes marked under the spot-size slider, in percent. */ + const SPOT_SIZE_TICKS = [50, 100, 200, 400]; + + /** Spot-size label for a percent ("0.5×", "1×", "1.5×"). */ + function spotSizeLabel(percent) { + return t('{value}×', { + value: formatNumber(percent / 100, { maximumFractionDigits: 2 }), + }); + } + /** Whole-megawatt label for a slider value or tick ("0", "20", "1 000"). */ function frpLabel(frp) { return formatNumber(frp, { maximumFractionDigits: 0 }); @@ -83,12 +98,12 @@ export function createQueries({ updateInterval: REFRESH_INTERVAL_MS, /** - * The intensity filter as layer options (share token `w`, owner - * `local-firms` in layerState.js). - * @returns {{minFrp: number, confidence: string}} + * The intensity filter and the heatmap spot size as layer options (share + * token `w`, owner `local-firms` in layerState.js). + * @returns {{minFrp: number, confidence: string, spotSize: number}} */ getParams() { - return { ...layerState._filter }; + return { ...layerState._filter, spotSize: layerState._spotSize }; }, /** @@ -96,24 +111,36 @@ export function createQueries({ * 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 + * A spot-size change only marks the heat raster stale: the shown set, + * markers and cards stay as they are. + * @param {{minFrp?: number, confidence?: string, spotSize?: number}} 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; + const spotSize = + normalizeHeatSpotSize(params?.spotSize) ?? layerState._spotSize; + const filterChanged = + next.minFrp !== layerState._filter.minFrp || + next.confidence !== layerState._filter.confidence; + const spotChanged = spotSize !== layerState._spotSize; + if (!filterChanged && !spotChanged) return true; layerState._filter = next; - if (!layerState._allFires.length) return true; - components.ingestion.applyFilter(); - if ( - layerState._selectedFire && - !firePassesFilter(layerState._selectedFire, next) - ) { - components.selection.clearFireSelection(); + layerState._spotSize = spotSize; + if (!layerState._allFires.length) { + layerState._rowControlsListener?.(); + return true; + } + if (filterChanged) { + components.ingestion.applyFilter(); + if ( + layerState._selectedFire && + !firePassesFilter(layerState._selectedFire, next) + ) { + components.selection.clearFireSelection(); + } + } else { + layerState._heatRaster = null; } if (layerState._enabled) components.rendering.renderCurrentLod(true); layerState._rowControlsListener?.(); @@ -123,8 +150,8 @@ export function createQueries({ /** * 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. + * the legend), the All / Nominal+ / High confidence switch, the far-zoom + * heatmap spot-size slider and the "N of M shown" counter. */ getRowControls() { const { minFrp, confidence } = layerState._filter; @@ -133,27 +160,48 @@ export function createQueries({ const shown = layerState._count; const total = layerState._totalCount; return { - slider: { - id: 'min-frp', - label: t('Fire intensity (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), - }), - }, + sliders: [ + { + id: 'min-frp', + label: t('Fire intensity (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), + }), + }, + { + id: 'heat-spot-size', + label: t('Heatmap spot size'), + ariaLabel: t('Far-zoom heatmap spot size'), + valueText: spotSizeLabel(layerState._spotSize), + min: 0, + max: SLIDER_STEPS, + step: 1, + value: Math.round( + sliderPositionForSpotSize(layerState._spotSize) * SLIDER_STEPS, + ), + ticks: SPOT_SIZE_TICKS.map((percent) => ({ + at: sliderPositionForSpotSize(percent), + label: spotSizeLabel(percent), + })), + params: (value) => ({ + spotSize: spotSizeForSliderPosition(Number(value) / SLIDER_STEPS), + }), + }, + ], segmented: { id: 'confidence', label: t('Confidence'), diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 84a6eed36..dd91effec 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -1,7 +1,10 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; import { horizonOccluder } from '../../data/iconOrientation.js'; -import { buildHeatRaster } from '../../data/firmsHeatRaster.js'; +import { + buildHeatRaster, + heatGridForSpotSize, +} from '../../data/firmsHeatRaster.js'; import { LOD_LEVELS, CONTEXT_TOP_N, @@ -144,13 +147,16 @@ export function createRendering({ return layerState._heatImagery; } - /** Build the heat raster from the shown set and drape it. */ + /** Build the heat raster from the shown set at the chosen spot size and drape it. */ function rebuildHeatRaster() { clearTimeout(layerState._heatRebuildTimer); layerState._heatRebuildTimer = null; if (layerState._destroyed) return; - layerState._heatRaster = buildHeatRaster(layerState._fires, HEAT_RASTER); + layerState._heatRaster = buildHeatRaster( + layerState._fires, + heatGridForSpotSize(HEAT_RASTER, layerState._spotSize), + ); heatImagery().setRaster(layerState._heatRaster); } diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index 9a0b031b2..349fcd375 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { DEFAULT_FIRE_FILTER } from '../../data/firmsIntensity.js'; +import { HEAT_SPOT_SIZE_DEFAULT } from '../../data/firmsHeatRaster.js'; export function createFirmsState({ services, config }) { const layerState = {}; @@ -69,6 +70,10 @@ export function createFirmsState({ services, config }) { layerState._filter = { ...DEFAULT_FIRE_FILTER }; + /** Heatmap spot size, percent of the shipped size (firmsHeatRaster.js). */ + + layerState._spotSize = HEAT_SPOT_SIZE_DEFAULT; + /** Panel callback: repaint the row controls (counter) after a load. */ layerState._rowControlsListener = null; diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index 8ac00f51b..8f556ea1e 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -252,7 +252,11 @@ 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' }), + 'local-firms': Object.freeze({ + minFrp: 0, + confidence: 'all', + spotSize: 100, + }), traffic: Object.freeze({ roadMode: null }), 'weather-lightning': Object.freeze({ opacity: 'strong' }), 'weather-radar': Object.freeze({ opacity: 'strong' }), diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 3d2f62b26..466817ec3 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -430,7 +430,7 @@ export class LayerPanel { } /** - * A layer's row chips, one-of-many switch, slider, color legend, info line + * A layer's row chips, one-of-many switch, sliders, color legend, info line * and ordered list. The block * is hidden while the layer is off (or while a dependency owner has * surrendered it), so a quiet row stays quiet. Chips and list items are @@ -456,7 +456,7 @@ export class LayerPanel { })); const infoText = controls?.info ? String(controls.info) : ''; const segmented = controls?.segmented || null; - const slider = controls?.slider || null; + const sliders = Array.isArray(controls?.sliders) ? controls.sliders : []; // The info line, once shown, stays in place and hides when empty. if (infoText) row.info = true; return { @@ -465,10 +465,10 @@ export class LayerPanel { legend.length === 0 && !infoText && !segmented && - !slider, + sliders.length === 0, chips, segmented, - slider, + sliders, legend, info: row.info ? { text: infoText, title: controls?.infoTitle || '' } @@ -522,13 +522,10 @@ export class LayerPanel { // to params through the live descriptor on every tick. _moveSlider(id, sliderId, value, generation = this._generation) { if (this._destroyed || this._generation !== generation) return; - const slider = this._rowControlsFor(id)?.slider; - if ( - !slider || - slider.id !== sliderId || - typeof slider.params !== 'function' || - !this.isEnabled(id) - ) + const slider = this._rowControlsFor(id)?.sliders?.find( + (entry) => entry.id === sliderId, + ); + if (!slider || typeof slider.params !== 'function' || !this.isEnabled(id)) return; this.setLayerParams(id, slider.params(value), { origin: 'user' }); } diff --git a/src/ui/layerPanel/LayerRows.vue b/src/ui/layerPanel/LayerRows.vue index 5f5d7c97e..b8c272e24 100644 --- a/src/ui/layerPanel/LayerRows.vue +++ b/src/ui/layerPanel/LayerRows.vue @@ -63,11 +63,10 @@ defineProps({ :segmented="item.controls.segmented" :on-select="(optionId) => onChip(item.id, optionId)" /> ({ active: mode === active, })), }, - slider: { - id: 'min-frp', - label: 'Fire intensity (FRP)', - ariaLabel: 'Minimum fire radiative power, megawatts', - valueText: value ? `≥ ${value} MW` : 'All', - min: 0, - max: 1000, - step: 1, - value, - track: 'linear-gradient(90deg, #9e2a1b, #fff3b0)', - ticks: [ - { at: 0, label: '0' }, - { at: 0.5, label: '30' }, - ], - }, + sliders: [ + { + id: 'min-frp', + label: 'Fire intensity (FRP)', + ariaLabel: 'Minimum fire radiative power, megawatts', + valueText: value ? `≥ ${value} MW` : 'All', + min: 0, + max: 1000, + step: 1, + value, + track: 'linear-gradient(90deg, #9e2a1b, #fff3b0)', + ticks: [ + { at: 0, label: '0' }, + { at: 0.5, label: '30' }, + ], + }, + { + id: 'heat-spot-size', + label: 'Heatmap spot size', + ariaLabel: 'Far-zoom heatmap spot size', + valueText: '1×', + min: 0, + max: 1000, + value: 333, + ticks: [{ at: 1 / 3, label: '1×' }], + }, + ], legend: [], info: null, list: null, }, }); -test('a row switch and slider render their markup and report choices', () => { +test('a row switch and sliders render their markup and report choices', () => { document.body.replaceChildren(); const container = document.body.appendChild(document.createElement('div')); const chips = []; @@ -115,8 +127,13 @@ test('a row switch and slider render their markup and report choices', () => { segments[1].click(); assert.deepEqual(chips, [['fires', 'confidence-high']]); - const block = container.querySelector('.data-toggle-slider'); - assert.equal(block.dataset.sliderId, 'min-frp'); + const blocks = [...container.querySelectorAll('.data-toggle-slider')]; + assert.deepEqual( + blocks.map((entry) => entry.dataset.sliderId), + ['min-frp', 'heat-spot-size'], + 'sliders render in order, after the switch', + ); + const [block, spotBlock] = blocks; assert.equal( block.querySelector('.data-toggle-slider-label').textContent, 'Fire intensity (FRP)', @@ -147,7 +164,18 @@ test('a row switch and slider render their markup and report choices', () => { input.value = '120'; input.dispatchEvent(new Event('input', { bubbles: true })); - assert.deepEqual(moves, [['fires', 'min-frp', 120]]); + const spotInput = spotBlock.querySelector('input.data-toggle-slider-input'); + assert.equal( + spotInput.getAttribute('aria-label'), + 'Far-zoom heatmap spot size', + ); + assert.equal(spotInput.style.getPropertyValue('--slider-track'), ''); + spotInput.value = '700'; + spotInput.dispatchEvent(new Event('input', { bubbles: true })); + assert.deepEqual(moves, [ + ['fires', 'min-frp', 120], + ['fires', 'heat-spot-size', 700], + ]); // The refresh that tick triggers keeps the same nodes and updates the text. render(h(LayerRows, { items: [fireRow(120, 'high')], ...props }), container); From f01200b46769c595a85655c5fa2b53c3882b6ad3 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 20:41:50 +0400 Subject: [PATCH 18/24] Cross-fade Active Fires markers and heatmap; fix the spot size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The far-zoom heatmap now uses the ½× grid (4096 × 2048, ≈10 km per pixel, one-pixel blur), so a lone fire's spot matches its marker at the 3,000 km hand-off. The spot size slider and its share option are gone. Markers and heatmap are both kept built at every zoom and cross-fade by camera height between 2,400 and 3,600 km on every frame, so a fast zoom never shows a beat with neither. The heatmap fades by imagery alpha: hiding a Cesium imagery layer unmaps it from the globe tiles, and showing it again re-mapped them late, which was the gap at the band edge. Under the markers the heatmap is built 150 ms after the data or filter changes. --- changelog.d/1426-fire-intensity-filter.md | 10 +- docs/CURRENT-STATE.md | 29 ++-- src/data/firmsHeatRaster.js | 64 -------- src/data/firmsHeatRaster.test.mjs | 61 ------- src/data/firmsInteraction.test.mjs | 151 ++++++++++-------- src/data/layerState.js | 5 +- src/data/layerState.test.mjs | 32 +--- src/i18n/locales/ar.json | 3 - src/i18n/locales/de.json | 3 - src/i18n/locales/es.json | 3 - src/i18n/locales/fr.json | 3 - src/i18n/locales/it.json | 3 - src/i18n/locales/ja.json | 3 - src/i18n/locales/pt.json | 3 - src/i18n/locales/ru.json | 3 - src/i18n/locales/uk.json | 3 - src/i18n/locales/zh-CN.json | 3 - src/i18n/locales/zh-Hant.json | 3 - src/i18n/same-as-english.json | 15 +- src/layers/firms/heatImagery.js | 13 ++ src/layers/firms/heatImagery.test.mjs | 16 ++ src/layers/firms/lifecycle.js | 7 +- src/layers/firms/policy.js | 25 ++- src/layers/firms/queries.js | 141 ++++++---------- src/layers/firms/rendering.js | 186 ++++++++++++++-------- src/layers/firms/state.js | 9 +- src/layers/firms/viewport.js | 3 + src/share/viewCodec.js | 6 +- src/ui/layerPanel.js | 19 ++- src/ui/layerPanel/LayerRows.vue | 9 +- src/ui/layerPanel/layerRows.test.mjs | 66 +++----- 31 files changed, 372 insertions(+), 528 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index 2126e3c7f..2254b1169 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -4,9 +4,9 @@ log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / High confidence switch and an "N of M shown" counter to the layer row. Far zoom shows a Snap Map–style heatmap in the same colors instead of squares, - one distinct spot per fire, empty where nothing burns, with a ½×–4× spot - size slider. The filter applies once before rendering, so markers, cards, - the heatmap, context and the top-fires query agree; share links keep the - filter and the spot size (`w` token: `f`, `c`, `s`), and `get_active_fires` + one distinct spot per fire, empty where nothing burns, and cross-fades with + the markers around 3,000 km with no gap. The filter applies once before + rendering, so markers, cards, the heatmap, context and the top-fires query + agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All - / 1× (gekh, #1426). + (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 76cbd573a..8fdb5b582 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2209,37 +2209,36 @@ At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style heatmap instead of individual markers: the shown detections are linearly binned (each detection's weight shared between the four nearest pixel centres, so spots sit at the true position and stay round) on a -global 2048 × 1024 grid (≈20 km per pixel), each weighted by its FRP in megawatts (so heat is +global 4096 × 2048 grid (≈10 km per pixel), each weighted by its FRP in megawatts (so heat is radiated power, and raising the FRP slider does not dim areas where strong fires -burn), Gaussian-blurred by one pixel so each fire stays a distinct spot about -twice the size of its marker, +burn, and a cluster of detections reads brighter than one), Gaussian-blurred by one +pixel so each fire stays a distinct spot about the size of its marker, normalized on a log scale against the 99.5th percentile and mapped through the same hues, from a bright red edge through orange and gold to a pale white-hot core. The edge starts more than half opaque, so regions far below the hottest ones still read over forest and savanna imagery. Nothing is painted where nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt -on each data refresh or filter change (the first paint at once, later ones -150 ms after the last change) and hidden once markers take over below -~750 km. "N FIRES" cards from a 2° / 1° aggregation still label the hottest +on each data refresh or filter change (the first far-band paint at once, +later ones 150 ms after the last change). Heatmap and markers are both kept +built at every zoom and cross-fade by camera height between 2,400 and +3,600 km, every frame, so a fast zoom never shows a gap: the heatmap fades by +imagery `alpha` (a hidden Cesium imagery layer is unmapped from the globe +tiles and re-mapped late when shown), the markers by sprite alpha, and the +marker collection is hidden once the heatmap is whole. "N FIRES" cards from a 2° / 1° aggregation still label the hottest areas; their accent is the strongest fire's FRP color. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale whose track is painted with the ramp and ticked at the same six values (its value reads "All" or "≥ N MW"), a "Confidence" switch with All / Nominal+ / -High, a "Heatmap spot size" slider (½× to 4× on a log2 axis, 5 % steps, -default 1×) and an "N of M shown" counter. The spot size scales the heat -grid rather than the blur (2× is a 1024 × 512 grid with the same one-pixel -blur), so every spot grows exactly and weak lone fires do not fade as they -widen; changing it rebuilds only the heatmap, not the shown set. The default is 0 MW and +High, and an "N of M shown" counter. The default is 0 MW and All, because many of the strongest detections are rated low confidence. The filter runs once in `ingestion.applyFilter()` before rendering, so markers, cards, the heatmap, the shared context store, the top-fires query and the analyst records all see one filtered set; a selected fire that no longer passes -is deselected. The layer state keeps the options under the `w` (`local-firms`) -share token as `f` (whole megawatts, 0–10,000), `c` (`a`, `n`, `h`) and `s` -(spot size in percent, 50–400), including the short share codec; a link -without them restores the defaults. +is deselected. The layer state keeps both options under the `w` (`local-firms`) +share token as `f` (whole megawatts, 0–10,000) and `c` (`a`, `n`, `h`), +including the short share codec; a link without them restores the defaults. The `get_active_fires` tool accepts its own `min_frp_mw` and `confidence` (`all`, `nominal`, `high`) arguments, independent of the panel's setting, and diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js index 85894b19e..6704cf6e2 100644 --- a/src/data/firmsHeatRaster.js +++ b/src/data/firmsHeatRaster.js @@ -43,70 +43,6 @@ export function heatColorAt(t) { ]; } -/** - * Spot size, in percent of the shipped size: the panel slider and share - * option `w.s`. Whole percents, half to four times. - */ -export const HEAT_SPOT_SIZE_MIN = 50; -export const HEAT_SPOT_SIZE_MAX = 400; -export const HEAT_SPOT_SIZE_DEFAULT = 100; - -/** - * Spot size → whole percent inside the bounds, or null. - * @param {*} value - * @returns {?number} - */ -export function normalizeHeatSpotSize(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 >= HEAT_SPOT_SIZE_MIN && rounded <= HEAT_SPOT_SIZE_MAX - ? rounded - : null; -} - -/** - * The grid for a spot size. The same one-pixel blur on a grid that many - * times coarser (or finer) scales every spot exactly; widening the blur - * instead lowers each spot's peak, so weak lone fires faded as they grew. - * @param {{width: number, height: number, sigmaPx: number}} base Shipped grid. - * @param {number} spotSize Percent of the shipped size. - * @returns {{width: number, height: number, sigmaPx: number}} - */ -export function heatGridForSpotSize(base, spotSize) { - const percent = normalizeHeatSpotSize(spotSize) ?? HEAT_SPOT_SIZE_DEFAULT; - const height = Math.round((base.height * HEAT_SPOT_SIZE_DEFAULT) / percent); - return { width: height * 2, height, sigmaPx: base.sigmaPx }; -} - -/** - * Slider axis for the spot size: log2, so ½× and 2× sit equally far from 1×. - * Values snap to 5 % steps, which is also their share-link form. - */ -const SPOT_LOG_MIN = Math.log2(HEAT_SPOT_SIZE_MIN / 100); -const SPOT_LOG_SPAN = Math.log2(HEAT_SPOT_SIZE_MAX / 100) - SPOT_LOG_MIN; - -/** Track position in 0..1 for a spot size in percent. */ -export function sliderPositionForSpotSize(spotSize) { - const percent = normalizeHeatSpotSize(spotSize) ?? HEAT_SPOT_SIZE_DEFAULT; - return (Math.log2(percent / 100) - SPOT_LOG_MIN) / SPOT_LOG_SPAN; -} - -/** Spot size in percent for a track position in 0..1. */ -export function spotSizeForSliderPosition(position) { - const p = Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0; - const percent = 100 * Math.pow(2, SPOT_LOG_MIN + p * SPOT_LOG_SPAN); - return Math.min( - HEAT_SPOT_SIZE_MAX, - Math.max(HEAT_SPOT_SIZE_MIN, Math.round(percent / 5) * 5), - ); -} - /** Weight of a detection with no or zero FRP, in megawatts. */ export const HEAT_MIN_WEIGHT_MW = 0.5; diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs index 9fc0e02c8..754493754 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -7,10 +7,6 @@ import { binFires, blurGaussian, buildHeatRaster, - heatGridForSpotSize, - normalizeHeatSpotSize, - sliderPositionForSpotSize, - spotSizeForSliderPosition, } from './firmsHeatRaster.js'; const fire = (lat, lon, frp = 10) => ({ lat, lon, frp }); @@ -120,60 +116,3 @@ test('buildHeatRaster with no fires is fully transparent', () => { const r = buildHeatRaster([], { width: 8, height: 4, sigmaPx: 1 }); assert.ok(r.rgba.every((v) => v === 0)); }); - -test('a spot size scales the grid, not the blur', () => { - const base = { width: 2048, height: 1024, sigmaPx: 1 }; - assert.deepEqual(heatGridForSpotSize(base, 100), base); - assert.deepEqual(heatGridForSpotSize(base, 200), { - width: 1024, - height: 512, - sigmaPx: 1, - }); - assert.deepEqual(heatGridForSpotSize(base, 50), { - width: 4096, - height: 2048, - sigmaPx: 1, - }); - const odd = heatGridForSpotSize(base, 150); - assert.equal(odd.width, odd.height * 2, 'the grid stays 2:1'); - assert.deepEqual(heatGridForSpotSize(base, 9999), base, 'invalid → default'); -}); - -test('twice the spot size paints a lone fire twice as wide', () => { - const base = { width: 2048, height: 1024, sigmaPx: 1 }; - const fires = [fire(0, 0, 5)]; - for (let i = 0; i < 40; i++) fires.push(fire(-10, 25 + i * 0.05, 300)); - const widthKm = (spotSize) => { - const r = buildHeatRaster(fires, heatGridForSpotSize(base, spotSize)); - const y = Math.floor(r.height / 2); - let painted = 0; - for (let x = 0; x < r.width; x++) { - const lon = ((x + 0.5) / r.width) * 360 - 180; - if (Math.abs(lon) < 10 && r.rgba[(y * r.width + x) * 4 + 3] > 0) { - painted += 1; - } - } - return (painted * 40075) / r.width; - }; - const ratio = widthKm(200) / widthKm(100); - assert.ok(ratio > 1.7 && ratio < 2.3, `ratio ${ratio.toFixed(2)}`); -}); - -test('the spot-size slider is log2 with 1x a third along and 5 % steps', () => { - assert.equal(sliderPositionForSpotSize(50), 0); - assert.equal(sliderPositionForSpotSize(400), 1); - assert.ok(Math.abs(sliderPositionForSpotSize(100) - 1 / 3) < 1e-9); - assert.ok(Math.abs(sliderPositionForSpotSize(200) - 2 / 3) < 1e-9); - assert.equal(spotSizeForSliderPosition(0), 50); - assert.equal(spotSizeForSliderPosition(1), 400); - assert.equal(spotSizeForSliderPosition(1 / 3), 100); - assert.equal(spotSizeForSliderPosition(-1), 50); - for (let i = 0; i <= 100; i++) { - const percent = spotSizeForSliderPosition(i / 100); - assert.equal(percent % 5, 0, `${percent} at ${i}%`); - assert.equal(normalizeHeatSpotSize(percent), percent); - } - assert.equal(normalizeHeatSpotSize('150'), 150); - assert.equal(normalizeHeatSpotSize(49), null); - assert.equal(normalizeHeatSpotSize(''), null); -}); diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index cd901f729..fddf82f49 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -15,6 +15,7 @@ import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { HEAT_REBUILD_MS } from '../layers/firms/policy.js'; const LAYER_SOURCE = readLayerSource( path.join(path.dirname(fileURLToPath(import.meta.url)), 'firmsHeatmap.js'), @@ -393,7 +394,58 @@ test('disable cancels a pending source and a late response cannot repopulate the } finally { h.layer.destroy(h.viewer); h.cleanup(); } }); -test('far zoom drapes one heatmap and draws no rectangles; markers and disable hide it', () => { +test('the heatmap is preloaded under the markers before the first zoom-out', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const priorDocument = globalThis.document; + globalThis.document = { + createElement: () => ({ + getContext: () => ({ + createImageData: (w, hh) => ({ data: new Uint8ClampedArray(w * hh * 4) }), + putImageData() {}, + createRadialGradient: () => ({ addColorStop() {} }), + fillRect() {}, + }), + toDataURL: () => 'data:image/png;base64,AAAA', + }), + }; + const h = harness({ + withDataSource: true, + cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 1_000_000), + }); + h.viewer.camera = { positionCartographic: { height: 1_000_000 } }; + h.viewer.scene.primitives = { add() {}, remove() {}, raiseToTop() {}, contains: () => true }; + h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D }; + const added = []; + try { + h.layer.attachShellServices({ + imageryHost: () => ({ + kind: 'globe', + collection: { + addImageryProvider: (provider) => { + const layer = { provider, show: true, alpha: 1 }; + added.push(layer); + return layer; + }, + remove() {}, + raiseToTop() {}, + }, + }), + }); + h.layer._renderLodForTest('close'); + assert.equal(added.length, 0, 'the markers paint first; the build trails'); + t.mock.timers.tick(HEAT_REBUILD_MS); + assert.equal(added.length, 1, 'then the heatmap is draped'); + assert.equal(added[0].alpha, 0, 'invisible until the hand-off'); + assert.equal(added[0].show, true, 'but mapped onto the globe tiles'); + } finally { + h.layer.destroy(h.viewer); + h.cleanup(); + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + } +}); + +test('far zoom drapes one heatmap and the hand-off cross-fades it with the markers', () => { // A canvas stub for the heat drape and the detection glow sprites. const priorDocument = globalThis.document; const restoreDocument = () => { @@ -420,8 +472,16 @@ test('far zoom drapes one heatmap and draws no rectangles; markers and disable h }); const added = []; const removed = []; + let markers = null; + // The fade reads the camera height; the cull reads scene.camera. + const setHeight = (height) => { + h.viewer.camera = { positionCartographic: { height } }; + }; + setHeight(12_000_000); h.viewer.scene.primitives = { - add() {}, + add(primitive) { + markers = primitive; + }, remove() {}, raiseToTop() {}, contains: () => true, @@ -430,7 +490,7 @@ test('far zoom drapes one heatmap and draws no rectangles; markers and disable h try { const collection = { addImageryProvider: (provider) => { - const layer = { provider, show: true }; + const layer = { provider, show: true, alpha: 1 }; added.push(layer); return layer; }, @@ -443,17 +503,33 @@ test('far zoom drapes one heatmap and draws no rectangles; markers and disable h h.layer._renderLodForTest('global'); assert.equal(added.length, 1, 'one heat imagery layer'); assert.equal(added[0].show, true); + assert.equal(added[0].alpha, 1); assert.equal(h.layer._entityCountForTest(), 0, 'no rectangle entities'); assert.ok(h.published.length > 0, 'the far band still paints cards'); + assert.equal(markers.length, 2, 'the markers are built under the heatmap'); + assert.equal(markers.show, false, 'and hidden while the heatmap is whole'); h.layer._renderLodForTest('regional'); assert.equal(added.length, 1, 'switching far bands reuses the raster'); + setHeight(1_000_000); h.layer._renderLodForTest('close'); - assert.equal(added[0].show, false, 'the markers band hides the heatmap'); + assert.equal(added.length, 1, 'the markers band keeps the same heat layer'); + assert.equal(added[0].show, true, 'still mapped onto the globe tiles'); + assert.equal(added[0].alpha, 0, 'faded out under the markers'); + assert.equal(markers.show, true); + assert.equal(markers.get(0).color.alpha, 1); + + // Mid hand-off both are drawn, half each, in either band. + setHeight(3_000_000); + h.layer._applyHandoffFadeForTest(); + assert.equal(added[0].alpha, 0.5); + assert.equal(markers.show, true); + assert.equal(markers.get(0).color.alpha, 0.5); + h.layer._renderLodForTest('regional'); + assert.equal(added[0].alpha, 0.5); + assert.equal(markers.get(1).color.alpha, 0.5, 'rebuilt markers get the fade'); - h.layer._renderLodForTest('global'); - assert.equal(added[0].show, true); h.layer.disable(); assert.equal(added[0].show, false, 'disable hides the heatmap'); @@ -467,66 +543,3 @@ test('far zoom drapes one heatmap and draws no rectangles; markers and disable h restoreDocument(); } }); - -test('the spot-size slider rebuilds only the heatmap, on a proportionally coarser grid', async () => { - const priorDocument = globalThis.document; - const grids = []; - globalThis.document = { - createElement: () => ({ - getContext: () => ({ - createImageData: (w, hh) => { - grids.push([w, hh]); - return { data: new Uint8ClampedArray(w * hh * 4) }; - }, - putImageData() {}, - createRadialGradient: () => ({ addColorStop() {} }), - fillRect() {}, - }), - toDataURL: () => 'data:image/png;base64,AAAA', - }), - }; - const h = harness({ - fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })], - withDataSource: true, - cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000), - }); - h.viewer.scene.primitives = { add() {}, remove() {}, raiseToTop() {}, contains: () => true }; - h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D }; - const collection = { - addImageryProvider: (provider) => ({ provider, show: true }), - remove() {}, - raiseToTop() {}, - }; - try { - h.layer.attachShellServices({ imageryHost: () => ({ kind: 'globe', collection }) }); - h.layer._renderLodForTest('global'); - assert.deepEqual(grids.at(-1), [2048, 1024]); - assert.deepEqual( - h.layer.getRowControls().sliders.map((slider) => slider.id), - ['min-frp', 'heat-spot-size'], - ); - const spot = h.layer.getRowControls().sliders[1]; - assert.equal(spot.valueText, '1×'); - assert.deepEqual(spot.params(1000), { spotSize: 400 }); - - let repaints = 0; - h.layer.setRowControlsListener(() => repaints++); - h.layer.setParams({ spotSize: 200 }); - assert.equal(h.layer.getParams().spotSize, 200); - assert.equal(h.layer.getParams().minFrp, 0, 'the filter is untouched'); - assert.equal(h.layer.getStats().count, 2, 'every fire still shown'); - assert.equal(repaints, 1, 'the row repaints its value'); - assert.equal(h.layer.getRowControls().sliders[1].valueText, '2×'); - // The rebuild trails a drag (HEAT_REBUILD_MS) and keeps the old drape meanwhile. - await new Promise((resolve) => setTimeout(resolve, 200)); - assert.deepEqual(grids.at(-1), [1024, 512]); - - h.layer.setParams({ spotSize: 'nope' }); - assert.equal(h.layer.getParams().spotSize, 200, 'invalid input is ignored'); - } finally { - h.layer.destroy(h.viewer); - h.cleanup(); - if (priorDocument === undefined) delete globalThis.document; - else globalThis.document = priorDocument; - } -}); diff --git a/src/data/layerState.js b/src/data/layerState.js index a049a80c5..c68a67e67 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -298,7 +298,7 @@ function satelliteFilterOption() { } const OPTION_GROUPS = Object.freeze({ - // Active Fires intensity filter and heatmap spot size (HAL-676). `minFrp` is whole megawatts on + // 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 @@ -311,9 +311,6 @@ const OPTION_GROUPS = Object.freeze({ nominal: 'n', high: 'h', }), - // Heatmap spot size in percent; mirrors HEAT_SPOT_SIZE_* in - // firmsHeatRaster.js. - boundedIntegerOption('spotSize', 's', 100, { min: 50, max: 400 }), ]), traffic: Object.freeze([ enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], { diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 78b36c3a6..55d40dfe2 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -30,11 +30,6 @@ 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'; -import { - HEAT_SPOT_SIZE_DEFAULT, - HEAT_SPOT_SIZE_MAX, - HEAT_SPOT_SIZE_MIN, -} from './firmsHeatRaster.js'; function deferred() { let resolve; @@ -3024,14 +3019,9 @@ test('Active Fires: the intensity filter rides share token w and round-trips', ( assert.deepEqual(state.options['local-firms'], { minFrp: 0, confidence: 'all', - spotSize: 100, }); state.enabledLayerIds = ['local-firms']; - state.options['local-firms'] = { - minFrp: 20, - confidence: 'high', - spotSize: 250, - }; + 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, @@ -3039,12 +3029,10 @@ test('Active Fires: the intensity filter rides share token w and round-trips', ( 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(' ')); - assert.ok(assignments.includes('w.s.250'), assignments.join(' ')); const decoded = decodeLayerStateParams(new URLSearchParams(encoded)); assert.deepEqual(decoded.options['local-firms'], { minFrp: 20, confidence: 'high', - spotSize: 250, }); assert.deepEqual( parseStoredLayerState(serializeStoredLayerState(state)).options[ @@ -3062,11 +3050,7 @@ test('Active Fires: the intensity filter rides share token w and round-trips', ( .filter((assignment) => assignment.startsWith('w.')).length, 0, ); - assert.deepEqual(firmsOptions(''), { - minFrp: 0, - confidence: 'all', - spotSize: 100, - }); + assert.deepEqual(firmsOptions(''), { minFrp: 0, confidence: 'all' }); }); test('Active Fires: the codec accepts whole megawatts in bounds and the three confidence codes', () => { @@ -3094,17 +3078,5 @@ test('Active Fires: the codec accepts whole megawatts in bounds and the three co assert.deepEqual(firmsOptions('w.c.n_w.f.5'), { minFrp: 5, confidence: 'nominal', - spotSize: 100, }); }); - -test('Active Fires: the codec accepts whole-percent spot sizes from half to four times', () => { - assert.equal(HEAT_SPOT_SIZE_MIN, 50); - assert.equal(HEAT_SPOT_SIZE_MAX, 400); - assert.equal(HEAT_SPOT_SIZE_DEFAULT, 100); - assert.equal(firmsOptions('w.s.50').spotSize, 50); - assert.equal(firmsOptions('w.s.400').spotSize, 400); - assert.equal(firmsOptions('w.s.45').spotSize, 100, 'out of bounds → default'); - assert.equal(firmsOptions('w.s.401').spotSize, 100); - assert.equal(firmsOptions('w.s.x').spotSize, 100); -}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index b65608ed6..c96530641 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3224,9 +3224,6 @@ "≥ {value} MW": "{value} MW على الأقل", "Confidence": "الثقة", "Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط", - "Heatmap spot size": "حجم بقع الخريطة الحرارية", - "Far-zoom heatmap spot size": "حجم بقع الخريطة الحرارية عند العرض البعيد", - "{value}×": "{value}×", "fire intensity filter|All": "الكل", "Detection confidence": "ثقة الرصد", "{shown} of {total} shown": "المعروضة: {shown} من {total}", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 8bbec9f29..b4e02ea23 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2944,9 +2944,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Konfidenz", "Minimum fire radiative power, megawatts": "Minimale Strahlungsleistung des Brandes in Megawatt", - "Heatmap spot size": "Fleckengröße der Heatmap", - "Far-zoom heatmap spot size": "Fleckengröße der Heatmap in der Fernansicht", - "{value}×": "{value}×", "fire intensity filter|All": "Alle", "Detection confidence": "Konfidenz der Erkennung", "{shown} of {total} shown": "{shown} von {total} angezeigt", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3721c1ea0..ddfc90df9 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2993,9 +2993,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confianza", "Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios", - "Heatmap spot size": "Tamaño de las manchas del mapa de calor", - "Far-zoom heatmap spot size": "Tamaño de las manchas del mapa de calor en la vista lejana", - "{value}×": "{value}×", "fire intensity filter|All": "Todo", "Detection confidence": "Confianza de la detección", "{shown} of {total} shown": "{shown} de {total} mostrados", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 17c4dcf9d..d368bb387 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3014,9 +3014,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confiance", "Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts", - "Heatmap spot size": "Taille des taches de la carte de chaleur", - "Far-zoom heatmap spot size": "Taille des taches de la carte de chaleur en vue éloignée", - "{value}×": "{value}×", "fire intensity filter|All": "Toutes", "Detection confidence": "Confiance de la détection", "{shown} of {total} shown": "{shown} sur {total} affichées", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 8d43c74d7..9f1821423 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3014,9 +3014,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confidenza", "Minimum fire radiative power, megawatts": "Potenza radiativa minima dell'incendio, in megawatt", - "Heatmap spot size": "Dimensione delle macchie della mappa di calore", - "Far-zoom heatmap spot size": "Dimensione delle macchie della mappa di calore nella vista da lontano", - "{value}×": "{value}×", "fire intensity filter|All": "Tutti", "Detection confidence": "Confidenza del rilevamento", "{shown} of {total} shown": "{shown} di {total} mostrati", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0725812a4..192c52a27 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2874,9 +2874,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "信頼度", "Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)", - "Heatmap spot size": "ヒートマップの点の大きさ", - "Far-zoom heatmap spot size": "遠景ヒートマップの点の大きさ", - "{value}×": "{value}×", "fire intensity filter|All": "すべて", "Detection confidence": "検知の信頼度", "{shown} of {total} shown": "{total}件中{shown}件を表示", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 74316dc1b..0a6617eb8 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3014,9 +3014,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confiança", "Minimum fire radiative power, megawatts": "Potência radiativa mínima do fogo, em megawatts", - "Heatmap spot size": "Tamanho das manchas do mapa de calor", - "Far-zoom heatmap spot size": "Tamanho das manchas do mapa de calor na vista distante", - "{value}×": "{value}×", "fire intensity filter|All": "Todas", "Detection confidence": "Confiança da detecção", "{shown} of {total} shown": "{shown} de {total} exibidas", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index db69d0f4a..9551c1b52 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3063,9 +3063,6 @@ "≥ {value} MW": "≥ {value} МВт", "Confidence": "Достоверность", "Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты", - "Heatmap spot size": "Размер пятен тепловой карты", - "Far-zoom heatmap spot size": "Размер пятен тепловой карты на дальнем масштабе", - "{value}×": "{value}×", "fire intensity filter|All": "Все", "Detection confidence": "Достоверность детекции", "{shown} of {total} shown": "показано {shown} из {total}", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 4d05d26b6..bff311c79 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3055,9 +3055,6 @@ "≥ {value} MW": "≥ {value} МВт", "Confidence": "Достовірність", "Minimum fire radiative power, megawatts": "Мінімальна потужність випромінювання пожежі, мегавати", - "Heatmap spot size": "Розмір плям теплової карти", - "Far-zoom heatmap spot size": "Розмір плям теплової карти на далекому масштабі", - "{value}×": "{value}×", "fire intensity filter|All": "Усі", "Detection confidence": "Достовірність виявлення", "{shown} of {total} shown": "показано {shown} з {total}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e3a4762c5..1481226da 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2853,9 +2853,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "置信度", "Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)", - "Heatmap spot size": "热力图光斑大小", - "Far-zoom heatmap spot size": "远景热力图光斑大小", - "{value}×": "{value}×", "fire intensity filter|All": "全部", "Detection confidence": "检测置信度", "{shown} of {total} shown": "显示 {shown} / {total}", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index af214e6e7..cd182ecfd 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2880,9 +2880,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "置信度", "Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)", - "Heatmap spot size": "熱力圖光斑大小", - "Far-zoom heatmap spot size": "遠景熱力圖光斑大小", - "{value}×": "{value}×", "fire intensity filter|All": "全部", "Detection confidence": "檢測置信度", "{shown} of {total} shown": "顯示 {shown} / {total}", diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 9310e0d97..7aa51d344 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -31,7 +31,6 @@ "dB", "hPa", "{source} HTTP {status}", - "{value}×", "▲ {frp} MW" ], "de": [ @@ -198,7 +197,6 @@ "{value} KM DOWNRANGE", "{value} KM/H", "{value} KM/S", - "{value}×", "ΔN", "Ⅱ PAUSE", "≥ {value} MW", @@ -329,7 +327,6 @@ "{value} KM", "{value} KM/H", "{value} KM/S", - "{value}×", "≥ {value} MW", "ΔE", "ΔN", @@ -511,7 +508,6 @@ "{value} KM", "{value} KM/H", "{value} KM/S", - "{value}×", "ΔE", "ΔN", "Ⅱ PAUSE", @@ -685,7 +681,6 @@ "{value} KM/H", "{value} KM/S", "{value} conf", - "{value}×", "≥ {value} MW", "ΔE", "ΔN", @@ -736,7 +731,6 @@ "{source} HTTP {status}", "{speed} kn", "{type} SPD: {speed} HDG: {heading}", - "{value}×", "ΔE", "ΔN", "≥ {value} MW", @@ -871,7 +865,6 @@ "{value} KM/S", "ΔN", "▲ {frp} MW", - "{value}×", "≥ {value} MW" ], "ru": [ @@ -890,8 +883,7 @@ "S2 GhostMaps", "S2 · {name}", "VS", - "WHOT/BHOT", - "{value}×" + "WHOT/BHOT" ], "uk": [ "AIS: --", @@ -904,8 +896,7 @@ "NVG", "REC", "S2 GhostMaps", - "WHOT/BHOT", - "{value}×" + "WHOT/BHOT" ], "zh-CN": [ "1090 MHz", @@ -960,7 +951,6 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", - "{value}×", "≥ {value} MW", "ΔE", "ΔN", @@ -1019,7 +1009,6 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", - "{value}×", "≥ {value} MW", "ΔE", "ΔN", diff --git a/src/layers/firms/heatImagery.js b/src/layers/firms/heatImagery.js index 31fbf1f69..710a87f43 100644 --- a/src/layers/firms/heatImagery.js +++ b/src/layers/firms/heatImagery.js @@ -3,6 +3,12 @@ * imagery tile on the active imagery host (globe or 3D-tiles), like the wind * layer's scalar field. Owns the canvas and the imagery layer; nothing else * in the fires layer touches imagery. + * + * `show` is only for the layer being on or off. The zoom hand-off fades + * `alpha` instead: Cesium unmaps a hidden imagery layer from the globe + * tiles and re-maps it when shown, so a show toggle at the band edge left + * a beat with neither markers nor heat. At alpha 0 the layer stays mapped + * and costs no draw. */ const defaultCanvas = (width, height) => { @@ -21,6 +27,7 @@ export function createHeatImagery({ let layer = null; let collection = null; let show = true; + let opacity = 1; function detach() { if (layer && collection) collection.remove(layer, true); @@ -47,6 +54,7 @@ export function createHeatImagery({ collection = host.collection; layer = collection.addImageryProvider(provider); layer.show = show; + layer.alpha = opacity; collection.raiseToTop?.(layer); } @@ -63,6 +71,11 @@ export function createHeatImagery({ show = Boolean(value); if (layer) layer.show = show; }, + /** Hand-off fade, 0..1; the layer stays mapped at 0. */ + setOpacity(value) { + opacity = Math.min(1, Math.max(0, Number(value) || 0)); + if (layer) layer.alpha = opacity; + }, rehome() { const host = getHost?.(); if ((host?.collection ?? null) !== collection) attach(); diff --git a/src/layers/firms/heatImagery.test.mjs b/src/layers/firms/heatImagery.test.mjs index 5543a0691..27f45f55f 100644 --- a/src/layers/firms/heatImagery.test.mjs +++ b/src/layers/firms/heatImagery.test.mjs @@ -71,6 +71,22 @@ test('setShow toggles without rebuilding', () => { assert.equal(layer.show, true); }); +test('setOpacity fades the same layer and survives a rebuild', () => { + const h = harness(); + h.imagery.setRaster(raster); + const layer = h.layers[0]; + h.imagery.setOpacity(0); + assert.equal(layer.alpha, 0); + assert.equal(layer.show, true, 'a faded layer stays mapped'); + h.imagery.setOpacity(0.25); + assert.equal(h.layers[0], layer); + assert.equal(layer.alpha, 0.25); + h.imagery.setRaster(raster); + assert.equal(h.layers[0].alpha, 0.25, 'a rebuilt layer keeps the fade'); + h.imagery.setOpacity(7); + assert.equal(h.layers[0].alpha, 1); +}); + test('no imagery host keeps nothing draped; rehome brings it back', () => { const h = harness(); h.setHost({ collection: null, kind: 'none' }); diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 72434ad46..541efca16 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -1,6 +1,5 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; -import { LOD_LEVELS } from './policy.js'; export function createLifecycle({ layerState, @@ -55,8 +54,9 @@ export function createLifecycle({ layerState._billboards.show = true; } overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true); - if (LOD_LEVELS[layerState._currentLodIndex]?.mode === 'heat') - layerState._heatImagery?.setShow(true); + layerState._heatImagery?.setShow(true); + // The camera can have crossed the hand-off while the layer was off. + components.rendering.applyHandoffFade(true); components.viewport.installLodWatcher(); components.viewport.installMoveEndWatcher(); components.selection.installClickHandler(); @@ -119,6 +119,7 @@ export function createLifecycle({ layerState._heatImagery?.destroy(); layerState._heatImagery = null; layerState._heatRaster = null; + layerState._handoffHeat = -1; layerState._imageryHost = null; layerState._count = 0; layerState._totalCount = 0; diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 2af5d9f5c..6ba8ebb8e 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -49,20 +49,29 @@ export const LOD_LEVELS = [ ]; /** - * Global heat raster for the `heat` bands: ≈0.18° (≈20 km) per pixel and a - * one-pixel Gaussian σ, so a lone fire is a distinct spot about twice as wide - * as its marker at the 3,000 km hand-off and still reads from the - * whole-globe view; neighbours merge only when they nearly touch (σ 3 here - * smeared each fire over ~200 km). Built once per data refresh or filter - * change, never per camera move. + * Global heat raster for the `heat` bands: ≈0.09° (≈10 km) per pixel and a + * one-pixel Gaussian σ, so a lone fire is a spot about the size of its + * marker at the 3,000 km hand-off and the two cross-fade without a jump; + * neighbours merge only when they nearly touch (σ 3 on a 2048 grid smeared + * each fire over ~200 km). Built once per data refresh or filter change, + * never per camera move. */ export const HEAT_RASTER = Object.freeze({ - width: 2048, - height: 1024, + width: 4096, + height: 2048, sigmaPx: 1, }); +/** + * Camera heights, in metres, over which the heatmap and the markers + * cross-fade around the 3,000 km band edge: markers alone below `from`, + * heatmap alone above `to`. Wider than the ±10 % band hysteresis, so + * whichever band is active inside it, both layers are already built. + */ + +export const HANDOFF_FADE = Object.freeze({ from: 2_400_000, to: 3_600_000 }); + /** * Trailing delay before a stale heatmap is rebuilt while one is already on * screen: a slider drag fires many filter changes, and a build plus PNG diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index b9577925e..34040ea43 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -8,11 +8,6 @@ import { rampTrackGradient, sliderPositionForFrp, } from '../../data/firmsIntensity.js'; -import { - normalizeHeatSpotSize, - sliderPositionForSpotSize, - spotSizeForSliderPosition, -} from '../../data/firmsHeatRaster.js'; import { formatNumber, t } from '../../i18n/index.js'; import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js'; @@ -28,16 +23,6 @@ export function createQueries({ }) { const { id, name, icon, source } = config; - /** Spot sizes marked under the spot-size slider, in percent. */ - const SPOT_SIZE_TICKS = [50, 100, 200, 400]; - - /** Spot-size label for a percent ("0.5×", "1×", "1.5×"). */ - function spotSizeLabel(percent) { - return t('{value}×', { - value: formatNumber(percent / 100, { maximumFractionDigits: 2 }), - }); - } - /** Whole-megawatt label for a slider value or tick ("0", "20", "1 000"). */ function frpLabel(frp) { return formatNumber(frp, { maximumFractionDigits: 0 }); @@ -98,12 +83,12 @@ export function createQueries({ updateInterval: REFRESH_INTERVAL_MS, /** - * The intensity filter and the heatmap spot size as layer options (share - * token `w`, owner `local-firms` in layerState.js). - * @returns {{minFrp: number, confidence: string, spotSize: number}} + * The intensity filter as layer options (share token `w`, owner + * `local-firms` in layerState.js). + * @returns {{minFrp: number, confidence: string}} */ getParams() { - return { ...layerState._filter, spotSize: layerState._spotSize }; + return { ...layerState._filter }; }, /** @@ -111,36 +96,24 @@ export function createQueries({ * 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. - * A spot-size change only marks the heat raster stale: the shown set, - * markers and cards stay as they are. - * @param {{minFrp?: number, confidence?: string, spotSize?: number}} params + * @param {{minFrp?: number, confidence?: string}} params * @returns {boolean} True — the request is always accepted. */ setParams(params = {}) { const next = normalizeFireFilter(params, layerState._filter); - const spotSize = - normalizeHeatSpotSize(params?.spotSize) ?? layerState._spotSize; - const filterChanged = - next.minFrp !== layerState._filter.minFrp || - next.confidence !== layerState._filter.confidence; - const spotChanged = spotSize !== layerState._spotSize; - if (!filterChanged && !spotChanged) return true; - layerState._filter = next; - layerState._spotSize = spotSize; - if (!layerState._allFires.length) { - layerState._rowControlsListener?.(); + if ( + next.minFrp === layerState._filter.minFrp && + next.confidence === layerState._filter.confidence + ) return true; - } - if (filterChanged) { - components.ingestion.applyFilter(); - if ( - layerState._selectedFire && - !firePassesFilter(layerState._selectedFire, next) - ) { - components.selection.clearFireSelection(); - } - } else { - layerState._heatRaster = null; + 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?.(); @@ -150,8 +123,8 @@ export function createQueries({ /** * 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, the far-zoom - * heatmap spot-size slider and the "N of M shown" counter. + * the legend), the All / Nominal+ / High confidence switch, and the + * "N of M shown" counter. */ getRowControls() { const { minFrp, confidence } = layerState._filter; @@ -160,48 +133,27 @@ export function createQueries({ const shown = layerState._count; const total = layerState._totalCount; return { - sliders: [ - { - id: 'min-frp', - label: t('Fire intensity (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), - }), - }, - { - id: 'heat-spot-size', - label: t('Heatmap spot size'), - ariaLabel: t('Far-zoom heatmap spot size'), - valueText: spotSizeLabel(layerState._spotSize), - min: 0, - max: SLIDER_STEPS, - step: 1, - value: Math.round( - sliderPositionForSpotSize(layerState._spotSize) * SLIDER_STEPS, - ), - ticks: SPOT_SIZE_TICKS.map((percent) => ({ - at: sliderPositionForSpotSize(percent), - label: spotSizeLabel(percent), - })), - params: (value) => ({ - spotSize: spotSizeForSliderPosition(Number(value) / SLIDER_STEPS), - }), - }, - ], + slider: { + id: 'min-frp', + label: t('Fire intensity (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', label: t('Confidence'), @@ -359,10 +311,15 @@ export function createQueries({ /** Test seam: render one named LOD band against a near-global view. */ _renderLodForTest(lodId) { - const lod = LOD_LEVELS.find((level) => level.id === lodId); - if (lod.mode === 'detections') - components.rendering.renderDetections(lod, null); - else components.rendering.renderHeat(lod, null); + components.rendering.renderBand( + LOD_LEVELS.find((level) => level.id === lodId), + null, + ); + }, + + /** Test seam: one frame of the hand-off fade at the current camera. */ + _applyHandoffFadeForTest() { + components.rendering.applyHandoffFade(); }, /** Test seam: entities drawn by the layer's data source. */ diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index dd91effec..d6e8b2d3b 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -1,18 +1,22 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; import { horizonOccluder } from '../../data/iconOrientation.js'; -import { - buildHeatRaster, - heatGridForSpotSize, -} from '../../data/firmsHeatRaster.js'; +import { buildHeatRaster } from '../../data/firmsHeatRaster.js'; import { LOD_LEVELS, CONTEXT_TOP_N, + HANDOFF_FADE, HEAT_RASTER, HEAT_REBUILD_MS, } from './policy.js'; import { createHeatImagery } from './heatImagery.js'; +/** The markers drawn under the far bands' heatmap for the hand-off fade. */ +const HANDOFF_MARKER_LOD = LOD_LEVELS.find((lod) => lod.mode === 'detections'); + +/** Fade steps: a moving camera rewrites marker colors at most this often. */ +const FADE_STEPS = 32; + export function createRendering({ layerState, services, @@ -64,14 +68,54 @@ export function createRendering({ ? components.model.paddedDegreeBounds(viewRect) : null; - if (lod.mode === 'detections') { - renderDetections(lod, bounds); - } else { - renderHeat(lod, bounds); - } + renderBand(lod, bounds); return true; } + /** + * Draw one band. Markers and heatmap stay built in every band; the + * hand-off only fades between them. + * @param {Object} lod - Band descriptor. + * @param {?Object} bounds - Padded view bounds in degrees, or null. + */ + + function renderBand(lod, bounds) { + prepareHeat(lod.mode === 'heat'); + if (lod.mode === 'detections') renderDetections(lod, bounds); + else renderHeat(lod, bounds); + applyHandoffFade(true); + } + + const fadeColor = new Cesium.Color(); + + /** + * Cross-fade between markers and heatmap by camera height (HANDOFF_FADE), + * on every rendered frame, so a fast zoom never outruns the throttled band + * check. Cheap when nothing changed: one height read and a compare. + * @param {boolean} [force] Reapply, e.g. to freshly added markers. + */ + + function applyHandoffFade(force = false) { + const height = components.viewport.cameraHeight(); + const t = Math.min( + 1, + Math.max( + 0, + (height - HANDOFF_FADE.from) / (HANDOFF_FADE.to - HANDOFF_FADE.from), + ), + ); + const heat = Math.round(t * t * (3 - 2 * t) * FADE_STEPS) / FADE_STEPS; + if (!force && heat === layerState._handoffHeat) return; + layerState._handoffHeat = heat; + layerState._heatImagery?.setOpacity(heat); + const billboards = layerState._billboards; + if (!billboards) return; + billboards.show = layerState._enabled && heat < 1; + const color = Cesium.Color.WHITE.withAlpha(1 - heat, fadeColor); + for (let i = 0; i < billboards.length; i += 1) + billboards.get(i).color = color; + } + /** * Bin all detections into grid cells, then viewport-clip BEFORE the * top-N cap so fires in view never lose their slot to higher-scoring @@ -147,28 +191,27 @@ export function createRendering({ return layerState._heatImagery; } - /** Build the heat raster from the shown set at the chosen spot size and drape it. */ + /** Build the heat raster from the shown set and drape it. */ function rebuildHeatRaster() { clearTimeout(layerState._heatRebuildTimer); layerState._heatRebuildTimer = null; if (layerState._destroyed) return; - layerState._heatRaster = buildHeatRaster( - layerState._fires, - heatGridForSpotSize(HEAT_RASTER, layerState._spotSize), - ); + layerState._heatRaster = buildHeatRaster(layerState._fires, HEAT_RASTER); heatImagery().setRaster(layerState._heatRaster); } /** - * Make sure the draped heatmap matches the shown set. The first paint is - * immediate; later rebuilds (filter drags, refreshes) trail by - * HEAT_REBUILD_MS and keep the previous heatmap on screen meanwhile. + * Make sure the draped heatmap matches the shown set. The first paint in a + * far band is immediate; other builds (filter drags, refreshes, the + * preload under the markers) trail by HEAT_REBUILD_MS and keep the + * previous heatmap on screen meanwhile. + * @param {boolean} now Build at once when nothing is draped yet. */ - function ensureHeatRaster() { + function ensureHeatRaster(now) { if (layerState._heatRaster) return; - if (!heatImagery().hasRaster()) { + if (now && !heatImagery().hasRaster()) { rebuildHeatRaster(); return; } @@ -179,10 +222,25 @@ export function createRendering({ ); } + /** + * Keep the heatmap draped on the current map stack and current with the + * shown set in every band; the hand-off fade decides how much of it shows. + * @param {boolean} now The camera is in a far band. + */ + + function prepareHeat(now) { + const imagery = heatImagery(); + // The map stack may have changed since the last render. + imagery.rehome(); + ensureHeatRaster(now); + imagery.setShow(layerState._enabled); + } + /** * Far bands (global/regional): a Snap Map–style heatmap draped over the * globe, transparent where nothing burns, plus the decluttered "N FIRES" - * cards from the coarse aggregation. No entities and no sprites are drawn; + * cards from the coarse aggregation. The nearest band's markers are kept + * built underneath for the hand-off fade and hidden once it completes; * cards go through the same screen-space label pipeline as detections * (see {@link rebuildAmbientLabels}). * @param {Object} lod - Active LOD descriptor. @@ -190,17 +248,7 @@ export function createRendering({ */ function renderHeat(lod, bounds) { - ensureDetectionCollections(); - layerState._dataSource.entities.removeAll(); - if (layerState._billboards) layerState._billboards.removeAll(); - layerState._pickIndexById.clear(); - layerState._cullPositions.length = 0; - - const imagery = heatImagery(); - // The map stack may have changed since the last far render. - imagery.rehome(); - ensureHeatRaster(); - imagery.setShow(layerState._enabled); + paintMarkers(HANDOFF_MARKER_LOD, bounds); const cells = aggregateFires(lod, bounds); layerState._cellCount = cells.length; @@ -242,10 +290,48 @@ export function createRendering({ */ function renderDetections(lod, bounds) { - layerState._heatImagery?.setShow(false); + const markers = paintMarkers(lod, bounds); + if (!markers) return; + const candidates = markers.map((marker) => marker.fire); + + layerState._cellCount = candidates.length; + // FRP-sorted walk: marker order doubles as label priority. + layerState._labelCandidates = markers; + layerState._labelLodDistance = lod.labelDistance; + + components.cards.rebuildAmbientLabels(); + components.selection.refreshContextRegistrations( + candidates.slice(0, CONTEXT_TOP_N), + ); + + if (lod.id === 'close') { + // Batched (chunked ≤200, sequential, session-cached) DEM warm for the + // rendered subset; fires are static so each coarse cell resolves once + // ever. Re-render ONLY when a floor actually landed — a failed resolve + // reports false, so this chain terminates instead of looping against a + // down proxy (the next camera-driven rebuild retries). + warmFireAnchorFloors(candidates).then((warmed) => { + if (!warmed || !layerState._enabled || !layerState._viewer) return; + const currentLod = LOD_LEVELS[layerState._currentLodIndex]; + if (!currentLod || currentLod.mode !== 'detections') return; + renderCurrentLod(true); + }); + } + } + + /** + * The glow-sprite markers for a detections band: the strongest fires in + * view, capped at the band's maxDetections, horizon-culled. + * @param {Object} lod - Detections band descriptor. + * @param {?Object} bounds - Padded view bounds in degrees, or null. + * @returns {?Array} Label candidates for the fires drawn, + * strongest first; null before the sprite collection exists. + */ + + function paintMarkers(lod, bounds) { ensureDetectionCollections(); layerState._dataSource.entities.removeAll(); - if (!layerState._billboards) return; + if (!layerState._billboards) return null; layerState._billboards.removeAll(); layerState._pickIndexById.clear(); layerState._cullPositions.length = 0; @@ -267,10 +353,7 @@ export function createRendering({ candidates = layerState._firesByFrp.slice(0, lod.maxDetections); } - layerState._cellCount = candidates.length; - layerState._labelCandidates = []; - layerState._labelLodDistance = lod.labelDistance; - + const markers = []; for (const fire of candidates) { const coreSize = components.model.frpPixelSize(fire.frp); const position = components.model.firePosition(fire); @@ -278,6 +361,7 @@ export function createRendering({ const pickId = `firms-${fire.index}`; layerState._pickIndexById.set(pickId, fire); layerState._cullPositions.push(cullPosition); + markers.push({ position, cullPosition, fire, cell: null }); layerState._billboards.add({ id: pickId, position, @@ -289,37 +373,13 @@ export function createRendering({ horizontalOrigin: Cesium.HorizontalOrigin.CENTER, disableDepthTestDistance: Number.POSITIVE_INFINITY, }); - // FRP-sorted walk: candidate order doubles as label priority. - layerState._labelCandidates.push({ - position, - cullPosition, - fire, - cell: null, - }); } // Freshly-added sprites default to show=true; cull the far side now so a // rebuild never flashes through-the-planet detections while the camera // sits still (the moveEnd/preRender hooks only fire on camera motion). refreshHorizonCulling(); - components.cards.rebuildAmbientLabels(); - components.selection.refreshContextRegistrations( - candidates.slice(0, CONTEXT_TOP_N), - ); - - if (lod.id === 'close') { - // Batched (chunked ≤200, sequential, session-cached) DEM warm for the - // rendered subset; fires are static so each coarse cell resolves once - // ever. Re-render ONLY when a floor actually landed — a failed resolve - // reports false, so this chain terminates instead of looping against a - // down proxy (the next camera-driven rebuild retries). - warmFireAnchorFloors(candidates).then((warmed) => { - if (!warmed || !layerState._enabled || !layerState._viewer) return; - const currentLod = LOD_LEVELS[layerState._currentLodIndex]; - if (!currentLod || currentLod.mode !== 'detections') return; - renderCurrentLod(true); - }); - } + return markers; } function ensureDetectionCollections() { @@ -385,9 +445,11 @@ export function createRendering({ } return { renderCurrentLod, + renderBand, aggregateFires, renderHeat, renderDetections, + applyHandoffFade, ensureDetectionCollections, removeDetectionCollections, fireHorizonOccluder, diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index 349fcd375..0b96c53ea 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -1,6 +1,5 @@ import * as Cesium from 'cesium'; import { DEFAULT_FIRE_FILTER } from '../../data/firmsIntensity.js'; -import { HEAT_SPOT_SIZE_DEFAULT } from '../../data/firmsHeatRaster.js'; export function createFirmsState({ services, config }) { const layerState = {}; @@ -70,10 +69,6 @@ export function createFirmsState({ services, config }) { layerState._filter = { ...DEFAULT_FIRE_FILTER }; - /** Heatmap spot size, percent of the shipped size (firmsHeatRaster.js). */ - - layerState._spotSize = HEAT_SPOT_SIZE_DEFAULT; - /** Panel callback: repaint the row controls (counter) after a load. */ layerState._rowControlsListener = null; @@ -147,6 +142,10 @@ export function createFirmsState({ services, config }) { layerState._heatRebuildTimer = null; + /** Last applied hand-off fade (0 markers … 1 heatmap); -1 forces a reapply. */ + + layerState._handoffHeat = -1; + /** Imagery adapter (heatImagery.js), created on the first far render. */ layerState._heatImagery = null; diff --git a/src/layers/firms/viewport.js b/src/layers/firms/viewport.js index 932fe2e75..b32a02e37 100644 --- a/src/layers/firms/viewport.js +++ b/src/layers/firms/viewport.js @@ -151,6 +151,9 @@ export function createViewport({ // on screen throughout it — so the horizon pass below must keep running // while a fetch is in flight. Only the rebuild is gated on fresh data. if (!layerState._enabled) return; + // Every frame, ahead of the throttle: a fast zoom crosses the whole + // hand-off between two band checks. + components.rendering.applyHandoffFade(); const now = performance.now(); if (now - layerState._lastLodCheck < LOD_CHECK_MS) return; layerState._lastLodCheck = now; diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index 8f556ea1e..8ac00f51b 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -252,11 +252,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', - spotSize: 100, - }), + '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' }), diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 2327105be..d16a0c6ff 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -470,7 +470,7 @@ export class LayerPanel { } /** - * A layer's row chips, one-of-many switch, sliders, color legend, info line + * A layer's row chips, one-of-many switch, slider, color legend, info line * and ordered list. The block * is hidden while the layer is off (or while a dependency owner has * surrendered it), so a quiet row stays quiet. Chips and list items are @@ -496,7 +496,7 @@ export class LayerPanel { })); const infoText = controls?.info ? String(controls.info) : ''; const segmented = controls?.segmented || null; - const sliders = Array.isArray(controls?.sliders) ? controls.sliders : []; + const slider = controls?.slider || null; // The info line, once shown, stays in place and hides when empty. if (infoText) row.info = true; return { @@ -505,10 +505,10 @@ export class LayerPanel { legend.length === 0 && !infoText && !segmented && - sliders.length === 0, + !slider, chips, segmented, - sliders, + slider, legend, info: row.info ? { text: infoText, title: controls?.infoTitle || '' } @@ -562,10 +562,13 @@ export class LayerPanel { // to params through the live descriptor on every tick. _moveSlider(id, sliderId, value, generation = this._generation) { if (this._destroyed || this._generation !== generation) return; - const slider = this._rowControlsFor(id)?.sliders?.find( - (entry) => entry.id === sliderId, - ); - if (!slider || typeof slider.params !== 'function' || !this.isEnabled(id)) + const slider = this._rowControlsFor(id)?.slider; + if ( + !slider || + slider.id !== sliderId || + typeof slider.params !== 'function' || + !this.isEnabled(id) + ) return; this.setLayerParams(id, slider.params(value), { origin: 'user' }); } diff --git a/src/ui/layerPanel/LayerRows.vue b/src/ui/layerPanel/LayerRows.vue index b8c272e24..5f5d7c97e 100644 --- a/src/ui/layerPanel/LayerRows.vue +++ b/src/ui/layerPanel/LayerRows.vue @@ -63,10 +63,11 @@ defineProps({ :segmented="item.controls.segmented" :on-select="(optionId) => onChip(item.id, optionId)" /> ({ active: mode === active, })), }, - sliders: [ - { - id: 'min-frp', - label: 'Fire intensity (FRP)', - ariaLabel: 'Minimum fire radiative power, megawatts', - valueText: value ? `≥ ${value} MW` : 'All', - min: 0, - max: 1000, - step: 1, - value, - track: 'linear-gradient(90deg, #9e2a1b, #fff3b0)', - ticks: [ - { at: 0, label: '0' }, - { at: 0.5, label: '30' }, - ], - }, - { - id: 'heat-spot-size', - label: 'Heatmap spot size', - ariaLabel: 'Far-zoom heatmap spot size', - valueText: '1×', - min: 0, - max: 1000, - value: 333, - ticks: [{ at: 1 / 3, label: '1×' }], - }, - ], + slider: { + id: 'min-frp', + label: 'Fire intensity (FRP)', + ariaLabel: 'Minimum fire radiative power, megawatts', + valueText: value ? `≥ ${value} MW` : 'All', + min: 0, + max: 1000, + step: 1, + value, + track: 'linear-gradient(90deg, #9e2a1b, #fff3b0)', + ticks: [ + { at: 0, label: '0' }, + { at: 0.5, label: '30' }, + ], + }, legend: [], info: null, list: null, }, }); -test('a row switch and sliders render their markup and report choices', () => { +test('a row switch and slider render their markup and report choices', () => { document.body.replaceChildren(); const container = document.body.appendChild(document.createElement('div')); const chips = []; @@ -127,13 +115,8 @@ test('a row switch and sliders render their markup and report choices', () => { segments[1].click(); assert.deepEqual(chips, [['fires', 'confidence-high']]); - const blocks = [...container.querySelectorAll('.data-toggle-slider')]; - assert.deepEqual( - blocks.map((entry) => entry.dataset.sliderId), - ['min-frp', 'heat-spot-size'], - 'sliders render in order, after the switch', - ); - const [block, spotBlock] = blocks; + const block = container.querySelector('.data-toggle-slider'); + assert.equal(block.dataset.sliderId, 'min-frp'); assert.equal( block.querySelector('.data-toggle-slider-label').textContent, 'Fire intensity (FRP)', @@ -164,18 +147,7 @@ test('a row switch and sliders render their markup and report choices', () => { input.value = '120'; input.dispatchEvent(new Event('input', { bubbles: true })); - const spotInput = spotBlock.querySelector('input.data-toggle-slider-input'); - assert.equal( - spotInput.getAttribute('aria-label'), - 'Far-zoom heatmap spot size', - ); - assert.equal(spotInput.style.getPropertyValue('--slider-track'), ''); - spotInput.value = '700'; - spotInput.dispatchEvent(new Event('input', { bubbles: true })); - assert.deepEqual(moves, [ - ['fires', 'min-frp', 120], - ['fires', 'heat-spot-size', 700], - ]); + assert.deepEqual(moves, [['fires', 'min-frp', 120]]); // The refresh that tick triggers keeps the same nodes and updates the text. render(h(LayerRows, { items: [fireRow(120, 'high')], ...props }), container); From 9a1bc3404287d12f71eec0df030b8038a89e9e11 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 21:09:09 +0400 Subject: [PATCH 19/24] Keep weak Active Fires as markers through the hand-off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 750–3,000 km markers band now draws up to 20,000 detections instead of 2,500. At the 2,400 km end of the cross-fade the padded view over Africa holds ~21,000 fires, and the old cap kept only those above ~17 MW, so most heat spots faded into empty ground as the heatmap went out. A rebuild of that many costs ~60 ms, and only in the densest views. --- changelog.d/1426-fire-intensity-filter.md | 2 +- docs/CURRENT-STATE.md | 5 ++++- src/layers/firms/policy.js | 6 +++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index 2254b1169..d22e3f843 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -5,7 +5,7 @@ High confidence switch and an "N of M shown" counter to the layer row. Far zoom shows a Snap Map–style heatmap in the same colors instead of squares, one distinct spot per fire, empty where nothing burns, and cross-fades with - the markers around 3,000 km with no gap. The filter applies once before + the markers around 3,000 km with no gap, keeping weak fires as markers. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8fdb5b582..84325e63d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2225,7 +2225,10 @@ built at every zoom and cross-fade by camera height between 2,400 and 3,600 km, every frame, so a fast zoom never shows a gap: the heatmap fades by imagery `alpha` (a hidden Cesium imagery layer is unmapped from the globe tiles and re-mapped late when shown), the markers by sprite alpha, and the -marker collection is hidden once the heatmap is whole. "N FIRES" cards from a 2° / 1° aggregation still label the hottest +marker collection is hidden once the heatmap is whole. The 750–3,000 km +markers band draws up to 20,000 detections (the strongest in the padded +view), so the weak fires the heatmap shows stay on screen as markers when it +fades out. "N FIRES" cards from a 2° / 1° aggregation still label the hottest areas; their accent is the strongest fire's FRP color. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 6ba8ebb8e..8431655bd 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -33,10 +33,14 @@ export const LOD_LEVELS = [ labelDistance: 8500000, }, { + // Up to 20,000 markers: at the 2,400 km end of the hand-off the padded + // view over Africa, the densest region, holds ~21,000 detections, and a + // tighter cap left the heatmap's weak spots dissolving into nothing. + // A rebuild of that many costs ~60 ms, only there. id: 'local', minHeight: 750000, mode: 'detections', - maxDetections: 2500, + maxDetections: 20000, labelDistance: 4500000, }, { From f608ae81e7691835628735400f5484af48096509 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 21:24:50 +0400 Subject: [PATCH 20/24] Move the Active Fires hand-off up to 4,500 km The heatmap now takes over above 4,500 km instead of 3,000 km, with the cross-fade between 3,600 and 5,400 km. The markers band caps at 40,000 detections, which covers the ~40,000 in the padded view over Africa at the low end of the fade, so weak fires still stay as markers. To keep that rebuild near its old cost, markers outside the close band read the anchor already built for each fire instead of the ground floor cache: only the close band warms floors, so the lookup there always missed. A 40,000-marker rebuild now costs ~55 ms, down from ~120 ms. --- changelog.d/1426-fire-intensity-filter.md | 2 +- docs/CURRENT-STATE.md | 13 +++++++------ src/data/firmsInteraction.test.mjs | 2 +- src/layers/firms/model.js | 18 ++++++++++++++++-- src/layers/firms/policy.js | 16 ++++++++-------- src/layers/firms/rendering.js | 9 +++++++-- 6 files changed, 40 insertions(+), 20 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index d22e3f843..aacc37d71 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -5,7 +5,7 @@ High confidence switch and an "N of M shown" counter to the layer row. Far zoom shows a Snap Map–style heatmap in the same colors instead of squares, one distinct spot per fire, empty where nothing burns, and cross-fades with - the markers around 3,000 km with no gap, keeping weak fires as markers. The filter applies once before + the markers around 4,500 km with no gap, keeping weak fires as markers. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 84325e63d..934376fc7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2205,7 +2205,7 @@ none is a cold hue, so the brightest marks are the strongest fires; ~90% of detections are under 20 MW and stay dim red. Glow sprites and fire-card accents read that ramp; confidence and brightness do not affect the color. -At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style +At far zoom (camera above ~4,500 km) the layer drapes a Snap Map–style heatmap instead of individual markers: the shown detections are linearly binned (each detection's weight shared between the four nearest pixel centres, so spots sit at the true position and stay round) on a @@ -2221,14 +2221,15 @@ nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt on each data refresh or filter change (the first far-band paint at once, later ones 150 ms after the last change). Heatmap and markers are both kept -built at every zoom and cross-fade by camera height between 2,400 and -3,600 km, every frame, so a fast zoom never shows a gap: the heatmap fades by +built at every zoom and cross-fade by camera height between 3,600 and +5,400 km, every frame, so a fast zoom never shows a gap: the heatmap fades by imagery `alpha` (a hidden Cesium imagery layer is unmapped from the globe tiles and re-mapped late when shown), the markers by sprite alpha, and the -marker collection is hidden once the heatmap is whole. The 750–3,000 km -markers band draws up to 20,000 detections (the strongest in the padded +marker collection is hidden once the heatmap is whole. The 750–4,500 km +markers band draws up to 40,000 detections (the strongest in the padded view), so the weak fires the heatmap shows stay on screen as markers when it -fades out. "N FIRES" cards from a 2° / 1° aggregation still label the hottest +fades out; it reads each marker's already-built anchor instead of the ground +floor cache, which only the close band warms. "N FIRES" cards from a 2° / 1° aggregation still label the hottest areas; their accent is the strongest fire's FRP color. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index fddf82f49..8e12a435d 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -521,7 +521,7 @@ test('far zoom drapes one heatmap and the hand-off cross-fades it with the marke assert.equal(markers.get(0).color.alpha, 1); // Mid hand-off both are drawn, half each, in either band. - setHeight(3_000_000); + setHeight(4_500_000); h.layer._applyHandoffFadeForTest(); assert.equal(added[0].alpha, 0.5); assert.equal(markers.show, true); diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index aefb94b21..812f4f462 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -231,6 +231,19 @@ export function createModel({ return fire.position; } + /** + * The anchor already built for a detection, without the floor lookup; + * for the far marker band, where floors are never warmed and the height + * difference is invisible, so tens of thousands of markers skip a cache + * read each per rebuild. Builds one through {@link firePosition} once. + * @param {Object} fire - Detection record. + * @returns {Cesium.Cartesian3} + */ + + function fireBuiltPosition(fire) { + return fire.position ?? firePosition(fire); + } + /** * Occlusion-test anchor for one detection. Returns the render position * outright for anchors comfortably above the ellipsoid (the common case) and @@ -238,11 +251,11 @@ export function createModel({ * NEVER feeds rendering: the datum-correct anchor from {@link firePosition} * is what the sprite and the card are drawn at. * @param {Object} fire - Detection record. + * @param {Cesium.Cartesian3} [position] Its render anchor, if at hand. * @returns {Cesium.Cartesian3} */ - function fireCullPosition(fire) { - const position = firePosition(fire); + function fireCullPosition(fire, position = firePosition(fire)) { if (fire.positionHeight >= CULL_LIFT_THRESHOLD_M) return position; if (!fire.cullPosition) { fire.cullPosition = Cesium.Cartesian3.fromDegrees( @@ -587,6 +600,7 @@ export function createModel({ sizeBucket, glowSprite, firePosition, + fireBuiltPosition, fireCullPosition, cellCullPosition, applyHorizonCull, diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 8431655bd..e6f4bd664 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -7,7 +7,7 @@ import * as Cesium from 'cesium'; export const REFRESH_INTERVAL_MS = 600_000; /** - * LOD bands keyed by camera height. `heat` bands (above ~3,000 km) drape a + * LOD bands keyed by camera height. `heat` bands (above ~4,500 km) drape a * Snap Map–style heatmap over the globe (firmsHeatRaster.js); their * `gridDegrees` aggregation only feeds the "N FIRES" cards. `detections` * bands render individual fire detections as glow-sprite billboards. @@ -26,21 +26,21 @@ export const LOD_LEVELS = [ }, { id: 'regional', - minHeight: 3000000, + minHeight: 4500000, mode: 'heat', gridDegrees: 1.0, maxCells: 3600, labelDistance: 8500000, }, { - // Up to 20,000 markers: at the 2,400 km end of the hand-off the padded - // view over Africa, the densest region, holds ~21,000 detections, and a + // Up to 40,000 markers: at the 3,600 km end of the hand-off the padded + // view over Africa, the densest region, holds ~40,000 detections, and a // tighter cap left the heatmap's weak spots dissolving into nothing. // A rebuild of that many costs ~60 ms, only there. id: 'local', minHeight: 750000, mode: 'detections', - maxDetections: 20000, + maxDetections: 40000, labelDistance: 4500000, }, { @@ -55,7 +55,7 @@ export const LOD_LEVELS = [ /** * Global heat raster for the `heat` bands: ≈0.09° (≈10 km) per pixel and a * one-pixel Gaussian σ, so a lone fire is a spot about the size of its - * marker at the 3,000 km hand-off and the two cross-fade without a jump; + * marker near the hand-off and the two cross-fade without a jump; * neighbours merge only when they nearly touch (σ 3 on a 2048 grid smeared * each fire over ~200 km). Built once per data refresh or filter change, * never per camera move. @@ -69,12 +69,12 @@ export const HEAT_RASTER = Object.freeze({ /** * Camera heights, in metres, over which the heatmap and the markers - * cross-fade around the 3,000 km band edge: markers alone below `from`, + * cross-fade around the 4,500 km band edge: markers alone below `from`, * heatmap alone above `to`. Wider than the ±10 % band hysteresis, so * whichever band is active inside it, both layers are already built. */ -export const HANDOFF_FADE = Object.freeze({ from: 2_400_000, to: 3_600_000 }); +export const HANDOFF_FADE = Object.freeze({ from: 3_600_000, to: 5_400_000 }); /** * Trailing delay before a stale heatmap is rebuilt while one is already on diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index d6e8b2d3b..9718470f8 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -354,10 +354,15 @@ export function createRendering({ } const markers = []; + // Only the close band warms terrain floors (renderDetections). + const anchor = + lod.id === 'close' + ? components.model.firePosition + : components.model.fireBuiltPosition; for (const fire of candidates) { const coreSize = components.model.frpPixelSize(fire.frp); - const position = components.model.firePosition(fire); - const cullPosition = components.model.fireCullPosition(fire); + const position = anchor(fire); + const cullPosition = components.model.fireCullPosition(fire, position); const pickId = `firms-${fire.index}`; layerState._pickIndexById.set(pickId, fire); layerState._cullPositions.push(cullPosition); From 37e751f1e33c179520cecb43ed64681ca55c5369 Mon Sep 17 00:00:00 2001 From: Adrian Sbardella Date: Tue, 3 Nov 2026 21:06:56 -0300 Subject: [PATCH 21/24] fix(fires): keep retained heat hidden when reenabling an empty filter Add a lifecycle regression and register it in the format scope. --- docs/CURRENT-STATE.md | 2 ++ scripts/format-scope.json | 1 + src/layers/firms/heatVisibility.test.mjs | 44 ++++++++++++++++++++++++ src/layers/firms/lifecycle.js | 3 +- 4 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/layers/firms/heatVisibility.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 66dd7068c..c67967183 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2231,6 +2231,8 @@ view), so the weak fires the heatmap shows stay on screen as markers when it fades out; it reads each marker's already-built anchor instead of the ground floor cache, which only the close band warms. "N FIRES" cards from a 2° / 1° aggregation still label the hottest areas; their accent is the strongest fire's FRP color. +Re-enabling Active Fires keeps the retained heat imagery hidden when the current +filtered set is empty, including while a new snapshot is pending. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale whose track is painted with the ramp and ticked at the same six values (its diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 3df4155f2..606314960 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -408,6 +408,7 @@ "src/layers/firms/cards.js", "src/layers/firms/index.js", "src/layers/firms/ingestion.js", + "src/layers/firms/heatVisibility.test.mjs", "src/layers/firms/lifecycle.js", "src/layers/firms/model.js", "src/layers/firms/policy.js", diff --git a/src/layers/firms/heatVisibility.test.mjs b/src/layers/firms/heatVisibility.test.mjs new file mode 100644 index 000000000..4926db156 --- /dev/null +++ b/src/layers/firms/heatVisibility.test.mjs @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createLifecycle } from './lifecycle.js'; + +test('reenabling an empty filtered layer never reveals the retained pre-filter heat raster', async () => { + let shown = false; + const noop = () => {}; + const layerState = { + _enabled: false, + _viewer: {}, + _dataSource: {}, + _fires: [], + _loading: true, + _pickIndexById: new Map(), + _heatImagery: { + setShow(v) { + shown = v; + }, + }, + }; + const lifecycle = createLifecycle({ + layerState, + services: { + picking: { registerPickOwner: noop, unregisterPickOwner: noop }, + sprites: { restoreSpriteOrderOnEnable: noop }, + context: { clearSelectedEntityContextForLayer: noop }, + }, + config: { id: 'local-firms', overlayHost: { setVisible: noop } }, + components: { + rendering: { applyHandoffFade: noop }, + viewport: { installLodWatcher: noop, installMoveEndWatcher: noop }, + selection: { installClickHandler: noop }, + }, + }); + await lifecycle.methods.enable(layerState._viewer); + assert.equal( + shown, + false, + 'empty current set must keep the old raster hidden while refresh is pending', + ); + layerState._fires = [{ frp: null, confidence: null }]; + await lifecycle.methods.enable(layerState._viewer); + assert.equal(shown, true, 'nonempty sets still resume their raster'); +}); diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 541efca16..90d9cc217 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -54,7 +54,8 @@ export function createLifecycle({ layerState._billboards.show = true; } overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true); - layerState._heatImagery?.setShow(true); + // A filter can leave an old raster cached while the current set is empty. + layerState._heatImagery?.setShow(layerState._fires.length > 0); // The camera can have crossed the hand-off while the layer was off. components.rendering.applyHandoffFade(true); components.viewport.installLodWatcher(); From 19e85941e63fad99439e77739137666f686bff45 Mon Sep 17 00:00:00 2001 From: Adrian Sbardella Date: Tue, 3 Nov 2026 21:07:29 -0300 Subject: [PATCH 22/24] docs: add changelog entry for empty-filter heat lifecycle fix --- changelog.d/1535-fires-empty-heat-reenable.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/1535-fires-empty-heat-reenable.md diff --git a/changelog.d/1535-fires-empty-heat-reenable.md b/changelog.d/1535-fires-empty-heat-reenable.md new file mode 100644 index 000000000..a862a7b56 --- /dev/null +++ b/changelog.d/1535-fires-empty-heat-reenable.md @@ -0,0 +1,2 @@ +- Keep retained Active Fires heat imagery hidden when an empty filtered layer is + re-enabled while a new snapshot is pending (radianx, #1535). From 06c84f92d3791278a80c1ab710e995535768612d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 23:35:25 +0400 Subject: [PATCH 23/24] Ignore Arabic short vowels and shadda in chat autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arabic catalog strings carry optional marks ("تتبّع"), but people type without them, so "تتبع" found no suggestion. Fold the marks out of both the draft and the candidates before comparing. --- src/voice/chatPreferences.js | 12 ++++++------ src/voice/chatPreferences.test.mjs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/voice/chatPreferences.js b/src/voice/chatPreferences.js index b094624bb..e478620cd 100644 --- a/src/voice/chatPreferences.js +++ b/src/voice/chatPreferences.js @@ -22,6 +22,10 @@ export function bindChatPreferences({ ui, getDraft, setDraft }) { msg('Reset to globe view'), msg('Show earthquakes'), ].map((text) => t(text)); + // Arabic short vowels and shadda are optional in writing: typing "تتبع" + // still finds "تتبّع". + const fold = (text) => + text.toLowerCase().replace(/[\u064B-\u065F\u0670]/g, ''); try { const stored = JSON.parse(localStorage.getItem(key)); if (typeof stored?.quick === 'boolean') quick = stored.quick; @@ -31,18 +35,14 @@ export function bindChatPreferences({ ui, getDraft, setDraft }) { function render() { const draft = getDraft(); if (historyIndex >= 0 && draft !== recent[historyIndex]) resetHistory(); - const query = draft.trim().toLowerCase(); + const query = fold(draft.trim()); const groups = []; if (query) { const candidates = [...recent, ...(quick ? quickCommands() : [])]; groups.push({ label: t('Suggestions'), items: [...new Set(candidates)] - .filter( - (text) => - text.toLowerCase().includes(query) && - text.toLowerCase() !== query, - ) + .filter((text) => fold(text).includes(query) && fold(text) !== query) .slice(0, 4), }); } else { diff --git a/src/voice/chatPreferences.test.mjs b/src/voice/chatPreferences.test.mjs index 916fc3342..28dcdefcb 100644 --- a/src/voice/chatPreferences.test.mjs +++ b/src/voice/chatPreferences.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { setLocale } from '../i18n/index.js'; import { bindChatPreferences } from './chatPreferences.js'; import { createTestVoiceUi } from '../testSupport/voiceUi.mjs'; @@ -99,3 +100,21 @@ test('editing a recalled message ends recall, and sending resets it', (t) => { press('ArrowUp'); assert.equal(field.value, 'Show live flights now'); }); + +test('autocomplete ignores Arabic short vowels and shadda', (t) => { + setLocale('ar'); + t.after(() => setLocale('en')); + const ui = createTestVoiceUi(); + let draft = ''; + const prefs = bindChatPreferences({ ui, getDraft: () => draft }); + t.after(() => prefs.destroy()); + const suggested = (text) => { + draft = text; + prefs.render(); + return ui.state.chat.suggestions.flatMap((group) => group.items); + }; + // The quick command is written "تتبّع", with a shadda. + assert.deepEqual(suggested('تتبع'), ['اعرض تتبّع الرحلات المباشر']); + prefs.remember('انتقل إلى طوكيو'); + assert.deepEqual(suggested('طُوكيو'), ['انتقل إلى طوكيو']); +}); From e84cae6baa0d41af79c6a25e2347a3f8587df542 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 23:35:46 +0400 Subject: [PATCH 24/24] Add the changelog entry for #1561 --- changelog.d/1561-chat-autocomplete-arabic-marks.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1561-chat-autocomplete-arabic-marks.md diff --git a/changelog.d/1561-chat-autocomplete-arabic-marks.md b/changelog.d/1561-chat-autocomplete-arabic-marks.md new file mode 100644 index 000000000..b49cd0e86 --- /dev/null +++ b/changelog.d/1561-chat-autocomplete-arabic-marks.md @@ -0,0 +1,3 @@ +- Chat autocomplete finds Arabic suggestions typed without short vowels or + shadda, so «تتبع» now suggests «اعرض تتبّع الرحلات المباشر» + (German, @gekh, #1561).