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' },