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

+6 -3
View File
@@ -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).
+9 -4
View File
@@ -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
+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);