mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
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:
1 parent
887b261195
commit
b778a43f1d
4 files changed
+407
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user