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);