diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index 6f2d11a33..2126e3c7f 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -4,6 +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. 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 (gekh, #1426). + 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` + takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All + / 1× (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 73ea42a0f..4b06c30d5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2205,14 +2205,19 @@ 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, and an "N of M shown" counter. The default is 0 MW and +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 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 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. +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. 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 6704cf6e2..85894b19e 100644 --- a/src/data/firmsHeatRaster.js +++ b/src/data/firmsHeatRaster.js @@ -43,6 +43,70 @@ 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 754493754..9fc0e02c8 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -7,6 +7,10 @@ import { binFires, blurGaussian, buildHeatRaster, + heatGridForSpotSize, + normalizeHeatSpotSize, + sliderPositionForSpotSize, + spotSizeForSliderPosition, } from './firmsHeatRaster.js'; const fire = (lat, lon, frp = 10) => ({ lat, lon, frp }); @@ -116,3 +120,60 @@ 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 39f0382c8..cd901f729 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -467,3 +467,66 @@ 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 c68a67e67..a049a80c5 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 (HAL-676). `minFrp` is whole megawatts on + // Active Fires intensity filter and heatmap spot size (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,6 +311,9 @@ 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 55d40dfe2..78b36c3a6 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -30,6 +30,11 @@ 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; @@ -3019,9 +3024,14 @@ 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' }; + state.options['local-firms'] = { + minFrp: 20, + confidence: 'high', + spotSize: 250, + }; const encoded = encode(state); assert.equal(new URLSearchParams(encoded).get('l'), 'w'); // Other owners write their own explicit defaults into `lo` too (flights 3D, @@ -3029,10 +3039,12 @@ 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[ @@ -3050,7 +3062,11 @@ 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' }); + assert.deepEqual(firmsOptions(''), { + minFrp: 0, + confidence: 'all', + spotSize: 100, + }); }); test('Active Fires: the codec accepts whole megawatts in bounds and the three confidence codes', () => { @@ -3078,5 +3094,17 @@ 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 301b60207..47d3808cc 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3229,6 +3229,9 @@ "≥ {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 92c843948..1449ad502 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2949,6 +2949,9 @@ "≥ {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 3cc9f4456..ffbc0bceb 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2992,6 +2992,9 @@ "≥ {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 a95b158c9..050a93b1c 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3019,6 +3019,9 @@ "≥ {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 74d9a4a39..bf3517df6 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3019,6 +3019,9 @@ "≥ {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 bc1477531..8f1c4dc6b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2879,6 +2879,9 @@ "≥ {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 837cbad93..6df69f579 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3019,6 +3019,9 @@ "≥ {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 d457abd24..9afd7eb6c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3062,6 +3062,9 @@ "≥ {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 3cf7e8f9e..83b9d5871 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3060,6 +3060,9 @@ "≥ {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 e46c8882b..b0764c1e2 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2852,6 +2852,9 @@ "≥ {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 c534272b3..84aa385e7 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2879,6 +2879,9 @@ "≥ {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 0061d2941..b1dc842a7 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -31,6 +31,7 @@ "dB", "hPa", "{source} HTTP {status}", + "{value}×", "▲ {frp} MW" ], "de": [ @@ -196,6 +197,7 @@ "{value} KM DOWNRANGE", "{value} KM/H", "{value} KM/S", + "{value}×", "ΔN", "Ⅱ PAUSE", "≥ {value} MW", @@ -325,6 +327,7 @@ "{value} KM", "{value} KM/H", "{value} KM/S", + "{value}×", "≥ {value} MW", "ΔE", "ΔN", @@ -505,6 +508,7 @@ "{value} KM", "{value} KM/H", "{value} KM/S", + "{value}×", "ΔE", "ΔN", "Ⅱ PAUSE", @@ -677,6 +681,7 @@ "{value} KM/H", "{value} KM/S", "{value} conf", + "{value}×", "≥ {value} MW", "ΔE", "ΔN", @@ -727,6 +732,7 @@ "{source} HTTP {status}", "{speed} kn", "{type} SPD: {speed} HDG: {heading}", + "{value}×", "ΔE", "ΔN", "≥ {value} MW", @@ -861,6 +867,7 @@ "{value} KM/S", "ΔN", "▲ {frp} MW", + "{value}×", "≥ {value} MW" ], "ru": [ @@ -879,7 +886,8 @@ "S2 GhostMaps", "S2 · {name}", "VS", - "WHOT/BHOT" + "WHOT/BHOT", + "{value}×" ], "uk": [ "AIS: --", @@ -892,7 +900,8 @@ "NVG", "REC", "S2 GhostMaps", - "WHOT/BHOT" + "WHOT/BHOT", + "{value}×" ], "zh-CN": [ "1090 MHz", @@ -947,6 +956,7 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", + "{value}×", "≥ {value} MW", "ΔE", "ΔN", @@ -1005,6 +1015,7 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", + "{value}×", "≥ {value} MW", "ΔE", "ΔN", diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 797734597..b9577925e 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -8,6 +8,11 @@ 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'; @@ -23,6 +28,16 @@ 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 }); @@ -83,12 +98,12 @@ export function createQueries({ updateInterval: REFRESH_INTERVAL_MS, /** - * The intensity filter as layer options (share token `w`, owner - * `local-firms` in layerState.js). - * @returns {{minFrp: number, confidence: string}} + * 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}} */ getParams() { - return { ...layerState._filter }; + return { ...layerState._filter, spotSize: layerState._spotSize }; }, /** @@ -96,24 +111,36 @@ 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. - * @param {{minFrp?: number, confidence?: string}} params + * 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 * @returns {boolean} True — the request is always accepted. */ setParams(params = {}) { const next = normalizeFireFilter(params, layerState._filter); - if ( - next.minFrp === layerState._filter.minFrp && - next.confidence === layerState._filter.confidence - ) - return true; + 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; - if (!layerState._allFires.length) return true; - components.ingestion.applyFilter(); - if ( - layerState._selectedFire && - !firePassesFilter(layerState._selectedFire, next) - ) { - components.selection.clearFireSelection(); + layerState._spotSize = spotSize; + if (!layerState._allFires.length) { + layerState._rowControlsListener?.(); + return true; + } + if (filterChanged) { + components.ingestion.applyFilter(); + if ( + layerState._selectedFire && + !firePassesFilter(layerState._selectedFire, next) + ) { + components.selection.clearFireSelection(); + } + } else { + layerState._heatRaster = null; } if (layerState._enabled) components.rendering.renderCurrentLod(true); layerState._rowControlsListener?.(); @@ -123,8 +150,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, and the - * "N of M shown" counter. + * the legend), the All / Nominal+ / High confidence switch, the far-zoom + * heatmap spot-size slider and the "N of M shown" counter. */ getRowControls() { const { minFrp, confidence } = layerState._filter; @@ -133,27 +160,48 @@ export function createQueries({ const shown = layerState._count; const total = layerState._totalCount; return { - 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), - }), - }, + 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), + }), + }, + ], segmented: { id: 'confidence', label: t('Confidence'), diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 84a6eed36..dd91effec 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -1,7 +1,10 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; import { horizonOccluder } from '../../data/iconOrientation.js'; -import { buildHeatRaster } from '../../data/firmsHeatRaster.js'; +import { + buildHeatRaster, + heatGridForSpotSize, +} from '../../data/firmsHeatRaster.js'; import { LOD_LEVELS, CONTEXT_TOP_N, @@ -144,13 +147,16 @@ export function createRendering({ return layerState._heatImagery; } - /** Build the heat raster from the shown set and drape it. */ + /** Build the heat raster from the shown set at the chosen spot size and drape it. */ function rebuildHeatRaster() { clearTimeout(layerState._heatRebuildTimer); layerState._heatRebuildTimer = null; if (layerState._destroyed) return; - layerState._heatRaster = buildHeatRaster(layerState._fires, HEAT_RASTER); + layerState._heatRaster = buildHeatRaster( + layerState._fires, + heatGridForSpotSize(HEAT_RASTER, layerState._spotSize), + ); heatImagery().setRaster(layerState._heatRaster); } diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index 9a0b031b2..349fcd375 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -1,5 +1,6 @@ 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 = {}; @@ -69,6 +70,10 @@ 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; diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index 8ac00f51b..8f556ea1e 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -252,7 +252,11 @@ 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' }), + 'local-firms': Object.freeze({ + minFrp: 0, + confidence: 'all', + spotSize: 100, + }), 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 3d2f62b26..466817ec3 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -430,7 +430,7 @@ export class LayerPanel { } /** - * A layer's row chips, one-of-many switch, slider, color legend, info line + * A layer's row chips, one-of-many switch, sliders, 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 @@ -456,7 +456,7 @@ export class LayerPanel { })); const infoText = controls?.info ? String(controls.info) : ''; const segmented = controls?.segmented || null; - const slider = controls?.slider || null; + const sliders = Array.isArray(controls?.sliders) ? controls.sliders : []; // The info line, once shown, stays in place and hides when empty. if (infoText) row.info = true; return { @@ -465,10 +465,10 @@ export class LayerPanel { legend.length === 0 && !infoText && !segmented && - !slider, + sliders.length === 0, chips, segmented, - slider, + sliders, legend, info: row.info ? { text: infoText, title: controls?.infoTitle || '' } @@ -522,13 +522,10 @@ 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)?.slider; - if ( - !slider || - slider.id !== sliderId || - typeof slider.params !== 'function' || - !this.isEnabled(id) - ) + const slider = this._rowControlsFor(id)?.sliders?.find( + (entry) => entry.id === sliderId, + ); + if (!slider || 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 5f5d7c97e..b8c272e24 100644 --- a/src/ui/layerPanel/LayerRows.vue +++ b/src/ui/layerPanel/LayerRows.vue @@ -63,11 +63,10 @@ defineProps({ :segmented="item.controls.segmented" :on-select="(optionId) => onChip(item.id, optionId)" /> ({ active: mode === active, })), }, - 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' }, - ], - }, + 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×' }], + }, + ], legend: [], info: null, list: null, }, }); -test('a row switch and slider render their markup and report choices', () => { +test('a row switch and sliders render their markup and report choices', () => { document.body.replaceChildren(); const container = document.body.appendChild(document.createElement('div')); const chips = []; @@ -115,8 +127,13 @@ test('a row switch and slider render their markup and report choices', () => { segments[1].click(); assert.deepEqual(chips, [['fires', 'confidence-high']]); - const block = container.querySelector('.data-toggle-slider'); - assert.equal(block.dataset.sliderId, 'min-frp'); + 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; assert.equal( block.querySelector('.data-toggle-slider-label').textContent, 'Fire intensity (FRP)', @@ -147,7 +164,18 @@ test('a row switch and slider render their markup and report choices', () => { input.value = '120'; input.dispatchEvent(new Event('input', { bubbles: true })); - assert.deepEqual(moves, [['fires', 'min-frp', 120]]); + 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], + ]); // The refresh that tick triggers keeps the same nodes and updates the text. render(h(LayerRows, { items: [fireRow(120, 'high')], ...props }), container);