Drape the Active Fires heatmap on Navara too, with the same hand-off fade to the markers

This commit is contained in:
German Khokhlov committed 2026-11-05 00:11:32 +04:00
1 parent 0c0df50a40
commit 727bb29cae
8 files changed
+390 -111

No files matched your search

+1
View File
@@ -3866,6 +3866,7 @@
"src/layers/shared/render/navaraCellGeometry.js",
"src/layers/shared/render/navaraGroundCells.js",
"src/layers/firms/render/navaraSprites.js",
"src/layers/firms/render/navaraHeat.js",
"src/layers/shared/render/navaraGroundGeometry.js",
"src/layers/shared/render/navaraGroundGrid.js",
"src/layers/shared/render/navaraGroundSurface.js",
+38 -29
View File
@@ -11,7 +11,8 @@ const settle = async () => {
await new Promise((resolve) => setImmediate(resolve));
};
const FIRE = { lat: 38.5, lon: -121.5 };
// One cluster a few hundred metres across: one cell from orbit, sprites at the fire.
// One cluster a few hundred metres across: one heat spot and one card from
// orbit, sprites at the fire.
const records = Array.from({ length: 9 }, (_, i) => ({
lat: FIRE.lat + (i % 3) * 0.002,
lon: FIRE.lon + Math.floor(i / 3) * 0.002,
@@ -69,7 +70,17 @@ function fixture() {
camera: {},
registerEffect() {},
addEffect(config) {
const handle = { config, update() {}, delete() {} };
const handle = {
config,
visible: config.visible,
deleted: false,
update(next) {
if ('visible' in next) handle.visible = next.visible;
},
delete() {
handle.deleted = true;
},
};
effects.push(handle);
return handle;
},
@@ -77,8 +88,6 @@ function fixture() {
EffectDesc: class {},
NavaraEffect: class {},
};
// Cells the layer hands to the ground display for its current build.
let cells = [];
const noop = () => {};
const layer = createFirmsHeatmapLayer({
id: 'local-firms',
@@ -106,13 +115,8 @@ function fixture() {
},
overlayHost: { setEntries: noop, setVisible: noop, clearSource: noop },
renderer: {
create: ({ engine: target, id }) => {
const renderer = createFirmsNavaraRenderer(target, { ...native, id });
const { addCell, clearCells } = renderer;
renderer.addCell = (cell) => (cells.push(cell), addCell(cell));
renderer.clearCells = () => ((cells = []), clearCells());
return renderer;
},
create: ({ engine: target, id }) =>
createFirmsNavaraRenderer(target, { ...native, id }),
},
});
layer.init(null, { engine });
@@ -123,9 +127,20 @@ function fixture() {
engine,
layer,
pose,
cells: () => cells,
// Ground cells actually drawn by the shared display.
drawnCells: () => effects[0]?.config.gevFirmsGround.owner.state.count ?? 0,
// The heatmap as drawn: its raster's alpha at the fire, 0 when the
// drape is hidden, faded out or unpainted.
drawnHeat() {
const handle = effects.find(
(effect) => effect.config.gevFirmsHeat && !effect.deleted,
);
const owner = handle?.config.gevFirmsHeat.owner;
if (!handle?.visible || !owner?.texture || !(owner.opacity > 0)) return 0;
const { data, width, height } = owner.texture.image;
const x = Math.floor(((FIRE.lon + 180) / 360) * width);
const y = Math.floor(((90 - FIRE.lat) / 180) * height);
return (data[(y * width + x) * 4 + 3] / 255) * owner.opacity;
},
cards: () => layer.getStats().cells,
// Fire sprites actually drawn (shown instances of the engine set).
drawnSprites() {
const set = engine.fake.moverSet('firms:local-firms');
@@ -148,21 +163,16 @@ function fixture() {
};
}
test('edge:local-firms: orbit to the fire keeps the observation sum and never doubles a marker across LOD changes', async () => {
test('edge:local-firms: orbit to the fire hands over between heatmap and markers and never shows both', async () => {
const f = fixture();
try {
await f.layer.enable();
await f.step();
const sum = (list) => list.reduce((n, c) => n + c.properties.count, 0);
assert.equal(
sum(f.cells()),
records.length,
'orbit cells hold every observation',
);
assert.equal(f.drawnCells(), 1);
assert.ok(f.drawnHeat() > 0, 'orbit: the heatmap glows at the fire');
assert.equal(f.cards(), 1, 'orbit: one card holds the cluster');
assert.equal(f.drawnSprites(), 0);
for (const round of [1, 2]) {
// Down to the fire: detections replace the cell, one sprite per observation.
// Down to the fire: markers replace the heatmap, one per observation.
f.pose(20_000);
await f.step();
assert.equal(
@@ -171,19 +181,18 @@ test('edge:local-firms: orbit to the fire keeps the observation sum and never do
`round ${round} at the fire`,
);
assert.equal(
f.drawnCells(),
f.drawnHeat(),
0,
`round ${round}: no cell under the sprites`,
`round ${round}: no heat under the markers`,
);
// Back to orbit: the cell returns with the same sum, sprites go.
// Back to orbit: the heatmap returns, the markers go.
f.pose(12_000_000);
await f.step();
assert.equal(sum(f.cells()), records.length);
assert.equal(f.drawnCells(), 1, `round ${round} in orbit`);
assert.ok(f.drawnHeat() > 0, `round ${round} in orbit`);
assert.equal(
f.drawnSprites(),
0,
`round ${round}: no sprite over the cell`,
`round ${round}: no marker over the heatmap`,
);
}
} finally {
+15
View File
@@ -4,6 +4,7 @@ import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js';
import { LOD_LEVELS } from '../policy.js';
import { createFirmsSpriteBank } from './navaraSprites.js';
import { createGroundCellDisplay } from '../../shared/render/navaraGroundCells.js';
import { createFirmsHeatDrape } from './navaraHeat.js';
export function createFirmsNavaraRenderer(
engine,
{ view: nativeView, EffectDesc, NavaraEffect, id = 'local-firms' } = {},
@@ -121,6 +122,20 @@ export function createFirmsNavaraRenderer(
return destroyed ? null : sprites.add(description);
},
removeSprites,
/** The far-zoom heatmap, draped on this view's surface. */
createHeatImagery() {
return destroyed
? null
: createFirmsHeatDrape(engine, {
view: nativeView,
EffectDesc,
NavaraEffect,
});
},
/** The hand-off fade: every marker at `alpha`, shown while `show`. */
fadeSprites(alpha, show) {
if (!destroyed) sprites?.setFade(alpha, show);
},
onEnable() {},
destroy() {
if (destroyed) return;
+202
View File
@@ -0,0 +1,202 @@
// The Active Fires far-zoom heatmap on Navara: the same equirectangular heat
// raster the Cesium globe drapes as imagery (firmsHeatRaster.js), drawn over
// visible terrain and 3D tiles by one non-pickable classification pass. Only
// surface pixels take it, so the fire markers above stay untouched. The
// adapter has heatImagery.js's interface, so the layer drives both alike.
import { Effect, EffectAttribute } from 'postprocessing';
import {
DataTexture,
LinearFilter,
Matrix4,
RGBAFormat,
RepeatWrapping,
ClampToEdgeWrapping,
Uniform,
UnsignedByteType,
} from 'three';
import {
groundSurfaceUniforms,
updateGroundSurface,
} from '../../shared/render/navaraGroundSurface.js';
const KEY = 'gevFirmsHeat';
const registeredViews = new WeakSet();
// Row 0 of the raster is the north edge and column 0 is 180°W; the texture
// keeps that order (no flip), so v = 0 is north.
const fragmentShader = /* glsl */ `
uniform sampler2D tAllDepth;
uniform sampler2D tSurfaceDepth;
uniform sampler2D tHeat;
uniform mat4 uInverseProjection;
uniform mat4 uInverseView;
uniform float uLogDepthFC;
uniform float uOpacity;
// WGS84 first eccentricity squared: geodetic latitude of a near-surface point.
const float E2=0.0066943799901413165;
vec3 toDisplay(vec3 c) {
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
}
vec3 toLinear(vec3 c) {
return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
}
void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
outputColor=inputColor;
float depth=unpackRGBAToDepth(texture2D(tAllDepth,uv));
if(depth>=1.0) return;
float surface=texture2D(tSurfaceDepth,uv).r;
if(depth<surface-2e-7) return;
float distance=exp2(2.0*depth/uLogDepthFC)-1.0;
vec4 ray=uInverseProjection*vec4(uv*2.0-1.0,-1.0,1.0);
vec3 point=ray.xyz*(distance/-ray.z);
vec3 world=(uInverseView*vec4(point,1.0)).xyz;
float rho=length(world.xy);
float longitude=atan(world.y,world.x);
float latitude=atan(world.z,rho*(1.0-E2));
// PI comes from three.js's shader chunks.
vec2 st=vec2(longitude/(2.0*PI)+0.5,0.5-latitude/PI);
vec4 heat=texture2D(tHeat,st);
float alpha=heat.a*uOpacity;
if(alpha<=0.0) return;
vec3 color=mix(toDisplay(inputColor.rgb),heat.rgb,alpha);
outputColor=vec4(toLinear(color),inputColor.a);
}
`;
function heatEffectClass(EffectDesc, NavaraEffect) {
return class FirmsHeatEffectDesc extends EffectDesc {
static key = KEY;
// Under the heat cells' and markers' place in the chain: after the other
// surface drapes, before sharpening, anti-aliasing and overlays.
static insertAfter = [
'gevEarthquakeGround',
'gevObservedWeather',
'gevNepalEvent',
'gevRecentImagery',
'toneMapping',
];
static insertBefore = [
'gevFirmsGround',
'gevPreSharpenOverlay',
'gevSharpen',
'smaa',
'fxaa',
'gevOverlay',
'final',
];
constructor(view, context, config) {
super(view, context, config);
this.owner = config[KEY].owner;
}
createPass() {
this.effect = new Effect('GevFirmsHeat', fragmentShader, {
attributes: EffectAttribute.CONVOLUTION,
uniforms: new Map([
...groundSurfaceUniforms(),
['tHeat', new Uniform(null)],
['uOpacity', new Uniform(1)],
['uInverseView', new Uniform(new Matrix4())],
]),
});
return new NavaraEffect(this.view.camera.raw, this.effect);
}
updateFrame() {
const owner = this.owner,
camera = this.view.camera.raw;
if (!this.effect || !owner.texture || !camera) return;
updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt'));
const u = this.effect.uniforms;
u.get('tHeat').value = owner.texture;
u.get('uOpacity').value = owner.opacity;
u.get('uInverseView').value.copy(camera.matrixWorld);
}
onDestroy() {
this.effect?.dispose();
super.onDestroy();
}
};
}
/**
* The raster as a texture: linear between pixels, wrapping across 180°. Its
* sRGB bytes are read as they are and blended in display space, as the heat
* cells' colors are.
*/
function heatTexture(raster) {
const texture = new DataTexture(
raster.rgba,
raster.width,
raster.height,
RGBAFormat,
UnsignedByteType,
);
texture.minFilter = LinearFilter;
texture.magFilter = LinearFilter;
texture.wrapS = RepeatWrapping;
texture.wrapT = ClampToEdgeWrapping;
texture.generateMipmaps = false;
texture.needsUpdate = true;
return texture;
}
/**
* The heatmap drape on one Navara view, with heatImagery.js's interface:
* setRaster, hasRaster, setShow, setOpacity (the hand-off fade), rehome and
* destroy. Drawn only while shown, painted and above zero opacity.
*/
export function createFirmsHeatDrape(
engine,
{ view, EffectDesc, NavaraEffect },
) {
if (!registeredViews.has(view)) {
view.registerEffect(KEY, heatEffectClass(EffectDesc, NavaraEffect));
registeredViews.add(view);
}
const owner = { texture: null, opacity: 1 };
let raster = null,
show = true,
handle = null,
destroyed = false;
function sync() {
if (destroyed) return;
const visible = Boolean(owner.texture && show && owner.opacity > 0);
if (!handle && visible)
handle = view.addEffect({ [KEY]: { owner }, visible: true });
else handle?.update({ visible });
engine.render.request('firms-heat');
}
return {
setRaster(next) {
if (destroyed) return;
raster = next;
owner.texture?.dispose();
owner.texture = next ? heatTexture(next) : null;
sync();
},
/** True once a raster was handed over. */
hasRaster() {
return raster !== null;
},
setShow(value) {
show = Boolean(value);
sync();
},
/** Hand-off fade, 0..1. */
setOpacity(value) {
owner.opacity = Math.min(1, Math.max(0, Number(value) || 0));
sync();
},
/** Navara drapes on its own surface; there is no imagery host to follow. */
rehome() {},
destroy() {
if (destroyed) return;
destroyed = true;
handle?.delete();
handle = null;
owner.texture?.dispose();
owner.texture = null;
raster = null;
engine.render.request('firms-heat-destroy');
},
};
}
@@ -0,0 +1,80 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createFirmsHeatDrape } from './navaraHeat.js';
function harness() {
const effects = [];
const requests = [];
const view = {
registered: [],
registerEffect(key) {
view.registered.push(key);
},
addEffect(config) {
const handle = {
config,
visible: config.visible,
deleted: false,
update(next) {
handle.visible = next.visible;
},
delete() {
handle.deleted = true;
},
};
effects.push(handle);
return handle;
},
};
const engine = { render: { request: (reason) => requests.push(reason) } };
const drape = createFirmsHeatDrape(engine, {
view,
EffectDesc: class {},
NavaraEffect: class {},
});
return { drape, view, effects, requests };
}
const raster = (alpha) => ({
width: 4,
height: 2,
rgba: new Uint8ClampedArray(4 * 2 * 4).fill(alpha),
});
test('the heatmap drape draws only a painted, shown raster above zero opacity', () => {
const h = harness();
assert.deepEqual(h.view.registered, ['gevFirmsHeat']);
assert.equal(h.drape.hasRaster(), false);
assert.equal(h.effects.length, 0, 'nothing to draw before a raster');
h.drape.setRaster(raster(200));
assert.equal(h.drape.hasRaster(), true);
assert.equal(h.effects.length, 1);
const [effect] = h.effects;
const owner = effect.config.gevFirmsHeat.owner;
assert.equal(owner.texture.image.width, 4);
assert.equal(owner.texture.image.height, 2);
assert.equal(effect.visible, true);
h.drape.setOpacity(0);
assert.equal(effect.visible, false, 'faded out under the markers');
h.drape.setOpacity(0.5);
assert.equal(effect.visible, true);
assert.equal(owner.opacity, 0.5);
h.drape.setShow(false);
assert.equal(effect.visible, false, 'the layer is off');
h.drape.setShow(true);
// A new raster replaces the texture in the same pass.
const first = owner.texture;
let disposed = false;
first.dispose = () => (disposed = true);
h.drape.setRaster(raster(100));
assert.equal(disposed, true);
assert.notEqual(owner.texture, first);
assert.equal(h.effects.length, 1);
assert.ok(h.requests.length > 0, 'every change asks for a frame');
h.drape.destroy();
assert.equal(effect.deleted, true);
assert.equal(owner.texture, null);
assert.equal(h.drape.hasRaster(), false);
h.drape.setRaster(raster(50));
assert.equal(h.effects.length, 1, 'a destroyed drape draws nothing again');
});
+14 -2
View File
@@ -27,6 +27,9 @@ export function createFirmsSpriteBank(
) {
let rows = [],
visible = false,
// The heatmap hand-off: every marker's alpha, and whether any shows.
fade = WHITE,
fadeShown = true,
destroyed = false,
generation = 0,
queued = false,
@@ -88,7 +91,7 @@ export function createFirmsSpriteBank(
}
async function flush() {
if (destroyed) return;
if (!visible || !rows.length) {
if (!visible || !fadeShown || !rows.length) {
hide();
return;
}
@@ -120,7 +123,7 @@ export function createFirmsSpriteBank(
position: row.position,
cell: cell.key,
scale: cell.scale,
color: WHITE,
color: fade,
show: row.show,
pickId: row.id,
});
@@ -177,6 +180,15 @@ export function createFirmsSpriteBank(
schedule();
return row;
},
/** The hand-off fade: every marker at `alpha`, drawn while `show`. */
setFade(alpha, show) {
if (destroyed) return;
const next = alpha >= 1 ? WHITE : Object.freeze([1, 1, 1, alpha]);
if (next[3] === fade[3] && Boolean(show) === fadeShown) return;
fade = next;
fadeShown = Boolean(show);
schedule();
},
setVisible(value) {
if (destroyed || visible === Boolean(value)) return;
visible = Boolean(value);
@@ -141,3 +141,37 @@ test('clearing rows during decode preserves the new generation and commits only
assert.equal(h.commits, 1);
h.bank.destroy();
});
test('the heatmap hand-off fades every marker and hides them all once it completes', async () => {
const h = harness(async () => ({ width: 56, height: 56 }));
h.bank.add({ id: 'firms-0', position: { x: 1, y: 2, z: 3 }, image: 'a' });
h.bank.add({ id: 'firms-1', position: { x: 4, y: 5, z: 6 }, image: 'a' });
h.bank.setVisible(true);
await settle();
await settle();
const entry = h.registry.sets.get('firms-test');
assert.equal(entry.store.count, 2);
assert.deepEqual(
[...entry.store.colors.slice(0, 8)],
[1, 1, 1, 1, 1, 1, 1, 1],
);
h.bank.setFade(0.25, true);
await settle();
await settle();
assert.deepEqual(
[...entry.store.colors.slice(0, 8)],
[1, 1, 1, 0.25, 1, 1, 1, 0.25],
);
h.bank.setFade(0, false);
await settle();
await settle();
assert.equal(entry.store.count, 0, 'the heatmap alone: no marker drawn');
h.bank.setFade(1, true);
await settle();
await settle();
assert.equal(entry.store.count, 2);
assert.deepEqual(
[...entry.store.colors.slice(0, 8)],
[1, 1, 1, 1, 1, 1, 1, 1],
);
});
+6 -80
View File
@@ -69,19 +69,13 @@ export function createRendering({
}
/**
* Draw one band. On a globe that can drape the heatmap (Cesium), markers
* and heatmap stay built in every band and the hand-off only fades
* between them. Elsewhere the far bands draw heat cells instead.
* Draw one band. Markers and heatmap stay built in every band; the
* hand-off only fades between them.
* @param {Object} lod - Band descriptor.
* @param {?Object} bounds - Padded view bounds in degrees, or null.
*/
function renderBand(lod, bounds) {
if (!heatImagery()) {
if (lod.mode === 'detections') renderDetections(lod, bounds);
else renderCells(aggregateFires(lod, bounds), lod, bounds);
return;
}
prepareHeat(lod.mode === 'heat');
if (lod.mode === 'detections') renderDetections(lod, bounds);
else renderHeat(lod, bounds);
@@ -91,8 +85,7 @@ export function createRendering({
/**
* Cross-fade between markers and heatmap by camera height (HANDOFF_FADE),
* on every rendered frame, so a fast zoom never outruns the throttled band
* check. Cheap when nothing changed: one height read and a compare. A
* globe without the heatmap keeps its markers as they are.
* check. Cheap when nothing changed: one height read and a compare.
* @param {boolean} [force] Reapply, e.g. to freshly added markers.
*/
@@ -175,10 +168,7 @@ export function createRendering({
return clipped;
}
/**
* The heat imagery adapter, created on first use, or null when the
* renderer's globe cannot drape it.
*/
/** The renderer's heat imagery adapter, created on first use. */
function heatImagery() {
layerState._heatImagery ??=
@@ -234,8 +224,8 @@ export function createRendering({
}
/**
* Far bands (global/regional) on a globe with the heatmap: a Snap
* Map–style heatmap draped over the globe, transparent where nothing
* Far bands (global/regional): a Snap Map–style heatmap draped over the
* globe, transparent where nothing
* burns, plus the decluttered "N FIRES" cards from the coarse aggregation.
* The nearest band's markers are kept built underneath for the hand-off
* fade and hidden once it completes; cards go through the same
@@ -271,69 +261,6 @@ export function createRendering({
);
}
/**
* Far bands on a globe without the heatmap (Navara): aggregated heat cells
* as ground rectangles colored on the FRP ramp by their strongest fire.
* Labels go through the same screen-space pipeline as detections.
* @param {Array<Object>} cells - Aggregated cells, heat-sorted descending.
* @param {Object} lod - Active LOD descriptor.
* @param {?Object} bounds - Padded view bounds in degrees, or null.
*/
function renderCells(cells, lod, bounds) {
ensureDetectionCollections();
layerState._renderer.clearCells();
if (layerState._billboards) layerState._billboards.removeAll();
layerState._pickIndexById.clear();
layerState._cullPositions.length = 0;
layerState._cellCount = cells.length;
const maxScore = Math.max(1, ...cells.map(components.model.heatScore));
layerState._labelCandidates = [];
layerState._labelLodDistance = lod.labelDistance;
for (const cell of cells) {
const score = components.model.heatScore(cell);
const normalized = Math.min(1, Math.sqrt(score / maxScore));
const alpha = 0.16 + normalized * 0.5;
const color = components.model
.frpColorStop(cell.maxFrp)
.color.withAlpha(alpha);
const centerLon = cell.lonCell + lod.gridDegrees / 2;
const centerLat = cell.latCell + lod.gridDegrees / 2;
const position = geo.cartesian3.fromDegrees(centerLon, centerLat, 0);
layerState._renderer.addCell({
west: cell.lonCell,
south: cell.latCell,
east: cell.lonCell + lod.gridDegrees,
north: cell.latCell + lod.gridDegrees,
color,
position,
properties: {
count: cell.count,
intensity: score,
maxFrp: cell.maxFrp,
night: cell.night,
},
});
// Cells arrive heat-sorted, so candidate order doubles as label priority.
layerState._labelCandidates.push({
position,
cullPosition: components.model.cellCullPosition(centerLon, centerLat),
fire: null,
cell,
accent: components.model.cellCardAccent(cell),
});
}
components.cards.rebuildAmbientLabels();
components.selection.refreshContextRegistrations(
components.selection.topFiresWithinBounds(bounds),
);
}
/**
* Render individual detections (local/close bands) as pre-baked
* radial-glow sprite billboards sized by FRP, capped per viewport by
@@ -496,7 +423,6 @@ export function createRendering({
renderBand,
aggregateFires,
renderHeat,
renderCells,
renderDetections,
applyHandoffFade,
ensureDetectionCollections,