From f608ae81e7691835628735400f5484af48096509 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 21:24:50 +0400 Subject: [PATCH] Move the Active Fires hand-off up to 4,500 km The heatmap now takes over above 4,500 km instead of 3,000 km, with the cross-fade between 3,600 and 5,400 km. The markers band caps at 40,000 detections, which covers the ~40,000 in the padded view over Africa at the low end of the fade, so weak fires still stay as markers. To keep that rebuild near its old cost, markers outside the close band read the anchor already built for each fire instead of the ground floor cache: only the close band warms floors, so the lookup there always missed. A 40,000-marker rebuild now costs ~55 ms, down from ~120 ms. --- changelog.d/1426-fire-intensity-filter.md | 2 +- docs/CURRENT-STATE.md | 13 +++++++------ src/data/firmsInteraction.test.mjs | 2 +- src/layers/firms/model.js | 18 ++++++++++++++++-- src/layers/firms/policy.js | 16 ++++++++-------- src/layers/firms/rendering.js | 9 +++++++-- 6 files changed, 40 insertions(+), 20 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index d22e3f843..aacc37d71 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -5,7 +5,7 @@ High confidence switch and an "N of M shown" counter to the layer row. Far zoom shows a Snap Map–style heatmap in the same colors instead of squares, one distinct spot per fire, empty where nothing burns, and cross-fades with - the markers around 3,000 km with no gap, keeping weak fires as markers. The filter applies once before + the markers around 4,500 km with no gap, keeping weak fires as markers. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 84325e63d..934376fc7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2205,7 +2205,7 @@ none is a cold hue, so the brightest marks are the strongest fires; ~90% of 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 +At far zoom (camera above ~4,500 km) the layer drapes a Snap Map–style 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 @@ -2221,14 +2221,15 @@ nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt on each data refresh or filter change (the first far-band paint at once, later ones 150 ms after the last change). Heatmap and markers are both kept -built at every zoom and cross-fade by camera height between 2,400 and -3,600 km, every frame, so a fast zoom never shows a gap: the heatmap fades by +built at every zoom and cross-fade by camera height between 3,600 and +5,400 km, every frame, so a fast zoom never shows a gap: the heatmap fades by imagery `alpha` (a hidden Cesium imagery layer is unmapped from the globe tiles and re-mapped late when shown), the markers by sprite alpha, and the -marker collection is hidden once the heatmap is whole. The 750–3,000 km -markers band draws up to 20,000 detections (the strongest in the padded +marker collection is hidden once the heatmap is whole. The 750–4,500 km +markers band draws up to 40,000 detections (the strongest in the padded view), so the weak fires the heatmap shows stay on screen as markers when it -fades out. "N FIRES" cards from a 2° / 1° aggregation still label the hottest +fades out; it reads each marker's already-built anchor instead of the ground +floor cache, which only the close band warms. "N FIRES" cards from a 2° / 1° aggregation still label the hottest areas; their accent is the strongest fire's FRP color. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index fddf82f49..8e12a435d 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -521,7 +521,7 @@ test('far zoom drapes one heatmap and the hand-off cross-fades it with the marke assert.equal(markers.get(0).color.alpha, 1); // Mid hand-off both are drawn, half each, in either band. - setHeight(3_000_000); + setHeight(4_500_000); h.layer._applyHandoffFadeForTest(); assert.equal(added[0].alpha, 0.5); assert.equal(markers.show, true); diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index aefb94b21..812f4f462 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -231,6 +231,19 @@ export function createModel({ return fire.position; } + /** + * The anchor already built for a detection, without the floor lookup; + * for the far marker band, where floors are never warmed and the height + * difference is invisible, so tens of thousands of markers skip a cache + * read each per rebuild. Builds one through {@link firePosition} once. + * @param {Object} fire - Detection record. + * @returns {Cesium.Cartesian3} + */ + + function fireBuiltPosition(fire) { + return fire.position ?? firePosition(fire); + } + /** * Occlusion-test anchor for one detection. Returns the render position * outright for anchors comfortably above the ellipsoid (the common case) and @@ -238,11 +251,11 @@ export function createModel({ * NEVER feeds rendering: the datum-correct anchor from {@link firePosition} * is what the sprite and the card are drawn at. * @param {Object} fire - Detection record. + * @param {Cesium.Cartesian3} [position] Its render anchor, if at hand. * @returns {Cesium.Cartesian3} */ - function fireCullPosition(fire) { - const position = firePosition(fire); + function fireCullPosition(fire, position = firePosition(fire)) { if (fire.positionHeight >= CULL_LIFT_THRESHOLD_M) return position; if (!fire.cullPosition) { fire.cullPosition = Cesium.Cartesian3.fromDegrees( @@ -587,6 +600,7 @@ export function createModel({ sizeBucket, glowSprite, firePosition, + fireBuiltPosition, fireCullPosition, cellCullPosition, applyHorizonCull, diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 8431655bd..e6f4bd664 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -7,7 +7,7 @@ import * as Cesium from 'cesium'; export const REFRESH_INTERVAL_MS = 600_000; /** - * LOD bands keyed by camera height. `heat` bands (above ~3,000 km) drape a + * LOD bands keyed by camera height. `heat` bands (above ~4,500 km) drape a * Snap Map–style heatmap over the globe (firmsHeatRaster.js); their * `gridDegrees` aggregation only feeds the "N FIRES" cards. `detections` * bands render individual fire detections as glow-sprite billboards. @@ -26,21 +26,21 @@ export const LOD_LEVELS = [ }, { id: 'regional', - minHeight: 3000000, + minHeight: 4500000, mode: 'heat', gridDegrees: 1.0, maxCells: 3600, labelDistance: 8500000, }, { - // Up to 20,000 markers: at the 2,400 km end of the hand-off the padded - // view over Africa, the densest region, holds ~21,000 detections, and a + // Up to 40,000 markers: at the 3,600 km end of the hand-off the padded + // view over Africa, the densest region, holds ~40,000 detections, and a // tighter cap left the heatmap's weak spots dissolving into nothing. // A rebuild of that many costs ~60 ms, only there. id: 'local', minHeight: 750000, mode: 'detections', - maxDetections: 20000, + maxDetections: 40000, labelDistance: 4500000, }, { @@ -55,7 +55,7 @@ export const LOD_LEVELS = [ /** * Global heat raster for the `heat` bands: ≈0.09° (≈10 km) per pixel and a * one-pixel Gaussian σ, so a lone fire is a spot about the size of its - * marker at the 3,000 km hand-off and the two cross-fade without a jump; + * marker near the hand-off and the two cross-fade without a jump; * neighbours merge only when they nearly touch (σ 3 on a 2048 grid smeared * each fire over ~200 km). Built once per data refresh or filter change, * never per camera move. @@ -69,12 +69,12 @@ export const HEAT_RASTER = Object.freeze({ /** * Camera heights, in metres, over which the heatmap and the markers - * cross-fade around the 3,000 km band edge: markers alone below `from`, + * cross-fade around the 4,500 km band edge: markers alone below `from`, * heatmap alone above `to`. Wider than the ±10 % band hysteresis, so * whichever band is active inside it, both layers are already built. */ -export const HANDOFF_FADE = Object.freeze({ from: 2_400_000, to: 3_600_000 }); +export const HANDOFF_FADE = Object.freeze({ from: 3_600_000, to: 5_400_000 }); /** * Trailing delay before a stale heatmap is rebuilt while one is already on diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index d6e8b2d3b..9718470f8 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -354,10 +354,15 @@ export function createRendering({ } const markers = []; + // Only the close band warms terrain floors (renderDetections). + const anchor = + lod.id === 'close' + ? components.model.firePosition + : components.model.fireBuiltPosition; for (const fire of candidates) { const coreSize = components.model.frpPixelSize(fire.frp); - const position = components.model.firePosition(fire); - const cullPosition = components.model.fireCullPosition(fire); + const position = anchor(fire); + const cullPosition = components.model.fireCullPosition(fire, position); const pickId = `firms-${fire.index}`; layerState._pickIndexById.set(pickId, fire); layerState._cullPositions.push(cullPosition);