Cross-fade Active Fires markers and heatmap; fix the spot size

The far-zoom heatmap now uses the ½× grid (4096 × 2048, ≈10 km per
pixel, one-pixel blur), so a lone fire's spot matches its marker at the
3,000 km hand-off. The spot size slider and its share option are gone.

Markers and heatmap are both kept built at every zoom and cross-fade by
camera height between 2,400 and 3,600 km on every frame, so a fast zoom
never shows a beat with neither. The heatmap fades by imagery alpha:
hiding a Cesium imagery layer unmaps it from the globe tiles, and showing
it again re-mapped them late, which was the gap at the band edge. Under
the markers the heatmap is built 150 ms after the data or filter changes.
This commit is contained in:
German Khokhlov committed 2026-11-03 20:41:50 +04:00
1 parent 4e950d04e9
commit f01200b467
31 files changed
+372 -528

No files matched your search

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