Add a heatmap spot size slider to the Active Fires row

A ½×–4× slider (log2 axis, 5 % steps, default 1×) under the intensity
filter sets how large far-zoom heat spots are. It scales the grid, not
the blur: 2× is a 1024 × 512 grid with the same one-pixel blur, so each
spot grows exactly and weak lone fires don't fade as they widen. A change
rebuilds only the heatmap. Share links keep it as w.s (percent, 50–400).
The layer row now takes a list of sliders.
This commit is contained in:
German Khokhlov committed 2026-11-03 14:56:40 +04:00
1 parent ef5c034dc9
commit 3139369673
26 files changed
+449 -91

No files matched your search

+64
View File
@@ -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;
+61
View File
@@ -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);
});
+63
View File
@@ -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;
}
});
+4 -1
View File
@@ -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'], {
+30 -2
View File
@@ -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);
});
+3
View File
@@ -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}"
+3
View File
@@ -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"
+3
View File
@@ -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",
+3
View File
@@ -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"
+3
View File
@@ -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"
+3
View File
@@ -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}件を表示"
+3
View File
@@ -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"
+3
View File
@@ -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}",
+3
View File
@@ -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}",
+3
View File
@@ -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}",
+3
View File
@@ -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}"
+13 -2
View File
@@ -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",
+88 -40
View File
@@ -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'),
+9 -3
View File
@@ -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);
}
+5
View File
@@ -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;
+5 -1
View File
@@ -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' }),
+8 -11
View File
@@ -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' });
}
+4 -5
View File
@@ -63,11 +63,10 @@ defineProps({
:segmented="item.controls.segmented"
:on-select="(optionId) => onChip(item.id, optionId)"
/><RowSlider
v-if="item.controls.slider"
:slider="item.controls.slider"
:on-input="
(value) => onSlider(item.id, item.controls.slider.id, value)
"
v-for="slider in item.controls.sliders || []"
:key="slider.id"
:slider="slider"
:on-input="(value) => onSlider(item.id, slider.id, value)"
/><span
v-for="(entry, index) in item.controls.legend"
:key="index"
+47 -19
View File
@@ -66,28 +66,40 @@ const fireRow = (value, active = 'all') => ({
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);