diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index 2126e3c7f..2254b1169 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -4,9 +4,9 @@ log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / 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, with a ½×–4× spot - size slider. The filter applies once before rendering, so markers, cards, - the heatmap, context and the top-fires query agree; share links keep the - filter and the spot size (`w` token: `f`, `c`, `s`), and `get_active_fires` + one distinct spot per fire, empty where nothing burns, and cross-fades with + the markers around 3,000 km with no gap. 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 - / 1× (gekh, #1426). + (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 76cbd573a..8fdb5b582 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2209,37 +2209,36 @@ At far zoom (camera above ~3,000 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 -global 2048 × 1024 grid (≈20 km per pixel), each weighted by its FRP in megawatts (so heat is +global 4096 × 2048 grid (≈10 km per pixel), each weighted by its FRP in megawatts (so heat is radiated power, and raising the FRP slider does not dim areas where strong fires -burn), Gaussian-blurred by one pixel so each fire stays a distinct spot about -twice the size of its marker, +burn, and a cluster of detections reads brighter than one), Gaussian-blurred by one +pixel so each fire stays a distinct spot about the size of its marker, normalized on a log scale against the 99.5th percentile and mapped through the same hues, from a bright red edge through orange and gold to a pale white-hot core. The edge starts more than half opaque, so regions far below the hottest ones still read over forest and savanna imagery. Nothing is painted where 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 paint at once, later ones -150 ms after the last change) and hidden once markers take over below -~750 km. "N FIRES" cards from a 2° / 1° aggregation still label the hottest +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 +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. "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 whose track is painted with the ramp and ticked at the same six values (its value reads "All" or "≥ N MW"), a "Confidence" switch with All / Nominal+ / -High, a "Heatmap spot size" slider (½× to 4× on a log2 axis, 5 % steps, -default 1×) and an "N of M shown" counter. The spot size scales the heat -grid rather than the blur (2× is a 1024 × 512 grid with the same one-pixel -blur), so every spot grows exactly and weak lone fires do not fade as they -widen; changing it rebuilds only the heatmap, not the shown set. The default is 0 MW and +High, and an "N of M shown" counter. The default is 0 MW and All, because many of the strongest detections are rated low confidence. The filter runs once in `ingestion.applyFilter()` before rendering, so markers, cards, the heatmap, the shared context store, the top-fires query and the analyst records all see one filtered set; a selected fire that no longer passes -is deselected. The layer state keeps the options under the `w` (`local-firms`) -share token as `f` (whole megawatts, 0–10,000), `c` (`a`, `n`, `h`) and `s` -(spot size in percent, 50–400), including the short share codec; a link -without them restores the defaults. +is deselected. The layer state keeps both options under the `w` (`local-firms`) +share token as `f` (whole megawatts, 0–10,000) and `c` (`a`, `n`, `h`), +including the short share codec; a link without them restores the defaults. The `get_active_fires` tool accepts its own `min_frp_mw` and `confidence` (`all`, `nominal`, `high`) arguments, independent of the panel's setting, and diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js index 85894b19e..6704cf6e2 100644 --- a/src/data/firmsHeatRaster.js +++ b/src/data/firmsHeatRaster.js @@ -43,70 +43,6 @@ export function heatColorAt(t) { ]; } -/** - * Spot size, in percent of the shipped size: the panel slider and share - * option `w.s`. Whole percents, half to four times. - */ -export const HEAT_SPOT_SIZE_MIN = 50; -export const HEAT_SPOT_SIZE_MAX = 400; -export const HEAT_SPOT_SIZE_DEFAULT = 100; - -/** - * Spot size → whole percent inside the bounds, or null. - * @param {*} value - * @returns {?number} - */ -export function normalizeHeatSpotSize(value) { - const number = - typeof value === 'number' - ? value - : typeof value === 'string' && value.trim() !== '' - ? Number(value) - : NaN; - if (!Number.isFinite(number)) return null; - const rounded = Math.round(number); - return rounded >= HEAT_SPOT_SIZE_MIN && rounded <= HEAT_SPOT_SIZE_MAX - ? rounded - : null; -} - -/** - * The grid for a spot size. The same one-pixel blur on a grid that many - * times coarser (or finer) scales every spot exactly; widening the blur - * instead lowers each spot's peak, so weak lone fires faded as they grew. - * @param {{width: number, height: number, sigmaPx: number}} base Shipped grid. - * @param {number} spotSize Percent of the shipped size. - * @returns {{width: number, height: number, sigmaPx: number}} - */ -export function heatGridForSpotSize(base, spotSize) { - const percent = normalizeHeatSpotSize(spotSize) ?? HEAT_SPOT_SIZE_DEFAULT; - const height = Math.round((base.height * HEAT_SPOT_SIZE_DEFAULT) / percent); - return { width: height * 2, height, sigmaPx: base.sigmaPx }; -} - -/** - * Slider axis for the spot size: log2, so ½× and 2× sit equally far from 1×. - * Values snap to 5 % steps, which is also their share-link form. - */ -const SPOT_LOG_MIN = Math.log2(HEAT_SPOT_SIZE_MIN / 100); -const SPOT_LOG_SPAN = Math.log2(HEAT_SPOT_SIZE_MAX / 100) - SPOT_LOG_MIN; - -/** Track position in 0..1 for a spot size in percent. */ -export function sliderPositionForSpotSize(spotSize) { - const percent = normalizeHeatSpotSize(spotSize) ?? HEAT_SPOT_SIZE_DEFAULT; - return (Math.log2(percent / 100) - SPOT_LOG_MIN) / SPOT_LOG_SPAN; -} - -/** Spot size in percent for a track position in 0..1. */ -export function spotSizeForSliderPosition(position) { - const p = Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0; - const percent = 100 * Math.pow(2, SPOT_LOG_MIN + p * SPOT_LOG_SPAN); - return Math.min( - HEAT_SPOT_SIZE_MAX, - Math.max(HEAT_SPOT_SIZE_MIN, Math.round(percent / 5) * 5), - ); -} - /** Weight of a detection with no or zero FRP, in megawatts. */ export const HEAT_MIN_WEIGHT_MW = 0.5; diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs index 9fc0e02c8..754493754 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -7,10 +7,6 @@ import { binFires, blurGaussian, buildHeatRaster, - heatGridForSpotSize, - normalizeHeatSpotSize, - sliderPositionForSpotSize, - spotSizeForSliderPosition, } from './firmsHeatRaster.js'; const fire = (lat, lon, frp = 10) => ({ lat, lon, frp }); @@ -120,60 +116,3 @@ 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)); }); - -test('a spot size scales the grid, not the blur', () => { - const base = { width: 2048, height: 1024, sigmaPx: 1 }; - assert.deepEqual(heatGridForSpotSize(base, 100), base); - assert.deepEqual(heatGridForSpotSize(base, 200), { - width: 1024, - height: 512, - sigmaPx: 1, - }); - assert.deepEqual(heatGridForSpotSize(base, 50), { - width: 4096, - height: 2048, - sigmaPx: 1, - }); - const odd = heatGridForSpotSize(base, 150); - assert.equal(odd.width, odd.height * 2, 'the grid stays 2:1'); - assert.deepEqual(heatGridForSpotSize(base, 9999), base, 'invalid → default'); -}); - -test('twice the spot size paints a lone fire twice as wide', () => { - const base = { width: 2048, height: 1024, sigmaPx: 1 }; - const fires = [fire(0, 0, 5)]; - for (let i = 0; i < 40; i++) fires.push(fire(-10, 25 + i * 0.05, 300)); - const widthKm = (spotSize) => { - const r = buildHeatRaster(fires, heatGridForSpotSize(base, spotSize)); - const y = Math.floor(r.height / 2); - let painted = 0; - for (let x = 0; x < r.width; x++) { - const lon = ((x + 0.5) / r.width) * 360 - 180; - if (Math.abs(lon) < 10 && r.rgba[(y * r.width + x) * 4 + 3] > 0) { - painted += 1; - } - } - return (painted * 40075) / r.width; - }; - const ratio = widthKm(200) / widthKm(100); - assert.ok(ratio > 1.7 && ratio < 2.3, `ratio ${ratio.toFixed(2)}`); -}); - -test('the spot-size slider is log2 with 1x a third along and 5 % steps', () => { - assert.equal(sliderPositionForSpotSize(50), 0); - assert.equal(sliderPositionForSpotSize(400), 1); - assert.ok(Math.abs(sliderPositionForSpotSize(100) - 1 / 3) < 1e-9); - assert.ok(Math.abs(sliderPositionForSpotSize(200) - 2 / 3) < 1e-9); - assert.equal(spotSizeForSliderPosition(0), 50); - assert.equal(spotSizeForSliderPosition(1), 400); - assert.equal(spotSizeForSliderPosition(1 / 3), 100); - assert.equal(spotSizeForSliderPosition(-1), 50); - for (let i = 0; i <= 100; i++) { - const percent = spotSizeForSliderPosition(i / 100); - assert.equal(percent % 5, 0, `${percent} at ${i}%`); - assert.equal(normalizeHeatSpotSize(percent), percent); - } - assert.equal(normalizeHeatSpotSize('150'), 150); - assert.equal(normalizeHeatSpotSize(49), null); - assert.equal(normalizeHeatSpotSize(''), null); -}); diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index cd901f729..fddf82f49 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -15,6 +15,7 @@ import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { HEAT_REBUILD_MS } from '../layers/firms/policy.js'; const LAYER_SOURCE = readLayerSource( path.join(path.dirname(fileURLToPath(import.meta.url)), 'firmsHeatmap.js'), @@ -393,7 +394,58 @@ test('disable cancels a pending source and a late response cannot repopulate the } finally { h.layer.destroy(h.viewer); h.cleanup(); } }); -test('far zoom drapes one heatmap and draws no rectangles; markers and disable hide it', () => { +test('the heatmap is preloaded under the markers before the first zoom-out', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const priorDocument = globalThis.document; + globalThis.document = { + createElement: () => ({ + getContext: () => ({ + createImageData: (w, hh) => ({ data: new Uint8ClampedArray(w * hh * 4) }), + putImageData() {}, + createRadialGradient: () => ({ addColorStop() {} }), + fillRect() {}, + }), + toDataURL: () => 'data:image/png;base64,AAAA', + }), + }; + const h = harness({ + withDataSource: true, + cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 1_000_000), + }); + h.viewer.camera = { positionCartographic: { height: 1_000_000 } }; + h.viewer.scene.primitives = { add() {}, remove() {}, raiseToTop() {}, contains: () => true }; + h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D }; + const added = []; + try { + h.layer.attachShellServices({ + imageryHost: () => ({ + kind: 'globe', + collection: { + addImageryProvider: (provider) => { + const layer = { provider, show: true, alpha: 1 }; + added.push(layer); + return layer; + }, + remove() {}, + raiseToTop() {}, + }, + }), + }); + h.layer._renderLodForTest('close'); + assert.equal(added.length, 0, 'the markers paint first; the build trails'); + t.mock.timers.tick(HEAT_REBUILD_MS); + assert.equal(added.length, 1, 'then the heatmap is draped'); + assert.equal(added[0].alpha, 0, 'invisible until the hand-off'); + assert.equal(added[0].show, true, 'but mapped onto the globe tiles'); + } finally { + h.layer.destroy(h.viewer); + h.cleanup(); + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + } +}); + +test('far zoom drapes one heatmap and the hand-off cross-fades it with the markers', () => { // A canvas stub for the heat drape and the detection glow sprites. const priorDocument = globalThis.document; const restoreDocument = () => { @@ -420,8 +472,16 @@ test('far zoom drapes one heatmap and draws no rectangles; markers and disable h }); const added = []; const removed = []; + let markers = null; + // The fade reads the camera height; the cull reads scene.camera. + const setHeight = (height) => { + h.viewer.camera = { positionCartographic: { height } }; + }; + setHeight(12_000_000); h.viewer.scene.primitives = { - add() {}, + add(primitive) { + markers = primitive; + }, remove() {}, raiseToTop() {}, contains: () => true, @@ -430,7 +490,7 @@ test('far zoom drapes one heatmap and draws no rectangles; markers and disable h try { const collection = { addImageryProvider: (provider) => { - const layer = { provider, show: true }; + const layer = { provider, show: true, alpha: 1 }; added.push(layer); return layer; }, @@ -443,17 +503,33 @@ test('far zoom drapes one heatmap and draws no rectangles; markers and disable h h.layer._renderLodForTest('global'); assert.equal(added.length, 1, 'one heat imagery layer'); assert.equal(added[0].show, true); + assert.equal(added[0].alpha, 1); assert.equal(h.layer._entityCountForTest(), 0, 'no rectangle entities'); assert.ok(h.published.length > 0, 'the far band still paints cards'); + assert.equal(markers.length, 2, 'the markers are built under the heatmap'); + assert.equal(markers.show, false, 'and hidden while the heatmap is whole'); h.layer._renderLodForTest('regional'); assert.equal(added.length, 1, 'switching far bands reuses the raster'); + setHeight(1_000_000); h.layer._renderLodForTest('close'); - assert.equal(added[0].show, false, 'the markers band hides the heatmap'); + assert.equal(added.length, 1, 'the markers band keeps the same heat layer'); + assert.equal(added[0].show, true, 'still mapped onto the globe tiles'); + assert.equal(added[0].alpha, 0, 'faded out under the markers'); + assert.equal(markers.show, true); + assert.equal(markers.get(0).color.alpha, 1); + + // Mid hand-off both are drawn, half each, in either band. + setHeight(3_000_000); + h.layer._applyHandoffFadeForTest(); + assert.equal(added[0].alpha, 0.5); + assert.equal(markers.show, true); + assert.equal(markers.get(0).color.alpha, 0.5); + h.layer._renderLodForTest('regional'); + assert.equal(added[0].alpha, 0.5); + assert.equal(markers.get(1).color.alpha, 0.5, 'rebuilt markers get the fade'); - h.layer._renderLodForTest('global'); - assert.equal(added[0].show, true); h.layer.disable(); assert.equal(added[0].show, false, 'disable hides the heatmap'); @@ -467,66 +543,3 @@ test('far zoom drapes one heatmap and draws no rectangles; markers and disable h restoreDocument(); } }); - -test('the spot-size slider rebuilds only the heatmap, on a proportionally coarser grid', async () => { - const priorDocument = globalThis.document; - const grids = []; - globalThis.document = { - createElement: () => ({ - getContext: () => ({ - createImageData: (w, hh) => { - grids.push([w, hh]); - return { data: new Uint8ClampedArray(w * hh * 4) }; - }, - putImageData() {}, - createRadialGradient: () => ({ addColorStop() {} }), - fillRect() {}, - }), - toDataURL: () => 'data:image/png;base64,AAAA', - }), - }; - const h = harness({ - fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })], - withDataSource: true, - cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000), - }); - h.viewer.scene.primitives = { add() {}, remove() {}, raiseToTop() {}, contains: () => true }; - h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D }; - const collection = { - addImageryProvider: (provider) => ({ provider, show: true }), - remove() {}, - raiseToTop() {}, - }; - try { - h.layer.attachShellServices({ imageryHost: () => ({ kind: 'globe', collection }) }); - h.layer._renderLodForTest('global'); - assert.deepEqual(grids.at(-1), [2048, 1024]); - assert.deepEqual( - h.layer.getRowControls().sliders.map((slider) => slider.id), - ['min-frp', 'heat-spot-size'], - ); - const spot = h.layer.getRowControls().sliders[1]; - assert.equal(spot.valueText, '1×'); - assert.deepEqual(spot.params(1000), { spotSize: 400 }); - - let repaints = 0; - h.layer.setRowControlsListener(() => repaints++); - h.layer.setParams({ spotSize: 200 }); - assert.equal(h.layer.getParams().spotSize, 200); - assert.equal(h.layer.getParams().minFrp, 0, 'the filter is untouched'); - assert.equal(h.layer.getStats().count, 2, 'every fire still shown'); - assert.equal(repaints, 1, 'the row repaints its value'); - assert.equal(h.layer.getRowControls().sliders[1].valueText, '2×'); - // The rebuild trails a drag (HEAT_REBUILD_MS) and keeps the old drape meanwhile. - await new Promise((resolve) => setTimeout(resolve, 200)); - assert.deepEqual(grids.at(-1), [1024, 512]); - - h.layer.setParams({ spotSize: 'nope' }); - assert.equal(h.layer.getParams().spotSize, 200, 'invalid input is ignored'); - } finally { - h.layer.destroy(h.viewer); - h.cleanup(); - if (priorDocument === undefined) delete globalThis.document; - else globalThis.document = priorDocument; - } -}); diff --git a/src/data/layerState.js b/src/data/layerState.js index a049a80c5..c68a67e67 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -298,7 +298,7 @@ function satelliteFilterOption() { } const OPTION_GROUPS = Object.freeze({ - // Active Fires intensity filter and heatmap spot size (HAL-676). `minFrp` is whole megawatts on + // Active Fires intensity filter (HAL-676). `minFrp` is whole megawatts on // the codec side; the panel's log slider rounds to it. Both default to // "show everything": many of the strongest fires are rated low confidence. // The bounds and modes mirror MIN_FRP_MW_MAX / FIRMS_CONFIDENCE_MODES in @@ -311,9 +311,6 @@ const OPTION_GROUPS = Object.freeze({ nominal: 'n', high: 'h', }), - // Heatmap spot size in percent; mirrors HEAT_SPOT_SIZE_* in - // firmsHeatRaster.js. - boundedIntegerOption('spotSize', 's', 100, { min: 50, max: 400 }), ]), traffic: Object.freeze([ enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], { diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 78b36c3a6..55d40dfe2 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -30,11 +30,6 @@ import { stampInitialShareGesture } from '../navigationPolicy.js'; import { MAX_SINCE_DAYS } from '../layers/streetLevel/policy.js'; import { SINCE_STOPS } from '../ui/streetLevelPresentation.js'; import { FIRMS_CONFIDENCE_MODES, MIN_FRP_MW_MAX } from './firmsIntensity.js'; -import { - HEAT_SPOT_SIZE_DEFAULT, - HEAT_SPOT_SIZE_MAX, - HEAT_SPOT_SIZE_MIN, -} from './firmsHeatRaster.js'; function deferred() { let resolve; @@ -3024,14 +3019,9 @@ test('Active Fires: the intensity filter rides share token w and round-trips', ( assert.deepEqual(state.options['local-firms'], { minFrp: 0, confidence: 'all', - spotSize: 100, }); state.enabledLayerIds = ['local-firms']; - state.options['local-firms'] = { - minFrp: 20, - confidence: 'high', - spotSize: 250, - }; + state.options['local-firms'] = { minFrp: 20, confidence: 'high' }; const encoded = encode(state); assert.equal(new URLSearchParams(encoded).get('l'), 'w'); // Other owners write their own explicit defaults into `lo` too (flights 3D, @@ -3039,12 +3029,10 @@ test('Active Fires: the intensity filter rides share token w and round-trips', ( const assignments = new URLSearchParams(encoded).get('lo').split('_'); assert.ok(assignments.includes('w.f.20'), assignments.join(' ')); assert.ok(assignments.includes('w.c.h'), assignments.join(' ')); - assert.ok(assignments.includes('w.s.250'), assignments.join(' ')); const decoded = decodeLayerStateParams(new URLSearchParams(encoded)); assert.deepEqual(decoded.options['local-firms'], { minFrp: 20, confidence: 'high', - spotSize: 250, }); assert.deepEqual( parseStoredLayerState(serializeStoredLayerState(state)).options[ @@ -3062,11 +3050,7 @@ test('Active Fires: the intensity filter rides share token w and round-trips', ( .filter((assignment) => assignment.startsWith('w.')).length, 0, ); - assert.deepEqual(firmsOptions(''), { - minFrp: 0, - confidence: 'all', - spotSize: 100, - }); + assert.deepEqual(firmsOptions(''), { minFrp: 0, confidence: 'all' }); }); test('Active Fires: the codec accepts whole megawatts in bounds and the three confidence codes', () => { @@ -3094,17 +3078,5 @@ test('Active Fires: the codec accepts whole megawatts in bounds and the three co assert.deepEqual(firmsOptions('w.c.n_w.f.5'), { minFrp: 5, confidence: 'nominal', - spotSize: 100, }); }); - -test('Active Fires: the codec accepts whole-percent spot sizes from half to four times', () => { - assert.equal(HEAT_SPOT_SIZE_MIN, 50); - assert.equal(HEAT_SPOT_SIZE_MAX, 400); - assert.equal(HEAT_SPOT_SIZE_DEFAULT, 100); - assert.equal(firmsOptions('w.s.50').spotSize, 50); - assert.equal(firmsOptions('w.s.400').spotSize, 400); - assert.equal(firmsOptions('w.s.45').spotSize, 100, 'out of bounds → default'); - assert.equal(firmsOptions('w.s.401').spotSize, 100); - assert.equal(firmsOptions('w.s.x').spotSize, 100); -}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index b65608ed6..c96530641 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3224,9 +3224,6 @@ "≥ {value} MW": "{value} MW على الأقل", "Confidence": "الثقة", "Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط", - "Heatmap spot size": "حجم بقع الخريطة الحرارية", - "Far-zoom heatmap spot size": "حجم بقع الخريطة الحرارية عند العرض البعيد", - "{value}×": "{value}×", "fire intensity filter|All": "الكل", "Detection confidence": "ثقة الرصد", "{shown} of {total} shown": "المعروضة: {shown} من {total}", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 8bbec9f29..b4e02ea23 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2944,9 +2944,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Konfidenz", "Minimum fire radiative power, megawatts": "Minimale Strahlungsleistung des Brandes in Megawatt", - "Heatmap spot size": "Fleckengröße der Heatmap", - "Far-zoom heatmap spot size": "Fleckengröße der Heatmap in der Fernansicht", - "{value}×": "{value}×", "fire intensity filter|All": "Alle", "Detection confidence": "Konfidenz der Erkennung", "{shown} of {total} shown": "{shown} von {total} angezeigt", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3721c1ea0..ddfc90df9 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2993,9 +2993,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confianza", "Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios", - "Heatmap spot size": "Tamaño de las manchas del mapa de calor", - "Far-zoom heatmap spot size": "Tamaño de las manchas del mapa de calor en la vista lejana", - "{value}×": "{value}×", "fire intensity filter|All": "Todo", "Detection confidence": "Confianza de la detección", "{shown} of {total} shown": "{shown} de {total} mostrados", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 17c4dcf9d..d368bb387 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3014,9 +3014,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confiance", "Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts", - "Heatmap spot size": "Taille des taches de la carte de chaleur", - "Far-zoom heatmap spot size": "Taille des taches de la carte de chaleur en vue éloignée", - "{value}×": "{value}×", "fire intensity filter|All": "Toutes", "Detection confidence": "Confiance de la détection", "{shown} of {total} shown": "{shown} sur {total} affichées", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 8d43c74d7..9f1821423 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3014,9 +3014,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confidenza", "Minimum fire radiative power, megawatts": "Potenza radiativa minima dell'incendio, in megawatt", - "Heatmap spot size": "Dimensione delle macchie della mappa di calore", - "Far-zoom heatmap spot size": "Dimensione delle macchie della mappa di calore nella vista da lontano", - "{value}×": "{value}×", "fire intensity filter|All": "Tutti", "Detection confidence": "Confidenza del rilevamento", "{shown} of {total} shown": "{shown} di {total} mostrati", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0725812a4..192c52a27 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2874,9 +2874,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "信頼度", "Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)", - "Heatmap spot size": "ヒートマップの点の大きさ", - "Far-zoom heatmap spot size": "遠景ヒートマップの点の大きさ", - "{value}×": "{value}×", "fire intensity filter|All": "すべて", "Detection confidence": "検知の信頼度", "{shown} of {total} shown": "{total}件中{shown}件を表示", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 74316dc1b..0a6617eb8 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3014,9 +3014,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confiança", "Minimum fire radiative power, megawatts": "Potência radiativa mínima do fogo, em megawatts", - "Heatmap spot size": "Tamanho das manchas do mapa de calor", - "Far-zoom heatmap spot size": "Tamanho das manchas do mapa de calor na vista distante", - "{value}×": "{value}×", "fire intensity filter|All": "Todas", "Detection confidence": "Confiança da detecção", "{shown} of {total} shown": "{shown} de {total} exibidas", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index db69d0f4a..9551c1b52 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3063,9 +3063,6 @@ "≥ {value} MW": "≥ {value} МВт", "Confidence": "Достоверность", "Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты", - "Heatmap spot size": "Размер пятен тепловой карты", - "Far-zoom heatmap spot size": "Размер пятен тепловой карты на дальнем масштабе", - "{value}×": "{value}×", "fire intensity filter|All": "Все", "Detection confidence": "Достоверность детекции", "{shown} of {total} shown": "показано {shown} из {total}", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 4d05d26b6..bff311c79 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3055,9 +3055,6 @@ "≥ {value} MW": "≥ {value} МВт", "Confidence": "Достовірність", "Minimum fire radiative power, megawatts": "Мінімальна потужність випромінювання пожежі, мегавати", - "Heatmap spot size": "Розмір плям теплової карти", - "Far-zoom heatmap spot size": "Розмір плям теплової карти на далекому масштабі", - "{value}×": "{value}×", "fire intensity filter|All": "Усі", "Detection confidence": "Достовірність виявлення", "{shown} of {total} shown": "показано {shown} з {total}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e3a4762c5..1481226da 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2853,9 +2853,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "置信度", "Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)", - "Heatmap spot size": "热力图光斑大小", - "Far-zoom heatmap spot size": "远景热力图光斑大小", - "{value}×": "{value}×", "fire intensity filter|All": "全部", "Detection confidence": "检测置信度", "{shown} of {total} shown": "显示 {shown} / {total}", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index af214e6e7..cd182ecfd 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2880,9 +2880,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "置信度", "Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)", - "Heatmap spot size": "熱力圖光斑大小", - "Far-zoom heatmap spot size": "遠景熱力圖光斑大小", - "{value}×": "{value}×", "fire intensity filter|All": "全部", "Detection confidence": "檢測置信度", "{shown} of {total} shown": "顯示 {shown} / {total}", diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 9310e0d97..7aa51d344 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -31,7 +31,6 @@ "dB", "hPa", "{source} HTTP {status}", - "{value}×", "▲ {frp} MW" ], "de": [ @@ -198,7 +197,6 @@ "{value} KM DOWNRANGE", "{value} KM/H", "{value} KM/S", - "{value}×", "ΔN", "Ⅱ PAUSE", "≥ {value} MW", @@ -329,7 +327,6 @@ "{value} KM", "{value} KM/H", "{value} KM/S", - "{value}×", "≥ {value} MW", "ΔE", "ΔN", @@ -511,7 +508,6 @@ "{value} KM", "{value} KM/H", "{value} KM/S", - "{value}×", "ΔE", "ΔN", "Ⅱ PAUSE", @@ -685,7 +681,6 @@ "{value} KM/H", "{value} KM/S", "{value} conf", - "{value}×", "≥ {value} MW", "ΔE", "ΔN", @@ -736,7 +731,6 @@ "{source} HTTP {status}", "{speed} kn", "{type} SPD: {speed} HDG: {heading}", - "{value}×", "ΔE", "ΔN", "≥ {value} MW", @@ -871,7 +865,6 @@ "{value} KM/S", "ΔN", "▲ {frp} MW", - "{value}×", "≥ {value} MW" ], "ru": [ @@ -890,8 +883,7 @@ "S2 GhostMaps", "S2 · {name}", "VS", - "WHOT/BHOT", - "{value}×" + "WHOT/BHOT" ], "uk": [ "AIS: --", @@ -904,8 +896,7 @@ "NVG", "REC", "S2 GhostMaps", - "WHOT/BHOT", - "{value}×" + "WHOT/BHOT" ], "zh-CN": [ "1090 MHz", @@ -960,7 +951,6 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", - "{value}×", "≥ {value} MW", "ΔE", "ΔN", @@ -1019,7 +1009,6 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", - "{value}×", "≥ {value} MW", "ΔE", "ΔN", diff --git a/src/layers/firms/heatImagery.js b/src/layers/firms/heatImagery.js index 31fbf1f69..710a87f43 100644 --- a/src/layers/firms/heatImagery.js +++ b/src/layers/firms/heatImagery.js @@ -3,6 +3,12 @@ * 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. + * + * `show` is only for the layer being on or off. The zoom hand-off fades + * `alpha` instead: Cesium unmaps a hidden imagery layer from the globe + * tiles and re-maps it when shown, so a show toggle at the band edge left + * a beat with neither markers nor heat. At alpha 0 the layer stays mapped + * and costs no draw. */ const defaultCanvas = (width, height) => { @@ -21,6 +27,7 @@ export function createHeatImagery({ let layer = null; let collection = null; let show = true; + let opacity = 1; function detach() { if (layer && collection) collection.remove(layer, true); @@ -47,6 +54,7 @@ export function createHeatImagery({ collection = host.collection; layer = collection.addImageryProvider(provider); layer.show = show; + layer.alpha = opacity; collection.raiseToTop?.(layer); } @@ -63,6 +71,11 @@ export function createHeatImagery({ show = Boolean(value); if (layer) layer.show = show; }, + /** Hand-off fade, 0..1; the layer stays mapped at 0. */ + setOpacity(value) { + opacity = Math.min(1, Math.max(0, Number(value) || 0)); + if (layer) layer.alpha = opacity; + }, rehome() { const host = getHost?.(); if ((host?.collection ?? null) !== collection) attach(); diff --git a/src/layers/firms/heatImagery.test.mjs b/src/layers/firms/heatImagery.test.mjs index 5543a0691..27f45f55f 100644 --- a/src/layers/firms/heatImagery.test.mjs +++ b/src/layers/firms/heatImagery.test.mjs @@ -71,6 +71,22 @@ test('setShow toggles without rebuilding', () => { assert.equal(layer.show, true); }); +test('setOpacity fades the same layer and survives a rebuild', () => { + const h = harness(); + h.imagery.setRaster(raster); + const layer = h.layers[0]; + h.imagery.setOpacity(0); + assert.equal(layer.alpha, 0); + assert.equal(layer.show, true, 'a faded layer stays mapped'); + h.imagery.setOpacity(0.25); + assert.equal(h.layers[0], layer); + assert.equal(layer.alpha, 0.25); + h.imagery.setRaster(raster); + assert.equal(h.layers[0].alpha, 0.25, 'a rebuilt layer keeps the fade'); + h.imagery.setOpacity(7); + assert.equal(h.layers[0].alpha, 1); +}); + test('no imagery host keeps nothing draped; rehome brings it back', () => { const h = harness(); h.setHost({ collection: null, kind: 'none' }); diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 72434ad46..541efca16 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -1,6 +1,5 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; -import { LOD_LEVELS } from './policy.js'; export function createLifecycle({ layerState, @@ -55,8 +54,9 @@ export function createLifecycle({ layerState._billboards.show = true; } overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true); - if (LOD_LEVELS[layerState._currentLodIndex]?.mode === 'heat') - layerState._heatImagery?.setShow(true); + layerState._heatImagery?.setShow(true); + // The camera can have crossed the hand-off while the layer was off. + components.rendering.applyHandoffFade(true); components.viewport.installLodWatcher(); components.viewport.installMoveEndWatcher(); components.selection.installClickHandler(); @@ -119,6 +119,7 @@ export function createLifecycle({ layerState._heatImagery?.destroy(); layerState._heatImagery = null; layerState._heatRaster = null; + layerState._handoffHeat = -1; layerState._imageryHost = null; layerState._count = 0; layerState._totalCount = 0; diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 2af5d9f5c..6ba8ebb8e 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -49,20 +49,29 @@ export const LOD_LEVELS = [ ]; /** - * Global heat raster for the `heat` bands: ≈0.18° (≈20 km) per pixel and a - * one-pixel Gaussian σ, so a lone fire is a distinct spot about twice as wide - * as its marker at the 3,000 km hand-off and still reads from the - * whole-globe view; neighbours merge only when they nearly touch (σ 3 here - * smeared each fire over ~200 km). Built once per data refresh or filter - * change, never per camera move. + * 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; + * 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. */ export const HEAT_RASTER = Object.freeze({ - width: 2048, - height: 1024, + width: 4096, + height: 2048, sigmaPx: 1, }); +/** + * Camera heights, in metres, over which the heatmap and the markers + * cross-fade around the 3,000 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 }); + /** * Trailing delay before a stale heatmap is rebuilt while one is already on * screen: a slider drag fires many filter changes, and a build plus PNG diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index b9577925e..34040ea43 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -8,11 +8,6 @@ import { rampTrackGradient, sliderPositionForFrp, } from '../../data/firmsIntensity.js'; -import { - normalizeHeatSpotSize, - sliderPositionForSpotSize, - spotSizeForSliderPosition, -} from '../../data/firmsHeatRaster.js'; import { formatNumber, t } from '../../i18n/index.js'; import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js'; @@ -28,16 +23,6 @@ export function createQueries({ }) { const { id, name, icon, source } = config; - /** Spot sizes marked under the spot-size slider, in percent. */ - const SPOT_SIZE_TICKS = [50, 100, 200, 400]; - - /** Spot-size label for a percent ("0.5×", "1×", "1.5×"). */ - function spotSizeLabel(percent) { - return t('{value}×', { - value: formatNumber(percent / 100, { maximumFractionDigits: 2 }), - }); - } - /** Whole-megawatt label for a slider value or tick ("0", "20", "1 000"). */ function frpLabel(frp) { return formatNumber(frp, { maximumFractionDigits: 0 }); @@ -98,12 +83,12 @@ export function createQueries({ updateInterval: REFRESH_INTERVAL_MS, /** - * The intensity filter and the heatmap spot size as layer options (share - * token `w`, owner `local-firms` in layerState.js). - * @returns {{minFrp: number, confidence: string, spotSize: number}} + * The intensity filter as layer options (share token `w`, owner + * `local-firms` in layerState.js). + * @returns {{minFrp: number, confidence: string}} */ getParams() { - return { ...layerState._filter, spotSize: layerState._spotSize }; + return { ...layerState._filter }; }, /** @@ -111,36 +96,24 @@ export function createQueries({ * value. The filter runs in one place (ingestion.applyFilter) and the LOD * rebuild re-derives markers, cells, cards and context registrations from * the shown set; a selected fire that no longer passes is deselected. - * A spot-size change only marks the heat raster stale: the shown set, - * markers and cards stay as they are. - * @param {{minFrp?: number, confidence?: string, spotSize?: number}} params + * @param {{minFrp?: number, confidence?: string}} params * @returns {boolean} True — the request is always accepted. */ setParams(params = {}) { const next = normalizeFireFilter(params, layerState._filter); - const spotSize = - normalizeHeatSpotSize(params?.spotSize) ?? layerState._spotSize; - const filterChanged = - next.minFrp !== layerState._filter.minFrp || - next.confidence !== layerState._filter.confidence; - const spotChanged = spotSize !== layerState._spotSize; - if (!filterChanged && !spotChanged) return true; - layerState._filter = next; - layerState._spotSize = spotSize; - if (!layerState._allFires.length) { - layerState._rowControlsListener?.(); + if ( + next.minFrp === layerState._filter.minFrp && + next.confidence === layerState._filter.confidence + ) return true; - } - if (filterChanged) { - components.ingestion.applyFilter(); - if ( - layerState._selectedFire && - !firePassesFilter(layerState._selectedFire, next) - ) { - components.selection.clearFireSelection(); - } - } else { - layerState._heatRaster = null; + layerState._filter = next; + if (!layerState._allFires.length) return true; + components.ingestion.applyFilter(); + if ( + layerState._selectedFire && + !firePassesFilter(layerState._selectedFire, next) + ) { + components.selection.clearFireSelection(); } if (layerState._enabled) components.rendering.renderCurrentLod(true); layerState._rowControlsListener?.(); @@ -150,8 +123,8 @@ export function createQueries({ /** * Row controls under the Active Fires row: the log-scale minimum-FRP * slider whose track is painted with the intensity ramp (so it doubles as - * the legend), the All / Nominal+ / High confidence switch, the far-zoom - * heatmap spot-size slider and the "N of M shown" counter. + * the legend), the All / Nominal+ / High confidence switch, and the + * "N of M shown" counter. */ getRowControls() { const { minFrp, confidence } = layerState._filter; @@ -160,48 +133,27 @@ export function createQueries({ const shown = layerState._count; const total = layerState._totalCount; return { - sliders: [ - { - id: 'min-frp', - label: t('Fire intensity (FRP)'), - ariaLabel: t('Minimum fire radiative power, megawatts'), - valueText: - minFrp > 0 - ? t('≥ {value} MW', { value: frpLabel(minFrp) }) - : t('All', { context: 'fire intensity filter' }), - min: 0, - max: SLIDER_STEPS, - step: 1, - value: Math.round(sliderPositionForFrp(minFrp) * SLIDER_STEPS), - track: rampTrackGradient(), - ticks: FRP_SLIDER_TICKS.map((frp) => ({ - at: sliderPositionForFrp(frp), - label: frpLabel(frp), - })), - params: (value) => ({ - minFrp: frpForSliderPosition(Number(value) / SLIDER_STEPS), - }), - }, - { - id: 'heat-spot-size', - label: t('Heatmap spot size'), - ariaLabel: t('Far-zoom heatmap spot size'), - valueText: spotSizeLabel(layerState._spotSize), - min: 0, - max: SLIDER_STEPS, - step: 1, - value: Math.round( - sliderPositionForSpotSize(layerState._spotSize) * SLIDER_STEPS, - ), - ticks: SPOT_SIZE_TICKS.map((percent) => ({ - at: sliderPositionForSpotSize(percent), - label: spotSizeLabel(percent), - })), - params: (value) => ({ - spotSize: spotSizeForSliderPosition(Number(value) / SLIDER_STEPS), - }), - }, - ], + slider: { + id: 'min-frp', + label: t('Fire intensity (FRP)'), + ariaLabel: t('Minimum fire radiative power, megawatts'), + valueText: + minFrp > 0 + ? t('≥ {value} MW', { value: frpLabel(minFrp) }) + : t('All', { context: 'fire intensity filter' }), + min: 0, + max: SLIDER_STEPS, + step: 1, + value: Math.round(sliderPositionForFrp(minFrp) * SLIDER_STEPS), + track: rampTrackGradient(), + ticks: FRP_SLIDER_TICKS.map((frp) => ({ + at: sliderPositionForFrp(frp), + label: frpLabel(frp), + })), + params: (value) => ({ + minFrp: frpForSliderPosition(Number(value) / SLIDER_STEPS), + }), + }, segmented: { id: 'confidence', label: t('Confidence'), @@ -359,10 +311,15 @@ export function createQueries({ /** Test seam: render one named LOD band against a near-global view. */ _renderLodForTest(lodId) { - const lod = LOD_LEVELS.find((level) => level.id === lodId); - if (lod.mode === 'detections') - components.rendering.renderDetections(lod, null); - else components.rendering.renderHeat(lod, null); + components.rendering.renderBand( + LOD_LEVELS.find((level) => level.id === lodId), + null, + ); + }, + + /** Test seam: one frame of the hand-off fade at the current camera. */ + _applyHandoffFadeForTest() { + components.rendering.applyHandoffFade(); }, /** Test seam: entities drawn by the layer's data source. */ diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index dd91effec..d6e8b2d3b 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -1,18 +1,22 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; import { horizonOccluder } from '../../data/iconOrientation.js'; -import { - buildHeatRaster, - heatGridForSpotSize, -} from '../../data/firmsHeatRaster.js'; +import { buildHeatRaster } from '../../data/firmsHeatRaster.js'; import { LOD_LEVELS, CONTEXT_TOP_N, + HANDOFF_FADE, HEAT_RASTER, HEAT_REBUILD_MS, } from './policy.js'; import { createHeatImagery } from './heatImagery.js'; +/** The markers drawn under the far bands' heatmap for the hand-off fade. */ +const HANDOFF_MARKER_LOD = LOD_LEVELS.find((lod) => lod.mode === 'detections'); + +/** Fade steps: a moving camera rewrites marker colors at most this often. */ +const FADE_STEPS = 32; + export function createRendering({ layerState, services, @@ -64,14 +68,54 @@ export function createRendering({ ? components.model.paddedDegreeBounds(viewRect) : null; - if (lod.mode === 'detections') { - renderDetections(lod, bounds); - } else { - renderHeat(lod, bounds); - } + renderBand(lod, bounds); return true; } + /** + * 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) { + prepareHeat(lod.mode === 'heat'); + if (lod.mode === 'detections') renderDetections(lod, bounds); + else renderHeat(lod, bounds); + applyHandoffFade(true); + } + + const fadeColor = new Cesium.Color(); + + /** + * 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. + * @param {boolean} [force] Reapply, e.g. to freshly added markers. + */ + + function applyHandoffFade(force = false) { + const height = components.viewport.cameraHeight(); + const t = Math.min( + 1, + Math.max( + 0, + (height - HANDOFF_FADE.from) / (HANDOFF_FADE.to - HANDOFF_FADE.from), + ), + ); + const heat = Math.round(t * t * (3 - 2 * t) * FADE_STEPS) / FADE_STEPS; + if (!force && heat === layerState._handoffHeat) return; + layerState._handoffHeat = heat; + layerState._heatImagery?.setOpacity(heat); + const billboards = layerState._billboards; + if (!billboards) return; + billboards.show = layerState._enabled && heat < 1; + const color = Cesium.Color.WHITE.withAlpha(1 - heat, fadeColor); + for (let i = 0; i < billboards.length; i += 1) + billboards.get(i).color = color; + } + /** * Bin all detections into grid cells, then viewport-clip BEFORE the * top-N cap so fires in view never lose their slot to higher-scoring @@ -147,28 +191,27 @@ export function createRendering({ return layerState._heatImagery; } - /** Build the heat raster from the shown set at the chosen spot size and drape it. */ + /** Build the heat raster from the shown set and drape it. */ function rebuildHeatRaster() { clearTimeout(layerState._heatRebuildTimer); layerState._heatRebuildTimer = null; if (layerState._destroyed) return; - layerState._heatRaster = buildHeatRaster( - layerState._fires, - heatGridForSpotSize(HEAT_RASTER, layerState._spotSize), - ); + layerState._heatRaster = buildHeatRaster(layerState._fires, HEAT_RASTER); heatImagery().setRaster(layerState._heatRaster); } /** - * Make sure the draped heatmap matches the shown set. The first paint is - * immediate; later rebuilds (filter drags, refreshes) trail by - * HEAT_REBUILD_MS and keep the previous heatmap on screen meanwhile. + * Make sure the draped heatmap matches the shown set. The first paint in a + * far band is immediate; other builds (filter drags, refreshes, the + * preload under the markers) trail by HEAT_REBUILD_MS and keep the + * previous heatmap on screen meanwhile. + * @param {boolean} now Build at once when nothing is draped yet. */ - function ensureHeatRaster() { + function ensureHeatRaster(now) { if (layerState._heatRaster) return; - if (!heatImagery().hasRaster()) { + if (now && !heatImagery().hasRaster()) { rebuildHeatRaster(); return; } @@ -179,10 +222,25 @@ export function createRendering({ ); } + /** + * Keep the heatmap draped on the current map stack and current with the + * shown set in every band; the hand-off fade decides how much of it shows. + * @param {boolean} now The camera is in a far band. + */ + + function prepareHeat(now) { + const imagery = heatImagery(); + // The map stack may have changed since the last render. + imagery.rehome(); + ensureHeatRaster(now); + imagery.setShow(layerState._enabled); + } + /** * 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. No entities and no sprites are drawn; + * 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 screen-space label pipeline as detections * (see {@link rebuildAmbientLabels}). * @param {Object} lod - Active LOD descriptor. @@ -190,17 +248,7 @@ export function createRendering({ */ function renderHeat(lod, bounds) { - ensureDetectionCollections(); - layerState._dataSource.entities.removeAll(); - if (layerState._billboards) layerState._billboards.removeAll(); - layerState._pickIndexById.clear(); - layerState._cullPositions.length = 0; - - const imagery = heatImagery(); - // The map stack may have changed since the last far render. - imagery.rehome(); - ensureHeatRaster(); - imagery.setShow(layerState._enabled); + paintMarkers(HANDOFF_MARKER_LOD, bounds); const cells = aggregateFires(lod, bounds); layerState._cellCount = cells.length; @@ -242,10 +290,48 @@ export function createRendering({ */ function renderDetections(lod, bounds) { - layerState._heatImagery?.setShow(false); + const markers = paintMarkers(lod, bounds); + if (!markers) return; + const candidates = markers.map((marker) => marker.fire); + + layerState._cellCount = candidates.length; + // FRP-sorted walk: marker order doubles as label priority. + layerState._labelCandidates = markers; + layerState._labelLodDistance = lod.labelDistance; + + components.cards.rebuildAmbientLabels(); + components.selection.refreshContextRegistrations( + candidates.slice(0, CONTEXT_TOP_N), + ); + + if (lod.id === 'close') { + // Batched (chunked ≤200, sequential, session-cached) DEM warm for the + // rendered subset; fires are static so each coarse cell resolves once + // ever. Re-render ONLY when a floor actually landed — a failed resolve + // reports false, so this chain terminates instead of looping against a + // down proxy (the next camera-driven rebuild retries). + warmFireAnchorFloors(candidates).then((warmed) => { + if (!warmed || !layerState._enabled || !layerState._viewer) return; + const currentLod = LOD_LEVELS[layerState._currentLodIndex]; + if (!currentLod || currentLod.mode !== 'detections') return; + renderCurrentLod(true); + }); + } + } + + /** + * The glow-sprite markers for a detections band: the strongest fires in + * view, capped at the band's maxDetections, horizon-culled. + * @param {Object} lod - Detections band descriptor. + * @param {?Object} bounds - Padded view bounds in degrees, or null. + * @returns {?Array} Label candidates for the fires drawn, + * strongest first; null before the sprite collection exists. + */ + + function paintMarkers(lod, bounds) { ensureDetectionCollections(); layerState._dataSource.entities.removeAll(); - if (!layerState._billboards) return; + if (!layerState._billboards) return null; layerState._billboards.removeAll(); layerState._pickIndexById.clear(); layerState._cullPositions.length = 0; @@ -267,10 +353,7 @@ export function createRendering({ candidates = layerState._firesByFrp.slice(0, lod.maxDetections); } - layerState._cellCount = candidates.length; - layerState._labelCandidates = []; - layerState._labelLodDistance = lod.labelDistance; - + const markers = []; for (const fire of candidates) { const coreSize = components.model.frpPixelSize(fire.frp); const position = components.model.firePosition(fire); @@ -278,6 +361,7 @@ export function createRendering({ const pickId = `firms-${fire.index}`; layerState._pickIndexById.set(pickId, fire); layerState._cullPositions.push(cullPosition); + markers.push({ position, cullPosition, fire, cell: null }); layerState._billboards.add({ id: pickId, position, @@ -289,37 +373,13 @@ export function createRendering({ horizontalOrigin: Cesium.HorizontalOrigin.CENTER, disableDepthTestDistance: Number.POSITIVE_INFINITY, }); - // FRP-sorted walk: candidate order doubles as label priority. - layerState._labelCandidates.push({ - position, - cullPosition, - fire, - cell: null, - }); } // Freshly-added sprites default to show=true; cull the far side now so a // rebuild never flashes through-the-planet detections while the camera // sits still (the moveEnd/preRender hooks only fire on camera motion). refreshHorizonCulling(); - components.cards.rebuildAmbientLabels(); - components.selection.refreshContextRegistrations( - candidates.slice(0, CONTEXT_TOP_N), - ); - - if (lod.id === 'close') { - // Batched (chunked ≤200, sequential, session-cached) DEM warm for the - // rendered subset; fires are static so each coarse cell resolves once - // ever. Re-render ONLY when a floor actually landed — a failed resolve - // reports false, so this chain terminates instead of looping against a - // down proxy (the next camera-driven rebuild retries). - warmFireAnchorFloors(candidates).then((warmed) => { - if (!warmed || !layerState._enabled || !layerState._viewer) return; - const currentLod = LOD_LEVELS[layerState._currentLodIndex]; - if (!currentLod || currentLod.mode !== 'detections') return; - renderCurrentLod(true); - }); - } + return markers; } function ensureDetectionCollections() { @@ -385,9 +445,11 @@ export function createRendering({ } return { renderCurrentLod, + renderBand, aggregateFires, renderHeat, renderDetections, + applyHandoffFade, ensureDetectionCollections, removeDetectionCollections, fireHorizonOccluder, diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index 349fcd375..0b96c53ea 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -1,6 +1,5 @@ import * as Cesium from 'cesium'; import { DEFAULT_FIRE_FILTER } from '../../data/firmsIntensity.js'; -import { HEAT_SPOT_SIZE_DEFAULT } from '../../data/firmsHeatRaster.js'; export function createFirmsState({ services, config }) { const layerState = {}; @@ -70,10 +69,6 @@ export function createFirmsState({ services, config }) { layerState._filter = { ...DEFAULT_FIRE_FILTER }; - /** Heatmap spot size, percent of the shipped size (firmsHeatRaster.js). */ - - layerState._spotSize = HEAT_SPOT_SIZE_DEFAULT; - /** Panel callback: repaint the row controls (counter) after a load. */ layerState._rowControlsListener = null; @@ -147,6 +142,10 @@ export function createFirmsState({ services, config }) { layerState._heatRebuildTimer = null; + /** Last applied hand-off fade (0 markers … 1 heatmap); -1 forces a reapply. */ + + layerState._handoffHeat = -1; + /** Imagery adapter (heatImagery.js), created on the first far render. */ layerState._heatImagery = null; diff --git a/src/layers/firms/viewport.js b/src/layers/firms/viewport.js index 932fe2e75..b32a02e37 100644 --- a/src/layers/firms/viewport.js +++ b/src/layers/firms/viewport.js @@ -151,6 +151,9 @@ export function createViewport({ // on screen throughout it — so the horizon pass below must keep running // while a fetch is in flight. Only the rebuild is gated on fresh data. if (!layerState._enabled) return; + // Every frame, ahead of the throttle: a fast zoom crosses the whole + // hand-off between two band checks. + components.rendering.applyHandoffFade(); const now = performance.now(); if (now - layerState._lastLodCheck < LOD_CHECK_MS) return; layerState._lastLodCheck = now; diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index 8f556ea1e..8ac00f51b 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -252,11 +252,7 @@ export const SHARE_VIEW_FLAGS = freezeAll([ * written explicitly. */ export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({ - 'local-firms': Object.freeze({ - minFrp: 0, - confidence: 'all', - spotSize: 100, - }), + 'local-firms': Object.freeze({ minFrp: 0, confidence: 'all' }), traffic: Object.freeze({ roadMode: null }), 'weather-lightning': Object.freeze({ opacity: 'strong' }), 'weather-radar': Object.freeze({ opacity: 'strong' }), diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 2327105be..d16a0c6ff 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -470,7 +470,7 @@ export class LayerPanel { } /** - * A layer's row chips, one-of-many switch, sliders, color legend, info line + * A layer's row chips, one-of-many switch, slider, color legend, info line * and ordered list. The block * is hidden while the layer is off (or while a dependency owner has * surrendered it), so a quiet row stays quiet. Chips and list items are @@ -496,7 +496,7 @@ export class LayerPanel { })); const infoText = controls?.info ? String(controls.info) : ''; const segmented = controls?.segmented || null; - const sliders = Array.isArray(controls?.sliders) ? controls.sliders : []; + const slider = controls?.slider || null; // The info line, once shown, stays in place and hides when empty. if (infoText) row.info = true; return { @@ -505,10 +505,10 @@ export class LayerPanel { legend.length === 0 && !infoText && !segmented && - sliders.length === 0, + !slider, chips, segmented, - sliders, + slider, legend, info: row.info ? { text: infoText, title: controls?.infoTitle || '' } @@ -562,10 +562,13 @@ export class LayerPanel { // to params through the live descriptor on every tick. _moveSlider(id, sliderId, value, generation = this._generation) { if (this._destroyed || this._generation !== generation) return; - const slider = this._rowControlsFor(id)?.sliders?.find( - (entry) => entry.id === sliderId, - ); - if (!slider || typeof slider.params !== 'function' || !this.isEnabled(id)) + const slider = this._rowControlsFor(id)?.slider; + if ( + !slider || + slider.id !== sliderId || + typeof slider.params !== 'function' || + !this.isEnabled(id) + ) return; this.setLayerParams(id, slider.params(value), { origin: 'user' }); } diff --git a/src/ui/layerPanel/LayerRows.vue b/src/ui/layerPanel/LayerRows.vue index b8c272e24..5f5d7c97e 100644 --- a/src/ui/layerPanel/LayerRows.vue +++ b/src/ui/layerPanel/LayerRows.vue @@ -63,10 +63,11 @@ defineProps({ :segmented="item.controls.segmented" :on-select="(optionId) => onChip(item.id, optionId)" /> ({ active: mode === active, })), }, - sliders: [ - { - id: 'min-frp', - label: 'Fire intensity (FRP)', - ariaLabel: 'Minimum fire radiative power, megawatts', - valueText: value ? `≥ ${value} MW` : 'All', - min: 0, - max: 1000, - step: 1, - value, - track: 'linear-gradient(90deg, #9e2a1b, #fff3b0)', - ticks: [ - { at: 0, label: '0' }, - { at: 0.5, label: '30' }, - ], - }, - { - id: 'heat-spot-size', - label: 'Heatmap spot size', - ariaLabel: 'Far-zoom heatmap spot size', - valueText: '1×', - min: 0, - max: 1000, - value: 333, - ticks: [{ at: 1 / 3, label: '1×' }], - }, - ], + slider: { + id: 'min-frp', + label: 'Fire intensity (FRP)', + ariaLabel: 'Minimum fire radiative power, megawatts', + valueText: value ? `≥ ${value} MW` : 'All', + min: 0, + max: 1000, + step: 1, + value, + track: 'linear-gradient(90deg, #9e2a1b, #fff3b0)', + ticks: [ + { at: 0, label: '0' }, + { at: 0.5, label: '30' }, + ], + }, legend: [], info: null, list: null, }, }); -test('a row switch and sliders render their markup and report choices', () => { +test('a row switch and slider render their markup and report choices', () => { document.body.replaceChildren(); const container = document.body.appendChild(document.createElement('div')); const chips = []; @@ -127,13 +115,8 @@ test('a row switch and sliders render their markup and report choices', () => { segments[1].click(); assert.deepEqual(chips, [['fires', 'confidence-high']]); - const blocks = [...container.querySelectorAll('.data-toggle-slider')]; - assert.deepEqual( - blocks.map((entry) => entry.dataset.sliderId), - ['min-frp', 'heat-spot-size'], - 'sliders render in order, after the switch', - ); - const [block, spotBlock] = blocks; + const block = container.querySelector('.data-toggle-slider'); + assert.equal(block.dataset.sliderId, 'min-frp'); assert.equal( block.querySelector('.data-toggle-slider-label').textContent, 'Fire intensity (FRP)', @@ -164,18 +147,7 @@ test('a row switch and sliders render their markup and report choices', () => { input.value = '120'; input.dispatchEvent(new Event('input', { bubbles: true })); - const spotInput = spotBlock.querySelector('input.data-toggle-slider-input'); - assert.equal( - spotInput.getAttribute('aria-label'), - 'Far-zoom heatmap spot size', - ); - assert.equal(spotInput.style.getPropertyValue('--slider-track'), ''); - spotInput.value = '700'; - spotInput.dispatchEvent(new Event('input', { bubbles: true })); - assert.deepEqual(moves, [ - ['fires', 'min-frp', 120], - ['fires', 'heat-spot-size', 700], - ]); + assert.deepEqual(moves, [['fires', 'min-frp', 120]]); // The refresh that tick triggers keeps the same nodes and updates the text. render(h(LayerRows, { items: [fireRow(120, 'high')], ...props }), container);