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.
This commit is contained in:
German Khokhlov committed 2026-10-29 18:25:33 +04:00
1 parent d32a4f62d3
commit bef47ee7c7
3 files changed
+42 -6

No files matched your search

+3 -1
View File
@@ -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
+10 -1
View File
@@ -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);
+29 -4
View File
@@ -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', () => {