fix(fires): paint aggregated heat cells warm by density, not by strongest FRP

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 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Fable 5.1 committed 2026-10-27 19:19:40 +04:00
1 parent 1dad6b658a
commit 887b261195
4 files changed
+39 -15

No files matched your search

+2 -1
View File
@@ -2,7 +2,8 @@
cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · 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 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" 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 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 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). `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426).
+6 -3
View File
@@ -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 → 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 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 100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Glow
cells, glow sprites and card accents read the same ramp; confidence and sprites and fire-card accents read that ramp; confidence and brightness do
brightness do not affect the color. 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 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 whose track is painted with the ramp and ticked at the same six values (its
+28 -9
View File
@@ -137,15 +137,33 @@ export function createModel({
} }
/** /**
* Fill color for an aggregated heat cell: the ramp color of its strongest * Severity stop for an aggregated heat cell from its heat-normalized score
* detection, so cells, markers and the slider legend read on one scale. * (density, night share and strongest FRP together). Cells say *where* it
* @param {number} maxFrp - Strongest FRP in the cell, megawatts. * 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). * @param {number} alpha - Fill alpha (heat-normalized by the caller).
* @returns {Cesium.Color} * @returns {Cesium.Color}
*/ */
function heatColor(maxFrp, alpha) { function heatColor(normalized, alpha) {
return frpColorStop(maxFrp).color.withAlpha(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. * 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. * @returns {string} "r, g, b" accent string.
*/ */
function cellAccent(maxFrp) { function cellAccent(normalized) {
return accentForSeverity(frpColorStop(maxFrp).name); return accentForSeverity(cellColorStop(normalized).name);
} }
/** Coordinate line like "30.512°N 75.831°E". */ /** Coordinate line like "30.512°N 75.831°E". */
@@ -590,6 +608,7 @@ export function createModel({
cellIntersectsBounds, cellIntersectsBounds,
heatScore, heatScore,
heatColor, heatColor,
cellColorStop,
frpColorStop, frpColorStop,
detectionColorStop, detectionColorStop,
frpPixelSize, frpPixelSize,
+3 -2
View File
@@ -144,7 +144,7 @@ export function createRendering({
const score = components.model.heatScore(cell); const score = components.model.heatScore(cell);
const normalized = Math.min(1, Math.sqrt(score / maxScore)); const normalized = Math.min(1, Math.sqrt(score / maxScore));
const alpha = 0.16 + normalized * 0.5; 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 centerLon = cell.lonCell + lod.gridDegrees / 2;
const centerLat = cell.latCell + lod.gridDegrees / 2; const centerLat = cell.latCell + lod.gridDegrees / 2;
const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0); 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. // 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({ layerState._labelCandidates.push({
position, position,
cullPosition: components.model.cellCullPosition(centerLon, centerLat), cullPosition: components.model.cellCullPosition(centerLon, centerLat),
fire: null, fire: null,
cell, cell,
accent: components.model.cellAccent(cell.maxFrp), accent: components.model.cellAccent(normalized),
}); });
} }