diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 948d6bf3d..3777cadeb 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/layers/firms/edgeCases.test.mjs b/src/layers/firms/edgeCases.test.mjs index 59bf0e80f..adfd39584 100644 --- a/src/layers/firms/edgeCases.test.mjs +++ b/src/layers/firms/edgeCases.test.mjs @@ -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 { diff --git a/src/layers/firms/render/navara.js b/src/layers/firms/render/navara.js index b12af4023..94238bc1f 100644 --- a/src/layers/firms/render/navara.js +++ b/src/layers/firms/render/navara.js @@ -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; diff --git a/src/layers/firms/render/navaraHeat.js b/src/layers/firms/render/navaraHeat.js new file mode 100644 index 000000000..8cfc0cc79 --- /dev/null +++ b/src/layers/firms/render/navaraHeat.js @@ -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 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'); + }, + }; +} diff --git a/src/layers/firms/render/navaraHeat.test.mjs b/src/layers/firms/render/navaraHeat.test.mjs new file mode 100644 index 000000000..2fac083c8 --- /dev/null +++ b/src/layers/firms/render/navaraHeat.test.mjs @@ -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'); +}); diff --git a/src/layers/firms/render/navaraSprites.js b/src/layers/firms/render/navaraSprites.js index 5276378f9..aa9ecffd1 100644 --- a/src/layers/firms/render/navaraSprites.js +++ b/src/layers/firms/render/navaraSprites.js @@ -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); diff --git a/src/layers/firms/render/navaraSprites.test.mjs b/src/layers/firms/render/navaraSprites.test.mjs index c1f0b149a..ed6ddca6d 100644 --- a/src/layers/firms/render/navaraSprites.test.mjs +++ b/src/layers/firms/render/navaraSprites.test.mjs @@ -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], + ); +}); diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index a4c74eeef..a516c2dc8 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -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} 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,