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', () => {