diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4d1b69849..73ea42a0f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2184,7 +2184,9 @@ 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 +heatmap instead of individual markers: the shown detections are linearly binned +(each detection's weight shared between the four nearest pixel centres, so spots +sit at the true position and stay round) on a global 2048 × 1024 grid (≈20 km per pixel), 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 by one pixel so each fire stays a distinct spot about diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js index f6aadff3b..6704cf6e2 100644 --- a/src/data/firmsHeatRaster.js +++ b/src/data/firmsHeatRaster.js @@ -1,8 +1,9 @@ /** * Snap Map–style heat raster for the Active Fires far-zoom bands: detections - * are binned on an equirectangular grid (row 0 = north, column 0 = 180°W), - * blurred, normalized on a log scale and mapped through HEAT_PALETTE. Pure: - * no Cesium, no DOM, so the whole pipeline is unit-tested in node. + * are linearly binned on an equirectangular grid (row 0 = north, column + * 0 = 180°W), blurred, normalized on a log scale and mapped through + * HEAT_PALETTE. Pure: no Cesium, no DOM, so the whole pipeline is unit-tested + * in node. */ /** Below this normalized heat nothing is painted: empty land stays empty. */ @@ -54,19 +55,35 @@ export const HEAT_MIN_WEIGHT_MW = 0.5; const defaultWeight = (fire) => Math.max(HEAT_MIN_WEIGHT_MW, Number(fire.frp) || 0); +/** + * Linear binning (Wand 1994): each detection's weight is shared between the + * four pixel centres around it in proportion to closeness, instead of + * dropping whole into one pixel. Spots then sit at the true position rather + * than snapping by up to half a pixel (~10 km), and a lone weak fire blurs + * into a round spot instead of a square or a plus sign. Longitude wraps; + * rows beyond the poles fold back onto the edge row. + */ export function binFires(fires, { width, height, weight = defaultWeight }) { const grid = new Float32Array(width * height); + const row = (y) => Math.min(height - 1, Math.max(0, y)) * width; + const col = (x) => ((x % width) + width) % width; for (const fire of fires) { if (!Number.isFinite(fire.lat) || !Number.isFinite(fire.lon)) continue; - const x = Math.min( - width - 1, - Math.max(0, Math.floor(((fire.lon + 180) / 360) * width)), - ); - const y = Math.min( - height - 1, - Math.max(0, Math.floor(((90 - fire.lat) / 180) * height)), - ); - grid[y * width + x] += weight(fire); + const fx = ((fire.lon + 180) / 360) * width - 0.5; + const fy = ((90 - fire.lat) / 180) * height - 0.5; + const x0 = Math.floor(fx); + const y0 = Math.floor(fy); + const tx = fx - x0; + const ty = fy - y0; + const w = weight(fire); + const top = row(y0); + const bottom = row(y0 + 1); + const left = col(x0); + const right = col(x0 + 1); + grid[top + left] += w * (1 - tx) * (1 - ty); + grid[top + right] += w * tx * (1 - ty); + grid[bottom + left] += w * (1 - tx) * ty; + grid[bottom + right] += w * tx * ty; } return grid; } diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs index 25c8b61fb..754493754 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -28,22 +28,37 @@ test('palette runs ember to white-hot and starts fully transparent', () => { } }); -test('binFires drops each detection into its equirectangular pixel', () => { - const grid = binFires([fire(89.9, -179.9, 1), fire(-89.9, 179.9, 1)], { +test('binFires puts a detection at a pixel centre wholly in that pixel', () => { + // 4×2 grid: pixel centres at lon −135/−45/45/135, lat ±45. + const grid = binFires([fire(45, -135, 1), fire(-45, 135, 1)], { width: 4, height: 2, }); - assert.equal(grid[0], 1, 'north-west corner'); - assert.equal(grid[7], 1, 'south-east corner'); + assert.equal(grid[0], 1, 'north-west pixel'); + assert.equal(grid[7], 1, 'south-east pixel'); assert.equal( grid.reduce((a, b) => a + b, 0), 2, ); }); +test('binFires shares a detection between the pixels around it', () => { + const between = binFires([fire(45, 0, 1)], { width: 4, height: 2 }); + assert.equal(between[1], 0.5, 'half to the pixel west of it'); + assert.equal(between[2], 0.5, 'half to the pixel east of it'); + const quarter = binFires([fire(45, -112.5, 1)], { width: 4, height: 2 }); + assert.equal(quarter[0], 0.75, 'closer centre takes more'); + assert.equal(quarter[1], 0.25); + const wrapped = binFires([fire(45, 180, 1)], { width: 4, height: 2 }); + assert.equal(wrapped[3], 0.5, 'the antimeridian wraps'); + assert.equal(wrapped[0], 0.5); + const polar = binFires([fire(90, -135, 1)], { width: 4, height: 2 }); + assert.equal(polar[0], 1, 'beyond the last row folds back onto it'); +}); + 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)], + [fire(45, 45, 250), fire(45, 45, 0), fire(45, 45, Number.NaN)], { width: 4, height: 2 }, ); assert.equal(grid[2], 250 + 2 * HEAT_MIN_WEIGHT_MW);