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