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

+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. */