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.
This commit is contained in:
German Khokhlov committed 2026-11-02 13:48:28 +04:00
1 parent 02953665de
commit 18d55f58b9
12 files changed
+115 -87

No files matched your search

+13 -13
View File
@@ -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', () => {
+11 -7
View File
@@ -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) {
+7 -3
View File
@@ -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', () => {
+15 -12
View File
@@ -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. */
+38 -24
View File
@@ -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);
});
+2 -2
View File
@@ -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) {
+10 -10
View File
@@ -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', () => {
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
*/
+1 -1
View File
@@ -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' },