feat(fires): Snap Map-style heat raster and its globe imagery drape

Detections are binned on a 2048x1024 equirectangular grid weighted
1 + log10(1 + FRP), Gaussian-blurred (longitude wraps), normalized on a log
scale against the 99.5th percentile and mapped blue -> red with an alpha
ramp that is fully transparent below the floor. heatImagery drapes the bytes
as one full-globe SingleTileImageryProvider on the active imagery host.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-29 17:05:14 +04:00
1 parent 887b261195
commit b778a43f1d
4 files changed
+407

No files matched your search

+176
View File
@@ -0,0 +1,176 @@
/**
* 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.
*/
/** Below this normalized heat nothing is painted: empty land stays empty. */
export const HEAT_FLOOR = 0.03;
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 },
]);
export function heatColorAt(t) {
if (!(t > HEAT_FLOOR)) return [0, 0, 0, 0];
const v = Math.min(1, t);
let i = 1;
while (i < HEAT_PALETTE.length - 1 && HEAT_PALETTE[i].at < v) i += 1;
const a = HEAT_PALETTE[i - 1];
const b = HEAT_PALETTE[i];
const k = (v - a.at) / (b.at - a.at || 1);
const mix = (p, q) => Math.round(p + (q - p) * k);
return [
mix(a.rgb[0], b.rgb[0]),
mix(a.rgb[1], b.rgb[1]),
mix(a.rgb[2], b.rgb[2]),
Math.round((a.alpha + (b.alpha - a.alpha) * k) * 255),
];
}
const defaultWeight = (fire) =>
1 + Math.log10(1 + Math.max(0, Number(fire.frp) || 0));
export function binFires(fires, { width, height, weight = defaultWeight }) {
const grid = new Float32Array(width * height);
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);
}
return grid;
}
function kernel(sigma) {
const radius = Math.max(1, Math.ceil(sigma * 3));
const k = new Float32Array(radius * 2 + 1);
let sum = 0;
for (let i = -radius; i <= radius; i++) {
k[i + radius] = Math.exp(-(i * i) / (2 * sigma * sigma));
sum += k[i + radius];
}
for (let i = 0; i < k.length; i++) k[i] /= sum;
return { k, radius };
}
export function blurGaussian(grid, width, height, sigma) {
const { k, radius } = kernel(sigma);
const tmp = new Float32Array(grid.length);
const out = new Float32Array(grid.length);
for (let y = 0; y < height; y++) {
const row = y * width;
for (let x = 0; x < width; x++) {
const v = grid[row + x];
if (v === 0) continue;
for (let i = -radius; i <= radius; i++) {
const xx = (((x + i) % width) + width) % width; // longitude wraps
tmp[row + xx] += v * k[i + radius];
}
}
}
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const v = tmp[y * width + x];
if (v === 0) continue;
for (let i = -radius; i <= radius; i++) {
const yy = Math.min(height - 1, Math.max(0, y + i)); // poles clamp
out[yy * width + x] += v * k[i + radius];
}
}
}
return out;
}
/** Bins of the log-density histogram used to find the reference quantile. */
const QUANTILE_BINS = 4096;
/**
* The p-quantile of the non-zero values, from a histogram of log1p(value):
* one linear pass instead of sorting ~350k burning pixels (a sort cost
* 200 ms+ and dominated the build). Bin width on the log axis is
* max/4096, far finer than the 256-step palette it feeds.
*/
function percentileOfNonZero(values, p) {
let count = 0;
let max = 0;
for (let i = 0; i < values.length; i++) {
const v = values[i];
if (v > 0) {
count += 1;
if (v > max) max = v;
}
}
if (!count) return 0;
const logMax = Math.log1p(max);
const bins = new Uint32Array(QUANTILE_BINS);
const toBin = (QUANTILE_BINS - 1) / logMax;
for (let i = 0; i < values.length; i++) {
const v = values[i];
if (v > 0) bins[Math.floor(Math.log1p(v) * toBin)] += 1;
}
const target = Math.floor(p * (count - 1));
let seen = 0;
for (let b = 0; b < QUANTILE_BINS; b++) {
seen += bins[b];
if (seen > target) return Math.expm1((b + 1) / toBin);
}
return max;
}
/** HEAT_PALETTE sampled at 256 steps, so the per-pixel walk is one lookup. */
const LUT_STEPS = 256;
let paletteLut = null;
function heatLut() {
if (!paletteLut) {
paletteLut = new Uint8ClampedArray(LUT_STEPS * 4);
for (let i = 0; i < LUT_STEPS; i++) {
paletteLut.set(heatColorAt(i / (LUT_STEPS - 1)), i * 4);
}
}
return paletteLut;
}
export function buildHeatRaster(
fires,
{ width, height, sigmaPx, percentile = 0.995 },
) {
const density = blurGaussian(
binFires(fires, { width, height }),
width,
height,
sigmaPx,
);
const reference = percentileOfNonZero(density, percentile);
const rgba = new Uint8ClampedArray(width * height * 4);
if (reference > 0) {
const lut = heatLut();
const scale = (LUT_STEPS - 1) / Math.log1p(reference);
for (let i = 0; i < density.length; i++) {
const d = density[i];
if (d <= 0) continue;
const step = Math.min(LUT_STEPS - 1, Math.round(Math.log1p(d) * scale));
const l = step * 4;
if (lut[l + 3] === 0) continue;
const o = i * 4;
rgba[o] = lut[l];
rgba[o + 1] = lut[l + 1];
rgba[o + 2] = lut[l + 2];
rgba[o + 3] = lut[l + 3];
}
}
return { width, height, rgba, reference };
}
+73
View File
@@ -0,0 +1,73 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
HEAT_PALETTE,
heatColorAt,
binFires,
blurGaussian,
buildHeatRaster,
} from './firmsHeatRaster.js';
const fire = (lat, lon, frp = 10) => ({ lat, lon, frp });
test('palette runs cold to 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(high[3] > low[3], 'hotter is more opaque');
});
test('binFires drops each detection into its equirectangular pixel', () => {
const grid = binFires([fire(89.9, -179.9, 0), fire(-89.9, 179.9, 0)], {
width: 4,
height: 2,
});
assert.equal(grid[0], 1, 'north-west corner');
assert.equal(grid[7], 1, 'south-east corner');
assert.equal(
grid.reduce((a, b) => a + b, 0),
2,
);
});
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('blurGaussian keeps mass and wraps across the antimeridian', () => {
const w = 16;
const h = 8;
const grid = new Float32Array(w * h);
grid[3 * w + 0] = 1;
const out = blurGaussian(grid, w, h, 1.5);
const sum = out.reduce((a, b) => a + b, 0);
assert.ok(Math.abs(sum - 1) < 1e-3, `mass ${sum}`);
assert.ok(out[3 * w + (w - 1)] > 0, 'spills to the east edge');
});
test('buildHeatRaster is transparent where there are no fires', () => {
const r = buildHeatRaster([fire(40, -120), fire(40.1, -120.1)], {
width: 360,
height: 180,
sigmaPx: 2,
});
assert.equal(r.rgba.length, 360 * 180 * 4);
const alphaAt = (lat, lon) => {
const x = Math.floor(((lon + 180) / 360) * 360);
const y = Math.floor(((90 - lat) / 180) * 180);
return r.rgba[(y * 360 + x) * 4 + 3];
};
assert.ok(alphaAt(40, -120) > 0, 'blob over the fires');
assert.equal(alphaAt(-30, 60), 0, 'empty far away');
});
test('buildHeatRaster with no fires is fully transparent', () => {
const r = buildHeatRaster([], { width: 8, height: 4, sigmaPx: 1 });
assert.ok(r.rgba.every((v) => v === 0));
});
+71
View File
@@ -0,0 +1,71 @@
/**
* Drapes the Active Fires heat raster over the globe as one full-extent
* imagery tile on the active imagery host (globe or 3D-tiles), like the wind
* layer's scalar field. Owns the canvas and the imagery layer; nothing else
* in the fires layer touches imagery.
*/
const defaultCanvas = (width, height) => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return canvas;
};
export function createHeatImagery({
cesium,
getHost,
createCanvas = defaultCanvas,
}) {
let raster = null;
let layer = null;
let collection = null;
let show = true;
function detach() {
if (layer && collection) collection.remove(layer, true);
layer = null;
collection = null;
}
function attach() {
detach();
if (!raster) return;
const host = getHost?.() ?? { collection: null, kind: 'none' };
if (!host.collection) return;
const canvas = createCanvas(raster.width, raster.height);
const ctx = canvas.getContext('2d');
const image = ctx.createImageData(raster.width, raster.height);
image.data.set(raster.rgba);
ctx.putImageData(image, 0, 0);
const provider = new cesium.SingleTileImageryProvider({
url: canvas.toDataURL('image/png'),
tileWidth: raster.width,
tileHeight: raster.height,
rectangle: cesium.Rectangle.MAX_VALUE,
});
collection = host.collection;
layer = collection.addImageryProvider(provider);
layer.show = show;
collection.raiseToTop?.(layer);
}
return {
setRaster(next) {
raster = next;
attach();
},
setShow(value) {
show = Boolean(value);
if (layer) layer.show = show;
},
rehome() {
const host = getHost?.();
if ((host?.collection ?? null) !== collection) attach();
},
destroy() {
detach();
raster = null;
},
};
}
+87
View File
@@ -0,0 +1,87 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHeatImagery } from './heatImagery.js';
function harness() {
const layers = [];
const removed = [];
const collection = {
addImageryProvider(provider) {
const layer = { provider, show: true, alpha: 1 };
layers.push(layer);
return layer;
},
remove(layer, destroy) {
layers.splice(layers.indexOf(layer), 1);
removed.push({ layer, destroy });
},
raiseToTop() {},
};
let host = { collection, kind: 'globe' };
const cesium = {
Rectangle: { MAX_VALUE: { max: true } },
SingleTileImageryProvider: class {
constructor(options) {
this.options = options;
}
},
};
const createCanvas = (w, h) => ({
width: w,
height: h,
getContext: () => ({
createImageData: (cw, ch) => ({
data: new Uint8ClampedArray(cw * ch * 4),
}),
putImageData() {},
}),
toDataURL: () => 'data:image/png;base64,AAAA',
});
const imagery = createHeatImagery({
cesium,
getHost: () => host,
createCanvas,
});
return { imagery, layers, removed, collection, setHost: (h) => (host = h) };
}
const raster = { width: 4, height: 2, rgba: new Uint8ClampedArray(32) };
test('setRaster drapes one full-globe tile and replaces it on the next build', () => {
const h = harness();
h.imagery.setRaster(raster);
assert.equal(h.layers.length, 1);
assert.deepEqual(h.layers[0].provider.options.rectangle, { max: true });
assert.equal(h.layers[0].provider.options.tileWidth, 4);
h.imagery.setRaster(raster);
assert.equal(h.layers.length, 1, 'never two heat layers');
assert.equal(h.removed[0].destroy, true);
});
test('setShow toggles without rebuilding', () => {
const h = harness();
h.imagery.setRaster(raster);
const layer = h.layers[0];
h.imagery.setShow(false);
assert.equal(layer.show, false);
h.imagery.setShow(true);
assert.equal(h.layers[0], layer);
assert.equal(layer.show, true);
});
test('no imagery host keeps nothing draped; rehome brings it back', () => {
const h = harness();
h.setHost({ collection: null, kind: 'none' });
h.imagery.setRaster(raster);
assert.equal(h.layers.length, 0);
h.setHost({ collection: h.collection, kind: 'globe' });
h.imagery.rehome();
assert.equal(h.layers.length, 1);
});
test('destroy removes the layer', () => {
const h = harness();
h.imagery.setRaster(raster);
h.imagery.destroy();
assert.equal(h.layers.length, 0);
});