mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +00:00
Merge origin/main into german/navara
Brings the Active Fires intensity work: the FRP filter, the confidence switch, the ember-to-white-hot ramp and the far-zoom heatmap. The filter, switch and ramp work on both engines. The heatmap drapes on Cesium through the layer's Cesium renderer, which also fades the markers across the hand-off; it goes with that renderer on an engine switch and is built again on the next Cesium globe. Navara keeps heat cells for the far bands, now coloured on the FRP ramp.
This commit is contained in:
commit
0c0df50a40
51 files changed
+2702
-144
No files matched your search
@@ -0,0 +1,12 @@
|
||||
- Color every Active Fires detection by fire radiative power on a six-stop
|
||||
ember → white-hot ramp (ember · red · flame · orange · gold · white-hot at
|
||||
0 · 5 · 20 · 50 · 100 · 300 MW, brighter as power rises), and add a
|
||||
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, and cross-fades with
|
||||
the markers around 4,500 km with no gap, keeping weak fires as markers. 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).
|
||||
@@ -0,0 +1,2 @@
|
||||
- Keep retained Active Fires heat imagery hidden when an empty filtered layer is
|
||||
re-enabled while a new snapshot is pending (radianx, #1535).
|
||||
@@ -2323,6 +2323,62 @@ Generated style sliders use the same name as their visible parameter label.
|
||||
The first-run suppression checkbox keeps its native wrapping label, so its
|
||||
accessible name remains "Don't show this again". Control behavior is unchanged.
|
||||
|
||||
## Active Fires intensity filter and color ramp
|
||||
|
||||
Every FIRMS detection is colored by fire radiative power on a six-stop ember →
|
||||
white-hot ramp: dark ember from 0 MW, red from 5, flame from 20, orange from
|
||||
50, gold from 100 and pale white-hot from 300 MW (`FRP_RAMP` in
|
||||
`src/data/firmsIntensity.js`). Each stop is lighter than the one below and
|
||||
none is a cold hue, so the brightest marks are the strongest fires; ~90% of
|
||||
detections are under 20 MW and stay dim red. Glow sprites and fire-card
|
||||
accents read that ramp; confidence and brightness do not affect the color.
|
||||
|
||||
At far zoom (camera above ~4,500 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 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, 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 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 3,600 and
|
||||
5,400 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. The 750–4,500 km
|
||||
markers band draws up to 40,000 detections (the strongest in the padded
|
||||
view), so the weak fires the heatmap shows stay on screen as markers when it
|
||||
fades out; it reads each marker's already-built anchor instead of the ground
|
||||
floor cache, which only the close band warms. "N FIRES" cards from a 2° / 1° aggregation still label the hottest
|
||||
areas; their accent is the strongest fire's FRP color.
|
||||
Re-enabling Active Fires keeps the retained heat imagery hidden when the current
|
||||
filtered set is empty, including while a new snapshot is pending.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
The `get_active_fires` tool accepts its own `min_frp_mw` and `confidence`
|
||||
(`all`, `nominal`, `high`) arguments, independent of the panel's setting, and
|
||||
names the applied criteria in its notes. Stale-snapshot and missing-source
|
||||
reporting are unchanged.
|
||||
|
||||
## FIRMS source status
|
||||
|
||||
The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times.
|
||||
|
||||
@@ -420,6 +420,7 @@
|
||||
"src/layers/firms/cards.js",
|
||||
"src/layers/firms/index.js",
|
||||
"src/layers/firms/ingestion.js",
|
||||
"src/layers/firms/heatVisibility.test.mjs",
|
||||
"src/layers/firms/lifecycle.js",
|
||||
"src/layers/firms/model.js",
|
||||
"src/layers/firms/policy.js",
|
||||
@@ -1506,6 +1507,10 @@
|
||||
"scripts/qa-installation-stencil.mjs",
|
||||
"scripts/qa-installation-stencil-entry.mjs",
|
||||
"src/layers/installations/render/navaraStencilBatch.test.mjs",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsIntensity.test.mjs",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
"src/ui/layerPanel/RowSlider.vue",
|
||||
"src/sources/phaseTiming.test.mjs",
|
||||
"scripts/build-census-places.mjs",
|
||||
"src/data/placeBoundaries.test.mjs",
|
||||
|
||||
@@ -689,6 +689,8 @@
|
||||
"src/ui/rail/classToggle.js",
|
||||
"src/ui/rail/focusSafeChildren.js",
|
||||
"src/ui/layerPanel/RowText.vue",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
"src/ui/layerPanel/RowSlider.vue",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/loadingFeedback.js",
|
||||
@@ -726,7 +728,8 @@
|
||||
"src/ui/kit/UiButton.vue",
|
||||
"src/ui/kit/UiBadge.vue",
|
||||
"src/ui/rail/fieldValue.js",
|
||||
"src/ui/kit/UiSlider.js"
|
||||
"src/ui/kit/UiSlider.js",
|
||||
"src/ui/kit/UiSegmented.vue"
|
||||
],
|
||||
"external": ["vue"]
|
||||
},
|
||||
@@ -969,6 +972,7 @@
|
||||
"src/data/detectionPolicy.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
@@ -1042,6 +1046,8 @@
|
||||
"src/ui/rail/classToggle.js",
|
||||
"src/ui/rail/focusSafeChildren.js",
|
||||
"src/ui/layerPanel/RowText.vue",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
"src/ui/layerPanel/RowSlider.vue",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/layers.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
@@ -1189,7 +1195,8 @@
|
||||
"src/geo/debug.js",
|
||||
"src/geo/developerError.js",
|
||||
"src/app/layers/directory.js",
|
||||
"src/globe/follow.js"
|
||||
"src/globe/follow.js",
|
||||
"src/ui/kit/UiSegmented.vue"
|
||||
],
|
||||
"external": ["cesium", "vue", "pinia", "vue-i18n"]
|
||||
},
|
||||
@@ -1378,6 +1385,7 @@
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
@@ -1466,6 +1474,7 @@
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
@@ -1545,6 +1554,7 @@
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
@@ -1710,6 +1720,8 @@
|
||||
"exports": ["./layers/firms"],
|
||||
"modules": [
|
||||
"src/data/firmsAdapt.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsHeatRaster.js",
|
||||
"src/data/firmsLabels.js",
|
||||
"src/layers/firms/anchors.js",
|
||||
"src/layers/firms/cards.js",
|
||||
@@ -1720,6 +1732,7 @@
|
||||
"src/layers/firms/policy.js",
|
||||
"src/layers/firms/queries.js",
|
||||
"src/layers/firms/rendering.js",
|
||||
"src/layers/firms/heatImagery.js",
|
||||
"src/layers/firms/selection.js",
|
||||
"src/layers/firms/source.js",
|
||||
"src/layers/firms/state.js",
|
||||
@@ -2383,6 +2396,7 @@
|
||||
"exports": ["./layers/radio"],
|
||||
"modules": [
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/radioCountry.js",
|
||||
@@ -2804,6 +2818,8 @@
|
||||
"src/data/fireAnchors.js",
|
||||
"src/data/firmsAdapt.js",
|
||||
"src/data/firmsHeatmap.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsHeatRaster.js",
|
||||
"src/data/firmsLabels.js",
|
||||
"src/data/flights.js",
|
||||
"src/data/flowMatch.js",
|
||||
@@ -2962,6 +2978,7 @@
|
||||
"src/layers/firms/policy.js",
|
||||
"src/layers/firms/queries.js",
|
||||
"src/layers/firms/rendering.js",
|
||||
"src/layers/firms/heatImagery.js",
|
||||
"src/layers/firms/selection.js",
|
||||
"src/layers/firms/source.js",
|
||||
"src/layers/firms/state.js",
|
||||
@@ -3224,6 +3241,8 @@
|
||||
"src/ui/rail/classToggle.js",
|
||||
"src/ui/rail/focusSafeChildren.js",
|
||||
"src/ui/layerPanel/RowText.vue",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
"src/ui/layerPanel/RowSlider.vue",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/layers.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
@@ -3971,6 +3990,8 @@
|
||||
"src/data/detectionDraw.js",
|
||||
"src/data/detectionPolicy.js",
|
||||
"src/data/firmsAdapt.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsHeatRaster.js",
|
||||
"src/data/firmsLabels.js",
|
||||
"src/data/flowMatch.js",
|
||||
"src/data/focusDeemphasis.js",
|
||||
@@ -4099,6 +4120,7 @@
|
||||
"src/layers/firms/policy.js",
|
||||
"src/layers/firms/queries.js",
|
||||
"src/layers/firms/rendering.js",
|
||||
"src/layers/firms/heatImagery.js",
|
||||
"src/layers/firms/selection.js",
|
||||
"src/layers/firms/source.js",
|
||||
"src/layers/firms/state.js",
|
||||
@@ -4741,6 +4763,7 @@
|
||||
"src/i18n/marker.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
@@ -5553,6 +5576,7 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/view/index.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/idSwitches.js",
|
||||
@@ -5681,6 +5705,8 @@
|
||||
"src/tools/queries/brief.js",
|
||||
"src/tools/queries/maritime.js",
|
||||
"src/tools/queries/share.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsAdapt.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/data/layerState.js",
|
||||
@@ -5833,6 +5859,7 @@
|
||||
"modules": [
|
||||
"src/view/index.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/visualStylePolicy.js"
|
||||
|
||||
@@ -45,7 +45,7 @@ test('buildFireCard: title carries FRP, detail carries conf/age/satellite', () =
|
||||
assert.equal(card.details.length, 1);
|
||||
assert.equal(card.details[0], 'high · 2h · N20');
|
||||
assert.equal(card.selected, false);
|
||||
assert.equal(card.accent, accentForSeverity('red'), 'FRP 1520 is red-hot');
|
||||
assert.equal(card.accent, accentForSeverity('white-hot'), 'FRP 1520 is white-hot');
|
||||
assert.deepEqual(card.position, { x: 1, y: 2, z: 3 }, 'uses the candidate position untouched');
|
||||
});
|
||||
|
||||
@@ -69,11 +69,27 @@ test('buildFireCard: power that rounds to 10 MW drops the decimal', () => {
|
||||
assert.equal(selected.title, 'FIRE · 10 MW');
|
||||
});
|
||||
|
||||
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => {
|
||||
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is an ember', () => {
|
||||
const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW);
|
||||
assert.match(card.details[0], /SNPP$/);
|
||||
assert.equal(card.title, '▲ 0.8 MW');
|
||||
assert.notEqual(card.accent, accentForSeverity('red'));
|
||||
assert.equal(card.accent, accentForSeverity('ember'));
|
||||
});
|
||||
|
||||
test('buildFireCard: accent steps through the FRP ramp at 5 · 20 · 50 · 100 · 300 MW', () => {
|
||||
const accentAt = (frp) =>
|
||||
buildFireCard({ fire: fire({ frp }), position: {} }, NOW).accent;
|
||||
assert.equal(accentAt(4.9), accentForSeverity('ember'));
|
||||
assert.equal(accentAt(5), accentForSeverity('red'));
|
||||
assert.equal(accentAt(20), accentForSeverity('flame'));
|
||||
assert.equal(accentAt(50), accentForSeverity('orange'));
|
||||
assert.equal(accentAt(100), accentForSeverity('gold'));
|
||||
assert.equal(accentAt(300), accentForSeverity('white-hot'));
|
||||
// Confidence never moves the color: a low-confidence 864 MW fire stays red.
|
||||
assert.equal(
|
||||
buildFireCard({ fire: fire({ frp: 864, confidence: 0.3 }), position: {} }, NOW).accent,
|
||||
accentForSeverity('white-hot'),
|
||||
);
|
||||
});
|
||||
|
||||
test('buildSelectedFireCard: full detail card with coords, selected flag, no fade', () => {
|
||||
@@ -93,12 +109,12 @@ test('buildCellCard: plural noun, max FRP and newest age, accent passthrough', (
|
||||
const candidate = {
|
||||
cell: { count: 14, maxFrp: 210.4, newestAcqMs: NOW - 3 * H, night: 2 },
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
accent: accentForSeverity('orange'),
|
||||
accent: accentForSeverity('gold'),
|
||||
};
|
||||
const card = buildCellCard(candidate, NOW);
|
||||
assert.equal(card.title, '14 FIRES');
|
||||
assert.equal(card.details[0], 'max 210 MW · new 3h');
|
||||
assert.equal(card.accent, accentForSeverity('orange'));
|
||||
assert.equal(card.accent, accentForSeverity('gold'));
|
||||
});
|
||||
|
||||
test('buildCellCard: the count picks the plural form of the language', () => {
|
||||
@@ -150,7 +166,7 @@ test('buildCellCard: singular noun and missing-age omission', () => {
|
||||
const card = buildCellCard(candidate, NOW);
|
||||
assert.equal(card.title, '1 FIRE');
|
||||
assert.equal(card.details[0], 'max 9.9 MW');
|
||||
assert.equal(card.accent, accentForSeverity('yellow'), 'missing accent defaults to yellow');
|
||||
assert.equal(card.accent, accentForSeverity('ember'), 'missing accent defaults to the weakest stop');
|
||||
});
|
||||
|
||||
test('FIRMS host policy keeps ambient cards bounded and selected cards protected', () => {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Snap Map–style heat raster for the Active Fires far-zoom bands: detections
|
||||
* are linearly binned on an equirectangular grid (row 0 = north, column
|
||||
* 0 = 180°W), blurred, normalized on a log scale and mapped through
|
||||
* HEAT_PALETTE. Pure: no Cesium, no DOM, so the whole pipeline is unit-tested
|
||||
* in node.
|
||||
*/
|
||||
|
||||
/** Below this normalized heat nothing is painted: empty land stays empty. */
|
||||
export const HEAT_FLOOR = 0.03;
|
||||
|
||||
/**
|
||||
* The FRP marker ramp as a continuous glow, from a red edge through orange
|
||||
* and gold to a pale white-hot core. The edge is a bright red, not the
|
||||
* markers' dark ember, and starts more than half opaque: most of the world
|
||||
* sits low on a scale set by the hottest regions, and a dark translucent red
|
||||
* vanishes into forest and savanna imagery.
|
||||
*/
|
||||
export const HEAT_PALETTE = Object.freeze([
|
||||
{ at: 0.0, rgb: [232, 58, 30], alpha: 0 },
|
||||
{ at: 0.08, rgb: [232, 58, 30], alpha: 0.58 },
|
||||
{ at: 0.25, rgb: [242, 86, 34], alpha: 0.7 },
|
||||
{ at: 0.45, rgb: [248, 122, 40], alpha: 0.76 },
|
||||
{ at: 0.65, rgb: [252, 166, 52], alpha: 0.82 },
|
||||
{ at: 0.82, rgb: [254, 208, 92], alpha: 0.87 },
|
||||
{ at: 1.0, rgb: [255, 245, 190], alpha: 0.92 },
|
||||
]);
|
||||
|
||||
export function heatColorAt(t) {
|
||||
if (!(t > HEAT_FLOOR)) return [0, 0, 0, 0];
|
||||
const v = Math.min(1, t);
|
||||
let i = 1;
|
||||
while (i < HEAT_PALETTE.length - 1 && HEAT_PALETTE[i].at < v) i += 1;
|
||||
const a = HEAT_PALETTE[i - 1];
|
||||
const b = HEAT_PALETTE[i];
|
||||
const k = (v - a.at) / (b.at - a.at || 1);
|
||||
const mix = (p, q) => Math.round(p + (q - p) * k);
|
||||
return [
|
||||
mix(a.rgb[0], b.rgb[0]),
|
||||
mix(a.rgb[1], b.rgb[1]),
|
||||
mix(a.rgb[2], b.rgb[2]),
|
||||
Math.round((a.alpha + (b.alpha - a.alpha) * k) * 255),
|
||||
];
|
||||
}
|
||||
|
||||
/** Weight of a detection with no or zero FRP, in megawatts. */
|
||||
export const HEAT_MIN_WEIGHT_MW = 0.5;
|
||||
|
||||
/**
|
||||
* Heat is radiated power, not a head count: each detection adds its FRP in
|
||||
* megawatts. Weak fires carry little power, so raising the minimum-FRP slider
|
||||
* removes most detections without dimming the areas where strong fires burn
|
||||
* (a count-based heat collapsed there and the hot spots faded).
|
||||
*/
|
||||
const defaultWeight = (fire) =>
|
||||
Math.max(HEAT_MIN_WEIGHT_MW, Number(fire.frp) || 0);
|
||||
|
||||
/**
|
||||
* Linear binning (Wand 1994): each detection's weight is shared between the
|
||||
* four pixel centres around it in proportion to closeness, instead of
|
||||
* dropping whole into one pixel. Spots then sit at the true position rather
|
||||
* than snapping by up to half a pixel (~10 km), and a lone weak fire blurs
|
||||
* into a round spot instead of a square or a plus sign. Longitude wraps;
|
||||
* rows beyond the poles fold back onto the edge row.
|
||||
*/
|
||||
export function binFires(fires, { width, height, weight = defaultWeight }) {
|
||||
const grid = new Float32Array(width * height);
|
||||
const row = (y) => Math.min(height - 1, Math.max(0, y)) * width;
|
||||
const col = (x) => ((x % width) + width) % width;
|
||||
for (const fire of fires) {
|
||||
if (!Number.isFinite(fire.lat) || !Number.isFinite(fire.lon)) continue;
|
||||
const fx = ((fire.lon + 180) / 360) * width - 0.5;
|
||||
const fy = ((90 - fire.lat) / 180) * height - 0.5;
|
||||
const x0 = Math.floor(fx);
|
||||
const y0 = Math.floor(fy);
|
||||
const tx = fx - x0;
|
||||
const ty = fy - y0;
|
||||
const w = weight(fire);
|
||||
const top = row(y0);
|
||||
const bottom = row(y0 + 1);
|
||||
const left = col(x0);
|
||||
const right = col(x0 + 1);
|
||||
grid[top + left] += w * (1 - tx) * (1 - ty);
|
||||
grid[top + right] += w * tx * (1 - ty);
|
||||
grid[bottom + left] += w * (1 - tx) * ty;
|
||||
grid[bottom + right] += w * tx * ty;
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
function kernel(sigma) {
|
||||
const radius = Math.max(1, Math.ceil(sigma * 3));
|
||||
const k = new Float32Array(radius * 2 + 1);
|
||||
let sum = 0;
|
||||
for (let i = -radius; i <= radius; i++) {
|
||||
k[i + radius] = Math.exp(-(i * i) / (2 * sigma * sigma));
|
||||
sum += k[i + radius];
|
||||
}
|
||||
for (let i = 0; i < k.length; i++) k[i] /= sum;
|
||||
return { k, radius };
|
||||
}
|
||||
|
||||
export function blurGaussian(grid, width, height, sigma) {
|
||||
const { k, radius } = kernel(sigma);
|
||||
const tmp = new Float32Array(grid.length);
|
||||
const out = new Float32Array(grid.length);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const row = y * width;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const v = grid[row + x];
|
||||
if (v === 0) continue;
|
||||
for (let i = -radius; i <= radius; i++) {
|
||||
const xx = (((x + i) % width) + width) % width; // longitude wraps
|
||||
tmp[row + xx] += v * k[i + radius];
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const v = tmp[y * width + x];
|
||||
if (v === 0) continue;
|
||||
for (let i = -radius; i <= radius; i++) {
|
||||
const yy = Math.min(height - 1, Math.max(0, y + i)); // poles clamp
|
||||
out[yy * width + x] += v * k[i + radius];
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Bins of the log-density histogram used to find the reference quantile. */
|
||||
const QUANTILE_BINS = 4096;
|
||||
|
||||
/**
|
||||
* The p-quantile of the non-zero values, from a histogram of log1p(value):
|
||||
* one linear pass instead of sorting ~350k burning pixels (a sort cost
|
||||
* 200 ms+ and dominated the build). Bin width on the log axis is
|
||||
* max/4096, far finer than the 256-step palette it feeds.
|
||||
*/
|
||||
function percentileOfNonZero(values, p) {
|
||||
let count = 0;
|
||||
let max = 0;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const v = values[i];
|
||||
if (v > 0) {
|
||||
count += 1;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
}
|
||||
if (!count) return 0;
|
||||
const logMax = Math.log1p(max);
|
||||
const bins = new Uint32Array(QUANTILE_BINS);
|
||||
const toBin = (QUANTILE_BINS - 1) / logMax;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const v = values[i];
|
||||
if (v > 0) bins[Math.floor(Math.log1p(v) * toBin)] += 1;
|
||||
}
|
||||
const target = Math.floor(p * (count - 1));
|
||||
let seen = 0;
|
||||
for (let b = 0; b < QUANTILE_BINS; b++) {
|
||||
seen += bins[b];
|
||||
if (seen > target) return Math.expm1((b + 1) / toBin);
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/** HEAT_PALETTE sampled at 256 steps, so the per-pixel walk is one lookup. */
|
||||
const LUT_STEPS = 256;
|
||||
let paletteLut = null;
|
||||
function heatLut() {
|
||||
if (!paletteLut) {
|
||||
paletteLut = new Uint8ClampedArray(LUT_STEPS * 4);
|
||||
for (let i = 0; i < LUT_STEPS; i++) {
|
||||
paletteLut.set(heatColorAt(i / (LUT_STEPS - 1)), i * 4);
|
||||
}
|
||||
}
|
||||
return paletteLut;
|
||||
}
|
||||
|
||||
export function buildHeatRaster(
|
||||
fires,
|
||||
{ width, height, sigmaPx, percentile = 0.995 },
|
||||
) {
|
||||
const density = blurGaussian(
|
||||
binFires(fires, { width, height }),
|
||||
width,
|
||||
height,
|
||||
sigmaPx,
|
||||
);
|
||||
const reference = percentileOfNonZero(density, percentile);
|
||||
const rgba = new Uint8ClampedArray(width * height * 4);
|
||||
if (reference > 0) {
|
||||
const lut = heatLut();
|
||||
const scale = (LUT_STEPS - 1) / Math.log1p(reference);
|
||||
for (let i = 0; i < density.length; i++) {
|
||||
const d = density[i];
|
||||
if (d <= 0) continue;
|
||||
const step = Math.min(LUT_STEPS - 1, Math.round(Math.log1p(d) * scale));
|
||||
const l = step * 4;
|
||||
if (lut[l + 3] === 0) continue;
|
||||
const o = i * 4;
|
||||
rgba[o] = lut[l];
|
||||
rgba[o + 1] = lut[l + 1];
|
||||
rgba[o + 2] = lut[l + 2];
|
||||
rgba[o + 3] = lut[l + 3];
|
||||
}
|
||||
}
|
||||
return { width, height, rgba, reference };
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
HEAT_MIN_WEIGHT_MW,
|
||||
HEAT_PALETTE,
|
||||
heatColorAt,
|
||||
binFires,
|
||||
blurGaussian,
|
||||
buildHeatRaster,
|
||||
} from './firmsHeatRaster.js';
|
||||
|
||||
const fire = (lat, lon, frp = 10) => ({ lat, lon, frp });
|
||||
|
||||
test('palette runs ember to white-hot and starts fully transparent', () => {
|
||||
assert.equal(HEAT_PALETTE[0].at, 0);
|
||||
assert.equal(HEAT_PALETTE.at(-1).at, 1);
|
||||
assert.deepEqual(heatColorAt(0), [0, 0, 0, 0]);
|
||||
assert.equal(heatColorAt(0.01)[3], 0, 'below the floor nothing is painted');
|
||||
const low = heatColorAt(0.15);
|
||||
const high = heatColorAt(1);
|
||||
assert.ok(low[0] > low[1] && low[1] > low[2], 'low heat is red');
|
||||
assert.ok(low[3] >= 0.5 * 255, 'low heat stays visible on any imagery');
|
||||
assert.ok(high[1] > low[1] && high[2] > low[2], 'the core is paler');
|
||||
assert.ok(high[3] > low[3], 'hotter is more opaque');
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
const [r, , b] = heatColorAt(i / 100);
|
||||
assert.ok(r >= b, `no cold hue at ${i}%`);
|
||||
}
|
||||
});
|
||||
|
||||
test('binFires puts a detection at a pixel centre wholly in that pixel', () => {
|
||||
// 4×2 grid: pixel centres at lon −135/−45/45/135, lat ±45.
|
||||
const grid = binFires([fire(45, -135, 1), fire(-45, 135, 1)], {
|
||||
width: 4,
|
||||
height: 2,
|
||||
});
|
||||
assert.equal(grid[0], 1, 'north-west pixel');
|
||||
assert.equal(grid[7], 1, 'south-east pixel');
|
||||
assert.equal(
|
||||
grid.reduce((a, b) => a + b, 0),
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test('binFires shares a detection between the pixels around it', () => {
|
||||
const between = binFires([fire(45, 0, 1)], { width: 4, height: 2 });
|
||||
assert.equal(between[1], 0.5, 'half to the pixel west of it');
|
||||
assert.equal(between[2], 0.5, 'half to the pixel east of it');
|
||||
const quarter = binFires([fire(45, -112.5, 1)], { width: 4, height: 2 });
|
||||
assert.equal(quarter[0], 0.75, 'closer centre takes more');
|
||||
assert.equal(quarter[1], 0.25);
|
||||
const wrapped = binFires([fire(45, 180, 1)], { width: 4, height: 2 });
|
||||
assert.equal(wrapped[3], 0.5, 'the antimeridian wraps');
|
||||
assert.equal(wrapped[0], 0.5);
|
||||
const polar = binFires([fire(90, -135, 1)], { width: 4, height: 2 });
|
||||
assert.equal(polar[0], 1, 'beyond the last row folds back onto it');
|
||||
});
|
||||
|
||||
test('binFires weights each detection by its radiative power', () => {
|
||||
const grid = binFires(
|
||||
[fire(45, 45, 250), fire(45, 45, 0), fire(45, 45, Number.NaN)],
|
||||
{ width: 4, height: 2 },
|
||||
);
|
||||
assert.equal(grid[2], 250 + 2 * HEAT_MIN_WEIGHT_MW);
|
||||
});
|
||||
|
||||
test('filtering out weak fires keeps a strong fire hot', () => {
|
||||
const strong = fire(0.1, 0.1, 300);
|
||||
const weak = Array.from({ length: 40 }, () => fire(0.1, 0.1, 2));
|
||||
const reference = Array.from({ length: 10 }, () => fire(20.1, 20.1, 100));
|
||||
const options = { width: 360, height: 180, sigmaPx: 2 };
|
||||
const pixel = ((90 - 0.1) | 0) * 360 + ((0.1 + 180) | 0);
|
||||
const red = (r) => r.rgba[pixel * 4];
|
||||
const alpha = (r) => r.rgba[pixel * 4 + 3];
|
||||
const unfiltered = buildHeatRaster([strong, ...weak, ...reference], options);
|
||||
const filtered = buildHeatRaster([strong, ...reference], options);
|
||||
assert.ok(alpha(filtered) > 0);
|
||||
assert.ok(
|
||||
alpha(filtered) >= alpha(unfiltered) * 0.9,
|
||||
`alpha ${alpha(unfiltered)} -> ${alpha(filtered)}`,
|
||||
);
|
||||
assert.ok(
|
||||
red(filtered) >= red(unfiltered) * 0.9,
|
||||
`red ${red(unfiltered)} -> ${red(filtered)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('blurGaussian keeps mass and wraps across the antimeridian', () => {
|
||||
const w = 16;
|
||||
const h = 8;
|
||||
const grid = new Float32Array(w * h);
|
||||
grid[3 * w + 0] = 1;
|
||||
const out = blurGaussian(grid, w, h, 1.5);
|
||||
const sum = out.reduce((a, b) => a + b, 0);
|
||||
assert.ok(Math.abs(sum - 1) < 1e-3, `mass ${sum}`);
|
||||
assert.ok(out[3 * w + (w - 1)] > 0, 'spills to the east edge');
|
||||
});
|
||||
|
||||
test('buildHeatRaster is transparent where there are no fires', () => {
|
||||
const r = buildHeatRaster([fire(40, -120), fire(40.1, -120.1)], {
|
||||
width: 360,
|
||||
height: 180,
|
||||
sigmaPx: 2,
|
||||
});
|
||||
assert.equal(r.rgba.length, 360 * 180 * 4);
|
||||
const alphaAt = (lat, lon) => {
|
||||
const x = Math.floor(((lon + 180) / 360) * 360);
|
||||
const y = Math.floor(((90 - lat) / 180) * 180);
|
||||
return r.rgba[(y * 360 + x) * 4 + 3];
|
||||
};
|
||||
assert.ok(alphaAt(40, -120) > 0, 'blob over the fires');
|
||||
assert.equal(alphaAt(-30, 60), 0, 'empty far away');
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* @module firmsIntensity
|
||||
* @description Fire intensity policy shared by the Active Fires layer and the
|
||||
* agent fire tool: the six-stop ember → white-hot FRP color ramp, the log-scale
|
||||
* slider that doubles as its legend, and the one filter every consumer
|
||||
* (markers, cards, heat cells, the top-fires query, `get_active_fires`) runs
|
||||
* before anything is rendered or selected. Pure — no Cesium, no DOM.
|
||||
*
|
||||
* The ramp (`FRP_RAMP`, stepped, not interpolated) runs like a fire cooling
|
||||
* down: a dim ember for weak fires through red, orange and gold to a pale
|
||||
* white-hot core, lightness rising with power, so the brightest marks are the
|
||||
* strongest fires. No cold hues: ~90% of detections are under 20 MW and a
|
||||
* blue start painted most of the map blue. The thresholds are GEV's own, set
|
||||
* on a real week's percentiles so every color appears: Portolan's
|
||||
* 0/10/50/200/500/1000 MW leaves one hottest fire a week (HAL-675).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Stepped FRP ramp, weakest first. A detection takes the LAST stop whose
|
||||
* `minFrp` it reaches. Names key the glow-sprite cache and the card accents.
|
||||
*/
|
||||
export const FRP_RAMP = Object.freeze([
|
||||
Object.freeze({ name: 'ember', minFrp: 0, hex: '#9e2a1b' }),
|
||||
Object.freeze({ name: 'red', minFrp: 5, hex: '#d2401f' }),
|
||||
Object.freeze({ name: 'flame', minFrp: 20, hex: '#f06a28' }),
|
||||
Object.freeze({ name: 'orange', minFrp: 50, hex: '#fb9a2e' }),
|
||||
Object.freeze({ name: 'gold', minFrp: 100, hex: '#fdc84a' }),
|
||||
Object.freeze({ name: 'white-hot', minFrp: 300, hex: '#fff3b0' }),
|
||||
]);
|
||||
|
||||
/** Slider ticks: the ramp thresholds, so slider, legend and markers agree. */
|
||||
export const FRP_SLIDER_TICKS = Object.freeze(
|
||||
FRP_RAMP.map((stop) => stop.minFrp),
|
||||
);
|
||||
|
||||
/** Minimum-FRP values the share codec accepts, in whole megawatts. */
|
||||
export const MIN_FRP_MW_MIN = 0;
|
||||
export const MIN_FRP_MW_MAX = 10_000;
|
||||
|
||||
/** Confidence switch positions; VIIRS reports only low / nominal / high. */
|
||||
export const FIRMS_CONFIDENCE_MODES = Object.freeze(['all', 'nominal', 'high']);
|
||||
|
||||
/**
|
||||
* Normalized-confidence floor for a switch position. The thresholds match
|
||||
* `confidenceBucket` in the layer model (0.3 / 0.6 / 0.9 after firmsAdapt).
|
||||
*/
|
||||
const CONFIDENCE_FLOOR = Object.freeze({ all: 0, nominal: 0.45, high: 0.75 });
|
||||
|
||||
export const DEFAULT_FIRE_FILTER = Object.freeze({
|
||||
minFrp: 0,
|
||||
confidence: 'all',
|
||||
});
|
||||
|
||||
/**
|
||||
* The ramp stop for a fire radiative power.
|
||||
* @param {number} frp Megawatts; non-finite or negative reads as 0.
|
||||
* @returns {{name: string, minFrp: number, hex: string}}
|
||||
*/
|
||||
export function frpRampStop(frp) {
|
||||
const value = Number.isFinite(frp) ? Math.max(0, frp) : 0;
|
||||
let stop = FRP_RAMP[0];
|
||||
for (const candidate of FRP_RAMP) {
|
||||
if (value >= candidate.minFrp) stop = candidate;
|
||||
else break;
|
||||
}
|
||||
return stop;
|
||||
}
|
||||
|
||||
/** `#rrggbb` → `[r, g, b]` in 0..255. */
|
||||
export function hexToRgb(hex) {
|
||||
const text = String(hex).replace('#', '');
|
||||
return [0, 2, 4].map((offset) =>
|
||||
Number.parseInt(text.slice(offset, offset + 2), 16),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log-scale slider axis: position 0 means "no minimum" (0 MW), and the rest
|
||||
* of the track runs 1 MW → SLIDER_MAX_MW logarithmically. The axis floor is 1
|
||||
* MW because the median detection is ~6 MW and a sub-megawatt floor hides
|
||||
* nothing worth a tick.
|
||||
*/
|
||||
export const SLIDER_MAX_MW = 1000;
|
||||
const LOG_MAX = Math.log10(SLIDER_MAX_MW);
|
||||
|
||||
/**
|
||||
* Track position in 0..1 for a minimum FRP.
|
||||
* @param {number} minFrp Megawatts.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function sliderPositionForFrp(minFrp) {
|
||||
if (!Number.isFinite(minFrp) || minFrp < 1) return 0;
|
||||
return Math.min(1, Math.log10(minFrp) / LOG_MAX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum FRP for a track position, rounded to whole megawatts so a slider
|
||||
* value and its share-link form are the same number.
|
||||
* @param {number} position 0..1.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function frpForSliderPosition(position) {
|
||||
const p = Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0;
|
||||
if (p <= 0) return 0;
|
||||
const frp = Math.pow(10, p * LOG_MAX);
|
||||
return Math.max(1, Math.round(frp));
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS `linear-gradient` painting the stepped ramp along the log axis, so the
|
||||
* slider track IS the legend: the color under the thumb is the color of the
|
||||
* weakest fire still shown.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function rampTrackGradient() {
|
||||
const parts = [];
|
||||
FRP_RAMP.forEach((stop, index) => {
|
||||
const start = sliderPositionForFrp(stop.minFrp) * 100;
|
||||
const next = FRP_RAMP[index + 1];
|
||||
const end = next ? sliderPositionForFrp(next.minFrp) * 100 : 100;
|
||||
parts.push(`${stop.hex} ${start.toFixed(2)}% ${end.toFixed(2)}%`);
|
||||
});
|
||||
return `linear-gradient(90deg, ${parts.join(', ')})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum FRP → whole megawatts inside the codec bounds, or null.
|
||||
* @param {*} value
|
||||
* @returns {?number}
|
||||
*/
|
||||
export function normalizeMinFrp(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 >= MIN_FRP_MW_MIN && rounded <= MIN_FRP_MW_MAX
|
||||
? rounded
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confidence mode → one of FIRMS_CONFIDENCE_MODES, or null.
|
||||
* @param {*} value
|
||||
* @returns {?string}
|
||||
*/
|
||||
export function normalizeConfidenceMode(value) {
|
||||
const text = String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return FIRMS_CONFIDENCE_MODES.includes(text) ? text : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a partial filter against the current one; unknown or invalid
|
||||
* fields keep their current value.
|
||||
* @param {Object} [params]
|
||||
* @param {{minFrp: number, confidence: string}} [current]
|
||||
* @returns {{minFrp: number, confidence: string}}
|
||||
*/
|
||||
export function normalizeFireFilter(
|
||||
params = {},
|
||||
current = DEFAULT_FIRE_FILTER,
|
||||
) {
|
||||
const minFrp = normalizeMinFrp(params?.minFrp);
|
||||
const confidence = normalizeConfidenceMode(params?.confidence);
|
||||
return {
|
||||
minFrp: minFrp === null ? current.minFrp : minFrp,
|
||||
confidence: confidence === null ? current.confidence : confidence,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one fire passes the filter. `confidence` is the normalized 0..1
|
||||
* value firmsAdapt produces; `frp` is megawatts.
|
||||
* @param {{frp: number, confidence: number}} fire
|
||||
* @param {{minFrp: number, confidence: string}} filter
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function firePassesFilter(fire, filter = DEFAULT_FIRE_FILTER) {
|
||||
const frp = Number.isFinite(fire?.frp) ? fire.frp : 0;
|
||||
if (frp < (filter.minFrp || 0)) return false;
|
||||
const floor = CONFIDENCE_FLOOR[filter.confidence] ?? 0;
|
||||
if (floor <= 0) return true;
|
||||
const confidence = Number.isFinite(fire?.confidence) ? fire.confidence : 0;
|
||||
return confidence >= floor;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fires that pass the filter, in input order. Returns the SAME array when
|
||||
* the filter is the default, so callers can tell "nothing hidden" cheaply.
|
||||
* @template T
|
||||
* @param {Array<T>} fires Internal records (normalized confidence).
|
||||
* @param {{minFrp: number, confidence: string}} filter
|
||||
* @returns {Array<T>}
|
||||
*/
|
||||
export function filterFires(fires, filter = DEFAULT_FIRE_FILTER) {
|
||||
if (!Array.isArray(fires)) return [];
|
||||
if (isDefaultFireFilter(filter)) return fires;
|
||||
return fires.filter((fire) => firePassesFilter(fire, filter));
|
||||
}
|
||||
|
||||
/** True when the filter hides nothing. */
|
||||
export function isDefaultFireFilter(filter) {
|
||||
return (
|
||||
!(filter?.minFrp > 0) &&
|
||||
(filter?.confidence === 'all' || filter?.confidence == null)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DEFAULT_FIRE_FILTER,
|
||||
FIRMS_CONFIDENCE_MODES,
|
||||
FRP_RAMP,
|
||||
FRP_SLIDER_TICKS,
|
||||
filterFires,
|
||||
firePassesFilter,
|
||||
frpForSliderPosition,
|
||||
frpRampStop,
|
||||
hexToRgb,
|
||||
isDefaultFireFilter,
|
||||
normalizeConfidenceMode,
|
||||
normalizeFireFilter,
|
||||
normalizeMinFrp,
|
||||
rampTrackGradient,
|
||||
sliderPositionForFrp,
|
||||
} from './firmsIntensity.js';
|
||||
|
||||
test('the ramp has six ember → white-hot stops at 0 · 5 · 20 · 50 · 100 · 300 MW', () => {
|
||||
assert.deepEqual(
|
||||
FRP_RAMP.map((stop) => [stop.name, stop.minFrp, stop.hex]),
|
||||
[
|
||||
['ember', 0, '#9e2a1b'],
|
||||
['red', 5, '#d2401f'],
|
||||
['flame', 20, '#f06a28'],
|
||||
['orange', 50, '#fb9a2e'],
|
||||
['gold', 100, '#fdc84a'],
|
||||
['white-hot', 300, '#fff3b0'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual([...FRP_SLIDER_TICKS], [0, 5, 20, 50, 100, 300]);
|
||||
assert.ok(Object.isFrozen(FRP_RAMP));
|
||||
});
|
||||
|
||||
test('every stop is warm and each is lighter than the one below', () => {
|
||||
const luma = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
let previous = -1;
|
||||
for (const stop of FRP_RAMP) {
|
||||
const rgb = hexToRgb(stop.hex);
|
||||
assert.ok(rgb[0] >= rgb[2], `${stop.name} has no cold hue`);
|
||||
assert.ok(
|
||||
luma(rgb) > previous,
|
||||
`${stop.name} is lighter than the stop below`,
|
||||
);
|
||||
previous = luma(rgb);
|
||||
}
|
||||
});
|
||||
|
||||
test('a detection takes the last stop it reaches; garbage reads as 0 MW', () => {
|
||||
assert.equal(frpRampStop(0).name, 'ember');
|
||||
assert.equal(frpRampStop(4.99).name, 'ember');
|
||||
assert.equal(frpRampStop(5).name, 'red');
|
||||
assert.equal(frpRampStop(19.9).name, 'red');
|
||||
assert.equal(frpRampStop(20).name, 'flame');
|
||||
assert.equal(frpRampStop(50).name, 'orange');
|
||||
assert.equal(frpRampStop(100).name, 'gold');
|
||||
assert.equal(frpRampStop(299.9).name, 'gold');
|
||||
assert.equal(frpRampStop(300).name, 'white-hot');
|
||||
assert.equal(frpRampStop(1246).name, 'white-hot');
|
||||
assert.equal(frpRampStop(NaN).name, 'ember');
|
||||
assert.equal(frpRampStop(-3).name, 'ember');
|
||||
assert.equal(frpRampStop(undefined).name, 'ember');
|
||||
});
|
||||
|
||||
test('hex stops convert to the "r, g, b" accents the cards paint', () => {
|
||||
assert.deepEqual(hexToRgb('#9e2a1b'), [158, 42, 27]);
|
||||
assert.deepEqual(hexToRgb('d1382a'), [209, 56, 42]);
|
||||
});
|
||||
|
||||
test('the log slider pins 0 MW at the left and runs 1 → 1000 MW; values round-trip', () => {
|
||||
assert.equal(sliderPositionForFrp(0), 0);
|
||||
assert.equal(sliderPositionForFrp(0.5), 0);
|
||||
assert.equal(sliderPositionForFrp(1), 0);
|
||||
assert.ok(Math.abs(sliderPositionForFrp(10) - 1 / 3) < 1e-9);
|
||||
assert.ok(Math.abs(sliderPositionForFrp(100) - 2 / 3) < 1e-9);
|
||||
assert.equal(sliderPositionForFrp(1000), 1);
|
||||
assert.equal(sliderPositionForFrp(5000), 1, 'clamped past the axis end');
|
||||
assert.equal(frpForSliderPosition(0), 0);
|
||||
assert.equal(frpForSliderPosition(1), 1000);
|
||||
assert.equal(frpForSliderPosition(-1), 0);
|
||||
assert.equal(frpForSliderPosition(2), 1000);
|
||||
for (const frp of FRP_SLIDER_TICKS) {
|
||||
assert.equal(
|
||||
frpForSliderPosition(sliderPositionForFrp(frp)),
|
||||
frp,
|
||||
`tick ${frp} MW round-trips through the axis`,
|
||||
);
|
||||
}
|
||||
// Ticks spread along the track instead of bunching at one end.
|
||||
const positions = FRP_SLIDER_TICKS.map(sliderPositionForFrp);
|
||||
for (let i = 1; i < positions.length; i += 1) {
|
||||
assert.ok(positions[i] - positions[i - 1] >= 0.09, `tick gap ${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the track gradient is the stepped ramp on the log axis, so it doubles as the legend', () => {
|
||||
const gradient = rampTrackGradient();
|
||||
assert.match(gradient, /^linear-gradient\(90deg, /);
|
||||
assert.match(gradient, /#9e2a1b 0\.00% 23\.3\d%/);
|
||||
assert.match(gradient, /#d2401f 23\.3\d% 43\.3\d%/);
|
||||
assert.match(gradient, /#fff3b0 82\.5\d% 100\.00%\)$/);
|
||||
assert.equal(gradient.match(/#/g).length, FRP_RAMP.length);
|
||||
});
|
||||
|
||||
test('minimum FRP normalizes to whole megawatts inside the codec bounds', () => {
|
||||
assert.equal(normalizeMinFrp(0), 0);
|
||||
assert.equal(normalizeMinFrp(19.6), 20);
|
||||
assert.equal(normalizeMinFrp('100'), 100);
|
||||
assert.equal(normalizeMinFrp(10000), 10000);
|
||||
assert.equal(normalizeMinFrp(10001), null);
|
||||
assert.equal(normalizeMinFrp(-1), null);
|
||||
assert.equal(normalizeMinFrp('abc'), null);
|
||||
assert.equal(normalizeMinFrp(''), null);
|
||||
assert.equal(normalizeMinFrp(null), null);
|
||||
assert.equal(normalizeMinFrp(NaN), null);
|
||||
});
|
||||
|
||||
test('confidence modes are exactly all / nominal / high', () => {
|
||||
assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']);
|
||||
assert.equal(normalizeConfidenceMode(' High '), 'high');
|
||||
assert.equal(normalizeConfidenceMode('nominal'), 'nominal');
|
||||
assert.equal(normalizeConfidenceMode('low'), null);
|
||||
assert.equal(normalizeConfidenceMode(0.9), null);
|
||||
assert.equal(normalizeConfidenceMode(undefined), null);
|
||||
});
|
||||
|
||||
test('a partial filter patches the current one and invalid fields keep their value', () => {
|
||||
assert.deepEqual(normalizeFireFilter(), DEFAULT_FIRE_FILTER);
|
||||
assert.deepEqual(normalizeFireFilter({ minFrp: 20 }), {
|
||||
minFrp: 20,
|
||||
confidence: 'all',
|
||||
});
|
||||
assert.deepEqual(
|
||||
normalizeFireFilter(
|
||||
{ confidence: 'high' },
|
||||
{ minFrp: 50, confidence: 'all' },
|
||||
),
|
||||
{ minFrp: 50, confidence: 'high' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeFireFilter(
|
||||
{ minFrp: 'nope', confidence: 'medium' },
|
||||
{ minFrp: 50, confidence: 'nominal' },
|
||||
),
|
||||
{ minFrp: 50, confidence: 'nominal' },
|
||||
);
|
||||
assert.equal(isDefaultFireFilter(DEFAULT_FIRE_FILTER), true);
|
||||
assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'all' }), true);
|
||||
assert.equal(isDefaultFireFilter({ minFrp: 1, confidence: 'all' }), false);
|
||||
assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'high' }), false);
|
||||
});
|
||||
|
||||
test('the filter keeps a fire at or above the floor and at or above the confidence mode', () => {
|
||||
const low = { frp: 864, confidence: 0.3 };
|
||||
const nominal = { frp: 20, confidence: 0.6 };
|
||||
const high = { frp: 2, confidence: 0.9 };
|
||||
assert.equal(firePassesFilter(low), true, 'default shows everything');
|
||||
assert.equal(
|
||||
firePassesFilter(nominal, { minFrp: 20, confidence: 'all' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
firePassesFilter(nominal, { minFrp: 21, confidence: 'all' }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
firePassesFilter(low, { minFrp: 0, confidence: 'nominal' }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
firePassesFilter(nominal, { minFrp: 0, confidence: 'nominal' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
firePassesFilter(high, { minFrp: 0, confidence: 'nominal' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
firePassesFilter(nominal, { minFrp: 0, confidence: 'high' }),
|
||||
false,
|
||||
);
|
||||
assert.equal(firePassesFilter(high, { minFrp: 0, confidence: 'high' }), true);
|
||||
assert.equal(
|
||||
firePassesFilter(high, { minFrp: 5, confidence: 'high' }),
|
||||
false,
|
||||
);
|
||||
// Missing numbers read as 0, never as a pass-through.
|
||||
assert.equal(firePassesFilter({}, { minFrp: 1, confidence: 'all' }), false);
|
||||
assert.equal(
|
||||
firePassesFilter({ frp: 10 }, { minFrp: 0, confidence: 'nominal' }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('filterFires returns the same array for the default filter and preserves order otherwise', () => {
|
||||
const fires = [
|
||||
{ frp: 864, confidence: 0.3 },
|
||||
{ frp: 20, confidence: 0.6 },
|
||||
{ frp: 2, confidence: 0.9 },
|
||||
{ frp: 120, confidence: 0.9 },
|
||||
];
|
||||
assert.equal(filterFires(fires), fires);
|
||||
assert.equal(filterFires(fires, { minFrp: 0, confidence: 'all' }), fires);
|
||||
assert.deepEqual(
|
||||
filterFires(fires, { minFrp: 20, confidence: 'all' }).map((f) => f.frp),
|
||||
[864, 20, 120],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterFires(fires, { minFrp: 0, confidence: 'high' }).map((f) => f.frp),
|
||||
[2, 120],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterFires(fires, { minFrp: 100, confidence: 'nominal' }).map(
|
||||
(f) => f.frp,
|
||||
),
|
||||
[120],
|
||||
);
|
||||
assert.deepEqual(filterFires(null, { minFrp: 1, confidence: 'all' }), []);
|
||||
});
|
||||
@@ -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'),
|
||||
@@ -330,6 +331,12 @@ test('a refresh that drops the selected fire emits an eviction the readout can a
|
||||
const cleared = [];
|
||||
h.windowTarget.addEventListener('gev:entity-selection-cleared', (e) => cleared.push(e.detail));
|
||||
const priorFetch = globalThis.fetch;
|
||||
// The far band keeps the markers built under the heatmap for the hand-off
|
||||
// fade; their glow sprites need a canvas.
|
||||
const priorDocument = globalThis.document;
|
||||
globalThis.document = {
|
||||
createElement: () => ({ getContext: () => null }),
|
||||
};
|
||||
|
||||
try {
|
||||
h.click();
|
||||
@@ -379,6 +386,8 @@ test('a refresh that drops the selected fire emits an eviction the readout can a
|
||||
} finally {
|
||||
if (priorFetch === undefined) delete globalThis.fetch;
|
||||
else globalThis.fetch = priorFetch;
|
||||
if (priorDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = priorDocument;
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -400,3 +409,153 @@ test('disable cancels a pending source and a late response cannot repopulate the
|
||||
assert.equal(h.layer.getStats().loading, false);
|
||||
} finally { h.layer.destroy(h.viewer); h.cleanup(); }
|
||||
});
|
||||
|
||||
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 = () => {
|
||||
if (priorDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = priorDocument;
|
||||
};
|
||||
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({
|
||||
fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })],
|
||||
withDataSource: true,
|
||||
cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000),
|
||||
});
|
||||
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(primitive) {
|
||||
markers = primitive;
|
||||
},
|
||||
remove() {},
|
||||
raiseToTop() {},
|
||||
contains: () => true,
|
||||
};
|
||||
h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D };
|
||||
try {
|
||||
const collection = {
|
||||
addImageryProvider: (provider) => {
|
||||
const layer = { provider, show: true, alpha: 1 };
|
||||
added.push(layer);
|
||||
return layer;
|
||||
},
|
||||
remove: (layer, destroy) => removed.push({ layer, destroy }),
|
||||
raiseToTop() {},
|
||||
};
|
||||
h.layer.attachShellServices({
|
||||
imageryHost: () => ({ kind: 'globe', collection }),
|
||||
});
|
||||
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.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(4_500_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.disable();
|
||||
assert.equal(added[0].show, false, 'disable hides the heatmap');
|
||||
|
||||
h.layer.destroy(h.viewer);
|
||||
assert.deepEqual(
|
||||
removed.map((entry) => [entry.layer, entry.destroy]),
|
||||
[[added[0], true]],
|
||||
);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
+14
-9
@@ -4,6 +4,8 @@
|
||||
* dedicated card canvas moved into the shared world-overlay host.
|
||||
*/
|
||||
|
||||
import { FRP_RAMP, hexToRgb } from './firmsIntensity.js';
|
||||
|
||||
/** Shipped ambient FIRMS card ceiling. Selected fires bypass this cohort. */
|
||||
export const FIRMS_AMBIENT_COHORT_LIMIT = 18;
|
||||
export const FIRMS_OVERLAY_SOURCE_ID = 'firms';
|
||||
@@ -23,20 +25,23 @@ export function fireDetectionKey(fire) {
|
||||
return `firms:${lat}:${lon}:${acq}:${source}`;
|
||||
}
|
||||
|
||||
/** Severity accent palette — matches the FIRMS glow-sprite color stops. */
|
||||
const ACCENT_RGB = Object.freeze({
|
||||
red: '224, 82, 82',
|
||||
orange: '240, 178, 62',
|
||||
yellow: '244, 227, 108',
|
||||
});
|
||||
/**
|
||||
* Severity accent palette — the FIRMS glow-sprite color stops (the six-stop
|
||||
* FRP ramp in firmsIntensity.js) as "r, g, b" strings for card accents.
|
||||
*/
|
||||
const ACCENT_RGB = Object.freeze(
|
||||
Object.fromEntries(
|
||||
FRP_RAMP.map((stop) => [stop.name, hexToRgb(stop.hex).join(', ')]),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Severity stop name → "r, g, b" accent string (defaults to yellow).
|
||||
* @param {string} stopName - 'red' | 'orange' | 'yellow'.
|
||||
* Severity stop name → "r, g, b" accent string (defaults to the weakest stop).
|
||||
* @param {string} stopName - A FRP_RAMP stop name ('ember' … 'white-hot').
|
||||
* @returns {string}
|
||||
*/
|
||||
export function accentForSeverity(stopName) {
|
||||
return ACCENT_RGB[stopName] || ACCENT_RGB.yellow;
|
||||
return ACCENT_RGB[stopName] || ACCENT_RGB[FRP_RAMP[0].name];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,11 +9,16 @@ import {
|
||||
formatFrpMw,
|
||||
} from './firmsLabels.js';
|
||||
|
||||
test('FIRMS formatting helpers retain the shipped severity palette', () => {
|
||||
assert.equal(accentForSeverity('red'), '224, 82, 82');
|
||||
assert.equal(accentForSeverity('orange'), '240, 178, 62');
|
||||
assert.equal(accentForSeverity('yellow'), '244, 227, 108');
|
||||
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow'));
|
||||
test('FIRMS card accents are the six FRP ramp stops, weakest as the fallback', () => {
|
||||
// Ember #9e2a1b … white-hot #fff3b0, lightness rising with power.
|
||||
assert.equal(accentForSeverity('ember'), '158, 42, 27');
|
||||
assert.equal(accentForSeverity('red'), '210, 64, 31');
|
||||
assert.equal(accentForSeverity('flame'), '240, 106, 40');
|
||||
assert.equal(accentForSeverity('orange'), '251, 154, 46');
|
||||
assert.equal(accentForSeverity('gold'), '253, 200, 74');
|
||||
assert.equal(accentForSeverity('white-hot'), '255, 243, 176');
|
||||
assert.equal(accentForSeverity('blue'), accentForSeverity('ember'));
|
||||
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('ember'));
|
||||
});
|
||||
|
||||
test('FIRMS power that rounds to 10 MW drops the decimal', () => {
|
||||
|
||||
+20
-1
@@ -298,6 +298,20 @@ function satelliteFilterOption() {
|
||||
}
|
||||
|
||||
const OPTION_GROUPS = Object.freeze({
|
||||
// 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
|
||||
// firmsIntensity.js (pinned by layerState.test); spelled out here because
|
||||
// this file runs in isolation in the token-ledger tests.
|
||||
'local-firms': Object.freeze([
|
||||
boundedIntegerOption('minFrp', 'f', 0, { min: 0, max: 10000 }),
|
||||
enumOption('confidence', 'c', 'all', ['all', 'nominal', 'high'], {
|
||||
all: 'a',
|
||||
nominal: 'n',
|
||||
high: 'h',
|
||||
}),
|
||||
]),
|
||||
traffic: Object.freeze([
|
||||
enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], {
|
||||
tomtom: 't',
|
||||
@@ -626,7 +640,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
token: 'd',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({ id: 'local-firms', token: 'w', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'local-firms',
|
||||
token: 'w',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'local-firms',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'military',
|
||||
token: 'm',
|
||||
|
||||
@@ -29,6 +29,7 @@ import radioLayer from './radio.js';
|
||||
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';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
@@ -3007,7 +3008,10 @@ test('Clear All cancels a saved restore still waiting to start', async () => {
|
||||
gate.resolve();
|
||||
const results = await restore;
|
||||
for (const id of ['earthquakes', 'flights']) {
|
||||
assert.equal(results.find((result) => result.layerId === id).succeeded, false);
|
||||
assert.equal(
|
||||
results.find((result) => result.layerId === id).succeeded,
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert.deepEqual([...manager.getEnabledLayerIds()], []);
|
||||
assert.equal(enabledLayerParam(sink), '');
|
||||
@@ -3019,7 +3023,9 @@ test('a link through an engine without some layers keeps their state and activat
|
||||
const manager = new DataLayerManager({});
|
||||
const enabled = [];
|
||||
manager.register(
|
||||
fakeLayer('satellites', { enable: () => (enabled.push('satellites'), true) }),
|
||||
fakeLayer('satellites', {
|
||||
enable: () => (enabled.push('satellites'), true),
|
||||
}),
|
||||
);
|
||||
manager.finalizeRegistrations(
|
||||
LAYER_STATE_REGISTRY.filter(({ id }) => id === 'satellites'),
|
||||
@@ -3042,10 +3048,11 @@ test('a link through an engine without some layers keeps their state and activat
|
||||
.sort();
|
||||
assert.deepEqual(missing, ['ais-live-vessels', 'flights']);
|
||||
// Re-saved (the link the user copies on Navara): the state is all there.
|
||||
assert.deepEqual(
|
||||
sink.provider().enabledLayerIds,
|
||||
['ais-live-vessels', 'flights', 'satellites'],
|
||||
);
|
||||
assert.deepEqual(sink.provider().enabledLayerIds, [
|
||||
'ais-live-vessels',
|
||||
'flights',
|
||||
'satellites',
|
||||
]);
|
||||
// An explicit change of a layer that is here keeps the others too.
|
||||
await manager.setEnabled('satellites', false, { origin: 'user' });
|
||||
assert.deepEqual(sink.provider().enabledLayerIds, [
|
||||
@@ -3059,3 +3066,80 @@ test('a link through an engine without some layers keeps their state and activat
|
||||
assert.deepEqual(decoded.enabledLayerIds, ['ais-live-vessels', 'flights']);
|
||||
coordinator.destroy();
|
||||
});
|
||||
|
||||
/** Active Fires options decoded from a v2 link with these `lo` assignments. */
|
||||
function firmsOptions(lo) {
|
||||
const params = new URLSearchParams([
|
||||
['v', '2'],
|
||||
['l', 'w'],
|
||||
]);
|
||||
if (lo) params.set('lo', lo);
|
||||
return decodeLayerStateParams(params).options['local-firms'];
|
||||
}
|
||||
|
||||
test('Active Fires: the intensity filter rides share token w and round-trips', () => {
|
||||
const state = createDefaultLayerState();
|
||||
assert.deepEqual(state.options['local-firms'], {
|
||||
minFrp: 0,
|
||||
confidence: 'all',
|
||||
});
|
||||
state.enabledLayerIds = ['local-firms'];
|
||||
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,
|
||||
// wind overlay); the fire assignments ride beside them.
|
||||
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(' '));
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams(encoded));
|
||||
assert.deepEqual(decoded.options['local-firms'], {
|
||||
minFrp: 20,
|
||||
confidence: 'high',
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseStoredLayerState(serializeStoredLayerState(state)).options[
|
||||
'local-firms'
|
||||
],
|
||||
state.options['local-firms'],
|
||||
);
|
||||
// Defaults are omitted from the link: a plain `l=w` is "show everything".
|
||||
const plain = createDefaultLayerState();
|
||||
plain.enabledLayerIds = ['local-firms'];
|
||||
assert.equal(
|
||||
new URLSearchParams(encode(plain))
|
||||
.get('lo')
|
||||
.split('_')
|
||||
.filter((assignment) => assignment.startsWith('w.')).length,
|
||||
0,
|
||||
);
|
||||
assert.deepEqual(firmsOptions(''), { minFrp: 0, confidence: 'all' });
|
||||
});
|
||||
|
||||
test('Active Fires: the codec accepts whole megawatts in bounds and the three confidence codes', () => {
|
||||
// The codec spells its bounds out (it runs in isolation in the ledger
|
||||
// tests); they must stay the layer's.
|
||||
assert.equal(MIN_FRP_MW_MAX, 10000);
|
||||
assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']);
|
||||
assert.equal(firmsOptions('w.f.300').minFrp, 300);
|
||||
assert.equal(firmsOptions('w.f.10000').minFrp, 10000);
|
||||
assert.equal(firmsOptions('w.f.10001').minFrp, 0, 'out of bounds → default');
|
||||
assert.equal(firmsOptions('w.f.-5').minFrp, 0);
|
||||
assert.equal(
|
||||
firmsOptions('w.f.2.5').minFrp,
|
||||
0,
|
||||
'a third segment is rejected',
|
||||
);
|
||||
assert.equal(firmsOptions('w.c.a').confidence, 'all');
|
||||
assert.equal(firmsOptions('w.c.n').confidence, 'nominal');
|
||||
assert.equal(firmsOptions('w.c.h').confidence, 'high');
|
||||
assert.equal(
|
||||
firmsOptions('w.c.x').confidence,
|
||||
'all',
|
||||
'unknown code → default',
|
||||
);
|
||||
assert.deepEqual(firmsOptions('w.c.n_w.f.5'), {
|
||||
minFrp: 5,
|
||||
confidence: 'nominal',
|
||||
});
|
||||
});
|
||||
@@ -3218,6 +3218,19 @@
|
||||
"Download the timing log of the last play": "تنزيل سجل توقيت آخر تشغيل",
|
||||
"Edit anchors, data packs and shot details": "تعديل نقاط الارتكاز وحزم البيانات وتفاصيل اللقطة",
|
||||
"Download this scene as JSON or an asset bundle": "تنزيل هذا المشهد بصيغة JSON أو حزمة موارد",
|
||||
"fire confidence filter|All": "الكل",
|
||||
"fire confidence filter|Nominal+": "متوسطة فأعلى",
|
||||
"fire confidence filter|High": "عالية",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "كل عمليات الرصد — كثير من أقوى الحرائق مصنّفة بثقة منخفضة",
|
||||
"Nominal and high confidence detections": "عمليات الرصد ذات الثقة المتوسطة والعالية",
|
||||
"High confidence detections only": "عمليات الرصد ذات الثقة العالية فقط",
|
||||
"Fire intensity (FRP)": "الشدة (FRP)",
|
||||
"≥ {value} MW": "{value} MW على الأقل",
|
||||
"Confidence": "الثقة",
|
||||
"Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط",
|
||||
"fire intensity filter|All": "الكل",
|
||||
"Detection confidence": "ثقة الرصد",
|
||||
"{shown} of {total} shown": "المعروضة: {shown} من {total}",
|
||||
"Name of {name}": "اسم {name}",
|
||||
"Change the colour of {name}": "تغيير لون {name}",
|
||||
"Colour of {name}": "لون {name}",
|
||||
|
||||
@@ -2950,6 +2950,19 @@
|
||||
"Right panels": "Rechte Panels",
|
||||
"CONFIRMED": "BESTÄTIGT",
|
||||
"PAD / RTLS": "STARTPLATZ / RTLS",
|
||||
"fire confidence filter|All": "Alle",
|
||||
"fire confidence filter|Nominal+": "Mittel+",
|
||||
"fire confidence filter|High": "Hoch",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Alle Erkennungen — viele der stärksten Brände haben eine niedrige Konfidenz",
|
||||
"Nominal and high confidence detections": "Erkennungen mit mittlerer und hoher Konfidenz",
|
||||
"High confidence detections only": "Nur Erkennungen mit hoher Konfidenz",
|
||||
"Fire intensity (FRP)": "Intensität (FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "Konfidenz",
|
||||
"Minimum fire radiative power, megawatts": "Minimale Strahlungsleistung des Brandes in Megawatt",
|
||||
"fire intensity filter|All": "Alle",
|
||||
"Detection confidence": "Konfidenz der Erkennung",
|
||||
"{shown} of {total} shown": "{shown} von {total} angezeigt",
|
||||
"Name of {name}": "Name von {name}",
|
||||
"Change the colour of {name}": "Farbe von {name} ändern",
|
||||
"Colour of {name}": "Farbe von {name}",
|
||||
|
||||
@@ -2987,6 +2987,19 @@
|
||||
"Voice replies appear here.": "Las respuestas de voz aparecen aquí.",
|
||||
"Preview is off.": "La vista previa está desactivada.",
|
||||
"Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.",
|
||||
"fire confidence filter|All": "Todas",
|
||||
"fire confidence filter|Nominal+": "Nominal+",
|
||||
"fire confidence filter|High": "Alta",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos tienen confianza baja",
|
||||
"Nominal and high confidence detections": "Detecciones de confianza nominal y alta",
|
||||
"High confidence detections only": "Solo detecciones de confianza alta",
|
||||
"Fire intensity (FRP)": "Intensidad (FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "Confianza",
|
||||
"Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios",
|
||||
"fire intensity filter|All": "Todo",
|
||||
"Detection confidence": "Confianza de la detección",
|
||||
"{shown} of {total} shown": "{shown} de {total} mostrados",
|
||||
"{lower} to {upper}": "{lower} a {upper}",
|
||||
"Up to {upper}": "Hasta {upper}",
|
||||
"From {lower}": "Desde {lower}",
|
||||
|
||||
@@ -3020,6 +3020,19 @@
|
||||
"Right panels": "Panneaux de droite",
|
||||
"CONFIRMED": "CONFIRMÉE",
|
||||
"PAD / RTLS": "PAS DE TIR / RTLS",
|
||||
"fire confidence filter|All": "Toutes",
|
||||
"fire confidence filter|Nominal+": "Nominale+",
|
||||
"fire confidence filter|High": "Élevée",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — beaucoup des feux les plus intenses ont une confiance faible",
|
||||
"Nominal and high confidence detections": "Détections de confiance nominale et élevée",
|
||||
"High confidence detections only": "Détections de confiance élevée uniquement",
|
||||
"Fire intensity (FRP)": "Intensité (FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "Confiance",
|
||||
"Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts",
|
||||
"fire intensity filter|All": "Toutes",
|
||||
"Detection confidence": "Confiance de la détection",
|
||||
"{shown} of {total} shown": "{shown} sur {total} affichées",
|
||||
"Name of {name}": "Nom de {name}",
|
||||
"Change the colour of {name}": "Changer la couleur de {name}",
|
||||
"Colour of {name}": "Couleur de {name}",
|
||||
|
||||
@@ -3020,6 +3020,19 @@
|
||||
"Right panels": "Pannelli a destra",
|
||||
"CONFIRMED": "CONFERMATA",
|
||||
"PAD / RTLS": "SITO DI LANCIO / RTLS",
|
||||
"fire confidence filter|All": "Tutti",
|
||||
"fire confidence filter|Nominal+": "Nominale+",
|
||||
"fire confidence filter|High": "Alta",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Tutti i rilevamenti — molti degli incendi più intensi hanno confidenza bassa",
|
||||
"Nominal and high confidence detections": "Rilevamenti con confidenza nominale e alta",
|
||||
"High confidence detections only": "Solo rilevamenti con confidenza alta",
|
||||
"Fire intensity (FRP)": "Intensità (FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "Confidenza",
|
||||
"Minimum fire radiative power, megawatts": "Potenza radiativa minima dell'incendio, in megawatt",
|
||||
"fire intensity filter|All": "Tutti",
|
||||
"Detection confidence": "Confidenza del rilevamento",
|
||||
"{shown} of {total} shown": "{shown} di {total} mostrati",
|
||||
"Name of {name}": "Nome di {name}",
|
||||
"Change the colour of {name}": "Cambia il colore di {name}",
|
||||
"Colour of {name}": "Colore di {name}",
|
||||
|
||||
@@ -2880,6 +2880,19 @@
|
||||
"Right panels": "右パネル",
|
||||
"CONFIRMED": "確認済み",
|
||||
"PAD / RTLS": "射場 / RTLS",
|
||||
"fire confidence filter|All": "すべて",
|
||||
"fire confidence filter|Nominal+": "中以上",
|
||||
"fire confidence filter|High": "高",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "すべての検知 — 最も強い火災の多くは信頼度が低と判定されています",
|
||||
"Nominal and high confidence detections": "信頼度が中・高の検知",
|
||||
"High confidence detections only": "信頼度が高の検知のみ",
|
||||
"Fire intensity (FRP)": "強度(FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "信頼度",
|
||||
"Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)",
|
||||
"fire intensity filter|All": "すべて",
|
||||
"Detection confidence": "検知の信頼度",
|
||||
"{shown} of {total} shown": "{total}件中{shown}件を表示",
|
||||
"Name of {name}": "{name}の名前",
|
||||
"Change the colour of {name}": "{name}の色を変更",
|
||||
"Colour of {name}": "{name}の色",
|
||||
|
||||
@@ -3020,6 +3020,19 @@
|
||||
"Right panels": "Painéis à direita",
|
||||
"CONFIRMED": "CONFIRMADA",
|
||||
"PAD / RTLS": "PLATAFORMA / RTLS",
|
||||
"fire confidence filter|All": "Todas",
|
||||
"fire confidence filter|Nominal+": "Nominal+",
|
||||
"fire confidence filter|High": "Alta",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Todas as detecções — muitos dos incêndios mais intensos têm confiança baixa",
|
||||
"Nominal and high confidence detections": "Detecções de confiança nominal e alta",
|
||||
"High confidence detections only": "Somente detecções de confiança alta",
|
||||
"Fire intensity (FRP)": "Intensidade (FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "Confiança",
|
||||
"Minimum fire radiative power, megawatts": "Potência radiativa mínima do fogo, em megawatts",
|
||||
"fire intensity filter|All": "Todas",
|
||||
"Detection confidence": "Confiança da detecção",
|
||||
"{shown} of {total} shown": "{shown} de {total} exibidas",
|
||||
"Name of {name}": "Nome de {name}",
|
||||
"Change the colour of {name}": "Mudar a cor de {name}",
|
||||
"Colour of {name}": "Cor de {name}",
|
||||
|
||||
@@ -3057,6 +3057,19 @@
|
||||
"Voice replies appear here.": "Здесь появляются голосовые ответы.",
|
||||
"Preview is off.": "Предпросмотр отключён.",
|
||||
"Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.",
|
||||
"fire confidence filter|All": "Все",
|
||||
"fire confidence filter|Nominal+": "Средняя+",
|
||||
"fire confidence filter|High": "Высокая",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Все детекции — у многих сильнейших пожаров достоверность низкая",
|
||||
"Nominal and high confidence detections": "Детекции средней и высокой достоверности",
|
||||
"High confidence detections only": "Только детекции высокой достоверности",
|
||||
"Fire intensity (FRP)": "Интенсивность (FRP)",
|
||||
"≥ {value} MW": "≥ {value} МВт",
|
||||
"Confidence": "Достоверность",
|
||||
"Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты",
|
||||
"fire intensity filter|All": "Все",
|
||||
"Detection confidence": "Достоверность детекции",
|
||||
"{shown} of {total} shown": "показано {shown} из {total}",
|
||||
"{lower} to {upper}": "от {lower} до {upper}",
|
||||
"Up to {upper}": "До {upper}",
|
||||
"From {lower}": "От {lower}",
|
||||
|
||||
@@ -3049,6 +3049,19 @@
|
||||
"Don't show this again": "Більше не показувати",
|
||||
"ESC to dismiss": "ESC — приховати",
|
||||
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Порада: кнопка МІК у доку дає змогу говорити з картою.",
|
||||
"fire confidence filter|All": "Усі",
|
||||
"fire confidence filter|Nominal+": "Середня+",
|
||||
"fire confidence filter|High": "Висока",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Усі виявлення — багато найсильніших пожеж мають низьку достовірність",
|
||||
"Nominal and high confidence detections": "Виявлення середньої та високої достовірності",
|
||||
"High confidence detections only": "Лише виявлення високої достовірності",
|
||||
"Fire intensity (FRP)": "Інтенсивність (FRP)",
|
||||
"≥ {value} MW": "≥ {value} МВт",
|
||||
"Confidence": "Достовірність",
|
||||
"Minimum fire radiative power, megawatts": "Мінімальна потужність випромінювання пожежі, мегавати",
|
||||
"fire intensity filter|All": "Усі",
|
||||
"Detection confidence": "Достовірність виявлення",
|
||||
"{shown} of {total} shown": "показано {shown} з {total}",
|
||||
"{lower} to {upper}": "від {lower} до {upper}",
|
||||
"Up to {upper}": "До {upper}",
|
||||
"From {lower}": "Від {lower}",
|
||||
|
||||
@@ -2847,6 +2847,19 @@
|
||||
"other": "{count} 个地标"
|
||||
},
|
||||
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。",
|
||||
"fire confidence filter|All": "全部",
|
||||
"fire confidence filter|Nominal+": "标称+",
|
||||
"fire confidence filter|High": "高",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低",
|
||||
"Nominal and high confidence detections": "标称和高置信度的检测",
|
||||
"High confidence detections only": "仅高置信度的检测",
|
||||
"Fire intensity (FRP)": "火势强度 (FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "置信度",
|
||||
"Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)",
|
||||
"fire intensity filter|All": "全部",
|
||||
"Detection confidence": "检测置信度",
|
||||
"{shown} of {total} shown": "显示 {shown} / {total}",
|
||||
"{lower} to {upper}": "{lower} 至 {upper}",
|
||||
"Up to {upper}": "最高 {upper}",
|
||||
"From {lower}": "自 {lower} 起",
|
||||
|
||||
@@ -2886,6 +2886,19 @@
|
||||
"Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包",
|
||||
"Left panels": "左側面板",
|
||||
"Right panels": "右側面板",
|
||||
"fire confidence filter|All": "全部",
|
||||
"fire confidence filter|Nominal+": "標稱+",
|
||||
"fire confidence filter|High": "高",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "所有檢測——許多最強的火點置信度為低",
|
||||
"Nominal and high confidence detections": "標稱和高置信度的檢測",
|
||||
"High confidence detections only": "僅高置信度的檢測",
|
||||
"Fire intensity (FRP)": "火勢強度 (FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "置信度",
|
||||
"Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)",
|
||||
"fire intensity filter|All": "全部",
|
||||
"Detection confidence": "檢測置信度",
|
||||
"{shown} of {total} shown": "顯示 {shown} / {total}",
|
||||
"UTILITIES": "工具",
|
||||
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
|
||||
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"{value} KM/S",
|
||||
"ΔN",
|
||||
"Ⅱ PAUSE",
|
||||
"≥ {value} MW",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"es": [
|
||||
@@ -307,6 +308,7 @@
|
||||
"WX",
|
||||
"audio {state}",
|
||||
"dB",
|
||||
"fire confidence filter|Nominal+",
|
||||
"fire confidence|nominal",
|
||||
"ft",
|
||||
"hPa",
|
||||
@@ -329,6 +331,7 @@
|
||||
"{value} KM",
|
||||
"{value} KM/H",
|
||||
"{value} KM/S",
|
||||
"≥ {value} MW",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
@@ -515,6 +518,7 @@
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"Ⅱ PAUSE",
|
||||
"≥ {value} MW",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"it": [
|
||||
@@ -687,6 +691,7 @@
|
||||
"{value} KM/H",
|
||||
"{value} KM/S",
|
||||
"{value} conf",
|
||||
"≥ {value} MW",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
@@ -738,6 +743,7 @@
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"≥ {value} MW",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"pt": [
|
||||
@@ -851,6 +857,7 @@
|
||||
"WHOT/BHOT",
|
||||
"WX",
|
||||
"dB",
|
||||
"fire confidence filter|Nominal+",
|
||||
"fire confidence|nominal",
|
||||
"ft",
|
||||
"hPa",
|
||||
@@ -869,7 +876,8 @@
|
||||
"{value} KM/H",
|
||||
"{value} KM/S",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"≥ {value} MW"
|
||||
],
|
||||
"ru": [
|
||||
"AIS: --",
|
||||
@@ -928,9 +936,9 @@
|
||||
"MM",
|
||||
"NVG",
|
||||
"REC",
|
||||
"SRC {value}",
|
||||
"S2 GhostMaps",
|
||||
"S2 · {name}",
|
||||
"SRC {value}",
|
||||
"Sentinel-2",
|
||||
"Techno",
|
||||
"Trance",
|
||||
@@ -955,6 +963,7 @@
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
"{value} KM/S",
|
||||
"≥ {value} MW",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
@@ -1012,6 +1021,7 @@
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
"{value} KM/S",
|
||||
"≥ {value} MW",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Drapes the Active Fires heat raster over the globe as one full-extent
|
||||
* 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) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
return canvas;
|
||||
};
|
||||
|
||||
export function createHeatImagery({
|
||||
cesium,
|
||||
getHost,
|
||||
createCanvas = defaultCanvas,
|
||||
}) {
|
||||
let raster = null;
|
||||
let layer = null;
|
||||
let collection = null;
|
||||
let show = true;
|
||||
let opacity = 1;
|
||||
|
||||
function detach() {
|
||||
if (layer && collection) collection.remove(layer, true);
|
||||
layer = null;
|
||||
collection = null;
|
||||
}
|
||||
|
||||
function attach() {
|
||||
detach();
|
||||
if (!raster) return;
|
||||
const host = getHost?.() ?? { collection: null, kind: 'none' };
|
||||
if (!host.collection) return;
|
||||
const canvas = createCanvas(raster.width, raster.height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const image = ctx.createImageData(raster.width, raster.height);
|
||||
image.data.set(raster.rgba);
|
||||
ctx.putImageData(image, 0, 0);
|
||||
const provider = new cesium.SingleTileImageryProvider({
|
||||
url: canvas.toDataURL('image/png'),
|
||||
tileWidth: raster.width,
|
||||
tileHeight: raster.height,
|
||||
rectangle: cesium.Rectangle.MAX_VALUE,
|
||||
});
|
||||
collection = host.collection;
|
||||
layer = collection.addImageryProvider(provider);
|
||||
layer.show = show;
|
||||
layer.alpha = opacity;
|
||||
collection.raiseToTop?.(layer);
|
||||
}
|
||||
|
||||
return {
|
||||
setRaster(next) {
|
||||
raster = next;
|
||||
attach();
|
||||
},
|
||||
/** True once a raster was handed over (painted or waiting for a host). */
|
||||
hasRaster() {
|
||||
return raster !== null;
|
||||
},
|
||||
setShow(value) {
|
||||
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();
|
||||
},
|
||||
destroy() {
|
||||
detach();
|
||||
raster = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHeatImagery } from './heatImagery.js';
|
||||
import { buildHeatRaster } from '../../data/firmsHeatRaster.js';
|
||||
import { HEAT_RASTER } from './policy.js';
|
||||
|
||||
function harness() {
|
||||
const layers = [];
|
||||
const removed = [];
|
||||
const collection = {
|
||||
addImageryProvider(provider) {
|
||||
const layer = { provider, show: true, alpha: 1 };
|
||||
layers.push(layer);
|
||||
return layer;
|
||||
},
|
||||
remove(layer, destroy) {
|
||||
layers.splice(layers.indexOf(layer), 1);
|
||||
removed.push({ layer, destroy });
|
||||
},
|
||||
raiseToTop() {},
|
||||
};
|
||||
let host = { collection, kind: 'globe' };
|
||||
const cesium = {
|
||||
Rectangle: { MAX_VALUE: { max: true } },
|
||||
SingleTileImageryProvider: class {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
}
|
||||
},
|
||||
};
|
||||
const createCanvas = (w, h) => ({
|
||||
width: w,
|
||||
height: h,
|
||||
getContext: () => ({
|
||||
createImageData: (cw, ch) => ({
|
||||
data: new Uint8ClampedArray(cw * ch * 4),
|
||||
}),
|
||||
putImageData() {},
|
||||
}),
|
||||
toDataURL: () => 'data:image/png;base64,AAAA',
|
||||
});
|
||||
const imagery = createHeatImagery({
|
||||
cesium,
|
||||
getHost: () => host,
|
||||
createCanvas,
|
||||
});
|
||||
return { imagery, layers, removed, collection, setHost: (h) => (host = h) };
|
||||
}
|
||||
|
||||
const raster = { width: 4, height: 2, rgba: new Uint8ClampedArray(32) };
|
||||
|
||||
test('setRaster drapes one full-globe tile and replaces it on the next build', () => {
|
||||
const h = harness();
|
||||
h.imagery.setRaster(raster);
|
||||
assert.equal(h.layers.length, 1);
|
||||
assert.deepEqual(h.layers[0].provider.options.rectangle, { max: true });
|
||||
assert.equal(h.layers[0].provider.options.tileWidth, 4);
|
||||
h.imagery.setRaster(raster);
|
||||
assert.equal(h.layers.length, 1, 'never two heat layers');
|
||||
assert.equal(h.removed[0].destroy, true);
|
||||
});
|
||||
|
||||
test('setShow toggles without rebuilding', () => {
|
||||
const h = harness();
|
||||
h.imagery.setRaster(raster);
|
||||
const layer = h.layers[0];
|
||||
h.imagery.setShow(false);
|
||||
assert.equal(layer.show, false);
|
||||
h.imagery.setShow(true);
|
||||
assert.equal(h.layers[0], layer);
|
||||
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' });
|
||||
h.imagery.setRaster(raster);
|
||||
assert.equal(h.layers.length, 0);
|
||||
h.setHost({ collection: h.collection, kind: 'globe' });
|
||||
h.imagery.rehome();
|
||||
assert.equal(h.layers.length, 1);
|
||||
});
|
||||
|
||||
test('destroy removes the layer', () => {
|
||||
const h = harness();
|
||||
h.imagery.setRaster(raster);
|
||||
h.imagery.destroy();
|
||||
assert.equal(h.layers.length, 0);
|
||||
});
|
||||
|
||||
test('the shipped raster paints a lone weak fire as a distinct spot', () => {
|
||||
// A hot cluster elsewhere sets the scale, as Africa does on real data.
|
||||
const fires = [{ lat: 0, lon: 0, frp: 5 }];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
fires.push({ lat: -10 + (i % 5) * 0.1, lon: 25 + i * 0.05, frp: 300 });
|
||||
}
|
||||
const r = buildHeatRaster(fires, HEAT_RASTER);
|
||||
const y = Math.floor((90 / 180) * r.height);
|
||||
const painted = [];
|
||||
for (let x = 0; x < r.width; x++) {
|
||||
const lon = ((x + 0.5) / r.width) * 360 - 180;
|
||||
if (Math.abs(lon) < 5 && r.rgba[(y * r.width + x) * 4 + 3] > 0) {
|
||||
painted.push(x);
|
||||
}
|
||||
}
|
||||
const kmPerPx = 40075 / r.width;
|
||||
assert.ok(painted.length > 0, 'the weak fire still shows');
|
||||
assert.ok(
|
||||
painted.length * kmPerPx <= 100,
|
||||
`spot ${Math.round(painted.length * kmPerPx)} km wide`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
|
||||
test('reenabling an empty filtered layer never reveals the retained pre-filter heat raster', async () => {
|
||||
let shown = false;
|
||||
const noop = () => {};
|
||||
const layerState = {
|
||||
_enabled: false,
|
||||
_viewer: {},
|
||||
_dataSource: {},
|
||||
_fires: [],
|
||||
_loading: true,
|
||||
_pickIndexById: new Map(),
|
||||
// The layer's renderer on the current globe (render/cesium.js).
|
||||
_renderer: { setVisible: noop, onEnable: noop },
|
||||
_heatImagery: {
|
||||
setShow(v) {
|
||||
shown = v;
|
||||
},
|
||||
},
|
||||
};
|
||||
const lifecycle = createLifecycle({
|
||||
layerState,
|
||||
services: {
|
||||
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
|
||||
sprites: { restoreSpriteOrderOnEnable: noop },
|
||||
context: { clearSelectedEntityContextForLayer: noop },
|
||||
},
|
||||
config: { id: 'local-firms', overlayHost: { setVisible: noop } },
|
||||
components: {
|
||||
rendering: { applyHandoffFade: noop, renderCurrentLod: noop },
|
||||
viewport: { installLodWatcher: noop, installMoveEndWatcher: noop },
|
||||
selection: { installClickHandler: noop },
|
||||
},
|
||||
});
|
||||
await lifecycle.methods.enable(layerState._viewer);
|
||||
assert.equal(
|
||||
shown,
|
||||
false,
|
||||
'empty current set must keep the old raster hidden while refresh is pending',
|
||||
);
|
||||
layerState._fires = [{ frp: null, confidence: null }];
|
||||
await lifecycle.methods.enable(layerState._viewer);
|
||||
assert.equal(shown, true, 'nonempty sets still resume their raster');
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { adaptFirmsRecords } from '../../data/firmsAdapt.js';
|
||||
import { fireDetectionKey } from '../../data/firmsLabels.js';
|
||||
import { filterFires } from '../../data/firmsIntensity.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
|
||||
export function createIngestion({
|
||||
@@ -40,12 +41,13 @@ export function createIngestion({
|
||||
layerState._stale = Boolean(payload?.stale);
|
||||
const previousSelection = layerState._selectedFire;
|
||||
layerState._selectedFire = null;
|
||||
layerState._fires = adaptFirmsRecords(payload?.fires);
|
||||
layerState._cellCacheByGrid.clear(); // aggregation is per-dataset — new fires, new cells
|
||||
layerState._firesByFrp = [...layerState._fires].sort(
|
||||
layerState._allFires = adaptFirmsRecords(payload?.fires);
|
||||
// Sorted once per payload; applyFilter only filters, so a slider drag
|
||||
// over 200k detections never re-sorts.
|
||||
layerState._allFiresByFrp = [...layerState._allFires].sort(
|
||||
(a, b) => b.frp - a.frp,
|
||||
);
|
||||
layerState._count = layerState._fires.length;
|
||||
applyFilter();
|
||||
// Data age, not response age: a stale proxy payload truthfully reads old.
|
||||
layerState._lastUpdate = Number.isFinite(payload?.fetchedAt)
|
||||
? payload.fetchedAt
|
||||
@@ -65,6 +67,7 @@ export function createIngestion({
|
||||
}
|
||||
components.rendering.renderCurrentLod(true);
|
||||
if (reselected) components.selection.selectFire(reselected, false);
|
||||
layerState._rowControlsListener?.();
|
||||
} catch (error) {
|
||||
if (
|
||||
request.signal.aborted ||
|
||||
@@ -83,6 +86,26 @@ export function createIngestion({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the intensity filter over the loaded payload. This is the ONE place
|
||||
* the shown set is derived: `_fires` / `_firesByFrp` feed markers, cards,
|
||||
* the heatmap, the context store, the top-fires query and the analyst
|
||||
* records, so none of them can disagree about which fires exist. Cells and
|
||||
* the heat raster are built per shown set, so their caches go with it.
|
||||
*/
|
||||
|
||||
function applyFilter() {
|
||||
layerState._fires = filterFires(layerState._allFires, layerState._filter);
|
||||
layerState._cellCacheByGrid.clear();
|
||||
layerState._heatRaster = null;
|
||||
layerState._firesByFrp = filterFires(
|
||||
layerState._allFiresByFrp,
|
||||
layerState._filter,
|
||||
);
|
||||
layerState._count = layerState._fires.length;
|
||||
layerState._totalCount = layerState._allFires.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the record in the freshly-loaded set matching a previous selection.
|
||||
* Identity is the detection itself (lat/lon/acquisition time) — indices
|
||||
@@ -109,5 +132,5 @@ export function createIngestion({
|
||||
},
|
||||
};
|
||||
|
||||
return { loadHeatmap, findMatchingFire, methods };
|
||||
return { loadHeatmap, applyFilter, findMatchingFire, methods };
|
||||
}
|
||||
@@ -11,7 +11,32 @@ export function createLifecycle({
|
||||
const { clearSelectedEntityContextForLayer } = services.context;
|
||||
const { id, overlayHost } = config;
|
||||
|
||||
// The heatmap is draped on the renderer's globe (Cesium only); it goes
|
||||
// with the renderer and is built again on the next one that can drape it.
|
||||
function releaseHeat() {
|
||||
clearTimeout(layerState._heatRebuildTimer);
|
||||
layerState._heatRebuildTimer = null;
|
||||
layerState._heatImagery?.destroy();
|
||||
layerState._heatImagery = null;
|
||||
layerState._heatRaster = null;
|
||||
layerState._handoffHeat = -1;
|
||||
}
|
||||
|
||||
const methods = {
|
||||
/**
|
||||
* Shell services: `imageryHost()` resolves where draped imagery goes on
|
||||
* the active map stack (globe or 3D tiles, src/maps/imageryHost.js). The
|
||||
* heatmap moves with it; without a host it drapes on viewer.imageryLayers.
|
||||
* @param {{imageryHost?: () => {collection: ?Object, kind: string}}|null} services
|
||||
*/
|
||||
attachShellServices(services) {
|
||||
layerState._imageryHost =
|
||||
typeof services?.imageryHost === 'function'
|
||||
? services.imageryHost
|
||||
: null;
|
||||
layerState._heatImagery?.rehome();
|
||||
},
|
||||
|
||||
init(viewer, options) {
|
||||
this.attachRenderer(viewer, options);
|
||||
},
|
||||
@@ -53,6 +78,7 @@ export function createLifecycle({
|
||||
components.viewport.removeLodWatcher();
|
||||
unregisterPickOwner(id);
|
||||
components.rendering.removeDetectionCollections(layerState._viewer);
|
||||
releaseHeat();
|
||||
layerState._renderer?.destroy();
|
||||
layerState._renderer = layerState._viewer = layerState._dataSource = null;
|
||||
layerState._pickIndexById.clear();
|
||||
@@ -73,6 +99,10 @@ export function createLifecycle({
|
||||
}
|
||||
layerState._renderer.setVisible(true);
|
||||
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true);
|
||||
// A filter can leave an old raster cached while the current set is empty.
|
||||
layerState._heatImagery?.setShow(layerState._fires.length > 0);
|
||||
// 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();
|
||||
@@ -101,6 +131,7 @@ export function createLifecycle({
|
||||
layerState._enabled = false;
|
||||
components.selection.clearFireSelection();
|
||||
layerState._renderer?.setVisible(false);
|
||||
layerState._heatImagery?.setShow(false);
|
||||
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
|
||||
clearSelectedEntityContextForLayer(id);
|
||||
@@ -128,11 +159,17 @@ export function createLifecycle({
|
||||
layerState._dataSource = null;
|
||||
components.selection.clearContextRegistrations();
|
||||
clearSelectedEntityContextForLayer(id);
|
||||
layerState._allFires = [];
|
||||
layerState._allFiresByFrp = [];
|
||||
layerState._fires = [];
|
||||
layerState._firesByFrp = [];
|
||||
layerState._cellCacheByGrid.clear();
|
||||
layerState.glowSpriteCache.clear();
|
||||
releaseHeat();
|
||||
layerState._imageryHost = null;
|
||||
layerState._count = 0;
|
||||
layerState._totalCount = 0;
|
||||
layerState._rowControlsListener = null;
|
||||
layerState._cellCount = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._keyRequired = false;
|
||||
|
||||
+43
-31
@@ -1,6 +1,5 @@
|
||||
import { rectangleWidth, rectangleHeight } from './rectangle.js';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { constantRgba } from '../shared/color.js';
|
||||
import { formatNumber, getLocale, isolate, t, tn } from '../../i18n/index.js';
|
||||
import {
|
||||
satelliteShortName,
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
accentForSeverity,
|
||||
formatFrpMw,
|
||||
} from '../../data/firmsLabels.js';
|
||||
import { FRP_RAMP, frpRampStop } from '../../data/firmsIntensity.js';
|
||||
import {
|
||||
VIEW_PADDING,
|
||||
DETECTION_COLOR_STOPS,
|
||||
@@ -15,9 +15,6 @@ import {
|
||||
CULL_LIFT_M,
|
||||
LABEL_MIN_SEP_PX,
|
||||
} from './policy.js';
|
||||
const FIRE_RED = constantRgba(1, 0, 0);
|
||||
const FIRE_ORANGE = constantRgba(1, 165 / 255, 0);
|
||||
const FIRE_YELLOW = constantRgba(1, 1, 0);
|
||||
|
||||
export function createModel({
|
||||
layerState,
|
||||
@@ -124,27 +121,30 @@ export function createModel({
|
||||
);
|
||||
}
|
||||
|
||||
function heatColor(value, alpha) {
|
||||
if (value > 0.72) return FIRE_RED.withAlpha(alpha);
|
||||
if (value > 0.42) return FIRE_ORANGE.withAlpha(alpha);
|
||||
return FIRE_YELLOW.withAlpha(alpha);
|
||||
/**
|
||||
* The ramp stop for a fire radiative power (stepped, weakest first).
|
||||
* Intensity alone drives the color: confidence feeds the card text and the
|
||||
* switch, brightness is unreliable (saturates at 367 K) — see HAL-675.
|
||||
* @param {number} frp - Megawatts.
|
||||
* @returns {{name: string, minFrp: number, color: Cesium.Color}}
|
||||
*/
|
||||
|
||||
function frpColorStop(frp) {
|
||||
const name = frpRampStop(frp).name;
|
||||
return (
|
||||
DETECTION_COLOR_STOPS.find((stop) => stop.name === name) ||
|
||||
DETECTION_COLOR_STOPS[0]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a sprite color stop from FRP + confidence, reusing the same
|
||||
* yellow → orange → red thresholds as the aggregated heat cells.
|
||||
* Pick a sprite color stop for one detection from its FRP on the shared ramp.
|
||||
* @param {Object} fire - Detection record.
|
||||
* @returns {{name: string, color: Cesium.Color}}
|
||||
*/
|
||||
|
||||
function detectionColorStop(fire) {
|
||||
const heat = Math.min(
|
||||
1,
|
||||
Math.sqrt(Math.max(0, fire.frp) / 150) * 0.85 + fire.confidence * 0.15,
|
||||
);
|
||||
if (heat > 0.72) return DETECTION_COLOR_STOPS[0];
|
||||
if (heat > 0.42) return DETECTION_COLOR_STOPS[1];
|
||||
return DETECTION_COLOR_STOPS[2];
|
||||
return frpColorStop(fire?.frp);
|
||||
}
|
||||
|
||||
/** FRP → core marker pixel size, clamped to 8..28px. */
|
||||
@@ -232,6 +232,19 @@ export function createModel({
|
||||
return fire.position;
|
||||
}
|
||||
|
||||
/**
|
||||
* The anchor already built for a detection, without the floor lookup;
|
||||
* for the far marker band, where floors are never warmed and the height
|
||||
* difference is invisible, so tens of thousands of markers skip a cache
|
||||
* read each per rebuild. Builds one through {@link firePosition} once.
|
||||
* @param {Object} fire - Detection record.
|
||||
* @returns {Cesium.Cartesian3}
|
||||
*/
|
||||
|
||||
function fireBuiltPosition(fire) {
|
||||
return fire.position ?? firePosition(fire);
|
||||
}
|
||||
|
||||
/**
|
||||
* Occlusion-test anchor for one detection. Returns the render position
|
||||
* outright for anchors comfortably above the ellipsoid (the common case) and
|
||||
@@ -239,11 +252,11 @@ export function createModel({
|
||||
* NEVER feeds rendering: the datum-correct anchor from {@link firePosition}
|
||||
* is what the sprite and the card are drawn at.
|
||||
* @param {Object} fire - Detection record.
|
||||
* @param {Cesium.Cartesian3} [position] Its render anchor, if at hand.
|
||||
* @returns {Cesium.Cartesian3}
|
||||
*/
|
||||
|
||||
function fireCullPosition(fire) {
|
||||
const position = firePosition(fire);
|
||||
function fireCullPosition(fire, position = firePosition(fire)) {
|
||||
if (fire.positionHeight >= CULL_LIFT_THRESHOLD_M) return position;
|
||||
if (!fire.cullPosition) {
|
||||
fire.cullPosition = geo.cartesian3.fromDegrees(
|
||||
@@ -403,8 +416,8 @@ export function createModel({
|
||||
|
||||
/**
|
||||
* Card model for an aggregated heat cell, e.g. title "14 FIRES", detail
|
||||
* "max 210 MW · new 3h". Accent comes from the candidate (heat-normalized
|
||||
* score is only known at renderCells time). Exported for unit tests.
|
||||
* "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets
|
||||
* it with {@link cellCardAccent}). Exported for unit tests.
|
||||
* @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate
|
||||
* @param {number} nowMs - Current epoch milliseconds.
|
||||
* @returns {Object} firmsLabels entry.
|
||||
@@ -422,7 +435,7 @@ export function createModel({
|
||||
position: candidate.position,
|
||||
cullPosition: candidate.cullPosition || candidate.position,
|
||||
gapPx: 10,
|
||||
accent: candidate.accent || accentForSeverity('yellow'),
|
||||
accent: candidate.accent || accentForSeverity(FRP_RAMP[0].name),
|
||||
// The count picks the plural form of the language ("حريقان (2)",
|
||||
// "14 حريقًا", "14 ПОЖАРОВ"); the card shows it in capitals like the
|
||||
// other card titles.
|
||||
@@ -469,16 +482,14 @@ export function createModel({
|
||||
}
|
||||
|
||||
/**
|
||||
* Heat-normalized cell score → severity accent, mirroring heatColor's
|
||||
* red/orange/yellow thresholds.
|
||||
* @param {number} normalized - 0..1 normalized heat score.
|
||||
* Cell card accent: the cell's strongest fire on the FRP ramp, the same
|
||||
* color its markers take once the camera is close enough to show them.
|
||||
* @param {{maxFrp?: number}} cell - Aggregated cell.
|
||||
* @returns {string} "r, g, b" accent string.
|
||||
*/
|
||||
|
||||
function cellAccent(normalized) {
|
||||
if (normalized > 0.72) return accentForSeverity('red');
|
||||
if (normalized > 0.42) return accentForSeverity('orange');
|
||||
return accentForSeverity('yellow');
|
||||
function cellCardAccent(cell) {
|
||||
return accentForSeverity(frpColorStop(cell?.maxFrp ?? 0).name);
|
||||
}
|
||||
|
||||
/** Coordinate line like "30.512°N 75.831°E". */
|
||||
@@ -584,12 +595,13 @@ export function createModel({
|
||||
boundsContainPoint,
|
||||
cellIntersectsBounds,
|
||||
heatScore,
|
||||
heatColor,
|
||||
frpColorStop,
|
||||
detectionColorStop,
|
||||
frpPixelSize,
|
||||
sizeBucket,
|
||||
glowSprite,
|
||||
firePosition,
|
||||
fireBuiltPosition,
|
||||
fireCullPosition,
|
||||
cellCullPosition,
|
||||
applyHorizonCull,
|
||||
@@ -598,7 +610,7 @@ export function createModel({
|
||||
buildFireCard,
|
||||
buildCellCard,
|
||||
applyFirmsOverlayPolicy,
|
||||
cellAccent,
|
||||
cellCardAccent,
|
||||
formatLatLon,
|
||||
formatFrp,
|
||||
formatAge,
|
||||
|
||||
+58
-17
@@ -1,17 +1,16 @@
|
||||
import { FIRMS_AMBIENT_COHORT_LIMIT } from '../../data/firmsLabels.js';
|
||||
import { constantRgba } from '../shared/color.js';
|
||||
const FIRE_RED = constantRgba(1, 0, 0);
|
||||
const FIRE_ORANGE = constantRgba(1, 165 / 255, 0);
|
||||
const FIRE_YELLOW = constantRgba(1, 1, 0);
|
||||
import { rgbaFromCss } from '../shared/color.js';
|
||||
import { FRP_RAMP } from '../../data/firmsIntensity.js';
|
||||
|
||||
/** Client poll interval; the proxy's 30 min TTL is what guards upstream quota. */
|
||||
|
||||
export const REFRESH_INTERVAL_MS = 600_000;
|
||||
|
||||
/**
|
||||
* LOD bands keyed by camera height. `cells` bands render aggregated
|
||||
* ground-clamped heat rectangles; `detections` bands (< ~750km plus the
|
||||
* local band) render individual fire detections as glow-sprite billboards.
|
||||
* LOD bands keyed by camera height. `heat` bands (above ~4,500 km) drape a
|
||||
* Snap Map–style heatmap over the globe (firmsHeatRaster.js); their
|
||||
* `gridDegrees` aggregation only feeds the "N FIRES" cards. `detections`
|
||||
* bands render individual fire detections as glow-sprite billboards.
|
||||
* Labels in every band go through the shared screen-space greedy declutter
|
||||
* (hard cap MAX_AMBIENT_LABELS) — there are no per-band label knobs.
|
||||
*/
|
||||
@@ -20,24 +19,28 @@ export const LOD_LEVELS = [
|
||||
{
|
||||
id: 'global',
|
||||
minHeight: 9000000,
|
||||
mode: 'cells',
|
||||
mode: 'heat',
|
||||
gridDegrees: 2.0,
|
||||
maxCells: 1800,
|
||||
labelDistance: 12000000,
|
||||
},
|
||||
{
|
||||
id: 'regional',
|
||||
minHeight: 3000000,
|
||||
mode: 'cells',
|
||||
minHeight: 4500000,
|
||||
mode: 'heat',
|
||||
gridDegrees: 1.0,
|
||||
maxCells: 3600,
|
||||
labelDistance: 8500000,
|
||||
},
|
||||
{
|
||||
// Up to 40,000 markers: at the 3,600 km end of the hand-off the padded
|
||||
// view over Africa, the densest region, holds ~40,000 detections, and a
|
||||
// tighter cap left the heatmap's weak spots dissolving into nothing.
|
||||
// A rebuild of that many costs ~60 ms, only there.
|
||||
id: 'local',
|
||||
minHeight: 750000,
|
||||
mode: 'detections',
|
||||
maxDetections: 2500,
|
||||
maxDetections: 40000,
|
||||
labelDistance: 4500000,
|
||||
},
|
||||
{
|
||||
@@ -49,6 +52,38 @@ export const LOD_LEVELS = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 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 near the 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: 4096,
|
||||
height: 2048,
|
||||
sigmaPx: 1,
|
||||
});
|
||||
|
||||
/**
|
||||
* Camera heights, in metres, over which the heatmap and the markers
|
||||
* cross-fade around the 4,500 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: 3_600_000, to: 5_400_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
|
||||
* encode costs a few hundred milliseconds. The first paint is immediate.
|
||||
*/
|
||||
|
||||
export const HEAT_REBUILD_MS = 150;
|
||||
|
||||
export const LOD_CHECK_MS = 650;
|
||||
|
||||
/** +/-10% hysteresis on LOD band edges so slow zooms don't thrash rebuilds. */
|
||||
@@ -91,10 +126,16 @@ export const CULL_LIFT_THRESHOLD_M = 10;
|
||||
|
||||
export const CULL_LIFT_M = 12;
|
||||
|
||||
/** Color stops shared by cell heat fills and detection glow sprites. */
|
||||
/**
|
||||
* Color stops for detection glow sprites and card accents: the
|
||||
* six-stop ember → white-hot FRP ramp (firmsIntensity.js), weakest first, as Cesium
|
||||
* colors. Stop names key the glow-sprite cache and the card accents.
|
||||
*/
|
||||
|
||||
export const DETECTION_COLOR_STOPS = [
|
||||
{ name: 'red', color: FIRE_RED },
|
||||
{ name: 'orange', color: FIRE_ORANGE },
|
||||
{ name: 'yellow', color: FIRE_YELLOW },
|
||||
];
|
||||
export const DETECTION_COLOR_STOPS = FRP_RAMP.map((stop) =>
|
||||
Object.freeze({
|
||||
name: stop.name,
|
||||
minFrp: stop.minFrp,
|
||||
color: rgbaFromCss(stop.hex),
|
||||
}),
|
||||
);
|
||||
+161
-2
@@ -1,6 +1,18 @@
|
||||
import { fireDetectionKey } from '../../data/firmsLabels.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { REFRESH_INTERVAL_MS } from './policy.js';
|
||||
import {
|
||||
FIRMS_CONFIDENCE_MODES,
|
||||
FRP_SLIDER_TICKS,
|
||||
firePassesFilter,
|
||||
frpForSliderPosition,
|
||||
normalizeFireFilter,
|
||||
rampTrackGradient,
|
||||
sliderPositionForFrp,
|
||||
} from '../../data/firmsIntensity.js';
|
||||
import { formatNumber, t } from '../../i18n/index.js';
|
||||
import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js';
|
||||
|
||||
/** Slider resolution: whole-percent positions along the log axis. */
|
||||
const SLIDER_STEPS = 1000;
|
||||
|
||||
export function createQueries({
|
||||
layerState,
|
||||
@@ -11,6 +23,25 @@ export function createQueries({
|
||||
}) {
|
||||
const { id, name, icon, source } = config;
|
||||
|
||||
/** Whole-megawatt label for a slider value or tick ("0", "20", "1 000"). */
|
||||
function frpLabel(frp) {
|
||||
return formatNumber(frp, { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
const confidenceLabels = () => ({
|
||||
all: t('All', { context: 'fire confidence filter' }),
|
||||
nominal: t('Nominal+', { context: 'fire confidence filter' }),
|
||||
high: t('High', { context: 'fire confidence filter' }),
|
||||
});
|
||||
|
||||
const confidenceTitles = () => ({
|
||||
all: t(
|
||||
'Every detection — many of the strongest fires are rated low confidence',
|
||||
),
|
||||
nominal: t('Nominal and high confidence detections'),
|
||||
high: t('High confidence detections only'),
|
||||
});
|
||||
|
||||
const methods = {
|
||||
/** Return the selected detection as a plain record for application consumers. */
|
||||
getSelectedInfo() {
|
||||
@@ -51,6 +82,110 @@ export function createQueries({
|
||||
// not this interval — is what protects the upstream FIRMS quota).
|
||||
updateInterval: REFRESH_INTERVAL_MS,
|
||||
|
||||
/**
|
||||
* The intensity filter as layer options (share token `w`, owner
|
||||
* `local-firms` in layerState.js).
|
||||
* @returns {{minFrp: number, confidence: string}}
|
||||
*/
|
||||
getParams() {
|
||||
return { ...layerState._filter };
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply a (partial) intensity filter. Invalid fields keep their current
|
||||
* 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
|
||||
* @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;
|
||||
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?.();
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
getRowControls() {
|
||||
const { minFrp, confidence } = layerState._filter;
|
||||
const labels = confidenceLabels();
|
||||
const titles = confidenceTitles();
|
||||
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),
|
||||
}),
|
||||
},
|
||||
segmented: {
|
||||
id: 'confidence',
|
||||
label: t('Confidence'),
|
||||
ariaLabel: t('Detection confidence'),
|
||||
options: FIRMS_CONFIDENCE_MODES.map((mode) => ({
|
||||
id: `confidence-${mode}`,
|
||||
label: labels[mode],
|
||||
title: titles[mode],
|
||||
active: mode === confidence,
|
||||
params: { confidence: mode },
|
||||
})),
|
||||
},
|
||||
info: total
|
||||
? t('{shown} of {total} shown', {
|
||||
shown: formatNumber(shown),
|
||||
total: formatNumber(total),
|
||||
})
|
||||
: '',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Install the panel's "row controls changed" callback: a load or a filter
|
||||
* change moves the counter, and nothing else repaints the row before the
|
||||
* next scheduled refresh.
|
||||
* @param {(() => void)|null} listener
|
||||
*/
|
||||
setRowControlsListener(listener) {
|
||||
layerState._rowControlsListener =
|
||||
typeof listener === 'function' ? listener : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Layer stats for the data panel. Degraded feed states surface through
|
||||
* `error` (established qa-failstate pattern: a dead feed must never look
|
||||
@@ -84,7 +219,9 @@ export function createQueries({
|
||||
});
|
||||
}
|
||||
return {
|
||||
// Shown (filtered) detections; `total` is the loaded payload.
|
||||
count: layerState._count,
|
||||
total: layerState._totalCount,
|
||||
cells: layerState._cellCount,
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
loading: layerState._loading,
|
||||
@@ -172,12 +309,34 @@ export function createQueries({
|
||||
return result;
|
||||
},
|
||||
|
||||
/** Test seam: render one named LOD band against a near-global view. */
|
||||
_renderLodForTest(lodId) {
|
||||
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. */
|
||||
_entityCountForTest() {
|
||||
return layerState._dataSource?.entities?.values?.length ?? 0;
|
||||
},
|
||||
|
||||
/** Test seam that binds the production click path and indexes. */
|
||||
_bindInteractionForTest(viewer, fires = []) {
|
||||
layerState._viewer = viewer;
|
||||
layerState._enabled = true;
|
||||
layerState._allFires = fires;
|
||||
layerState._allFiresByFrp = [...fires];
|
||||
layerState._fires = fires;
|
||||
layerState._firesByFrp = [...fires];
|
||||
layerState._count = fires.length;
|
||||
layerState._totalCount = fires.length;
|
||||
layerState._pickIndexById.clear();
|
||||
layerState._labelLodDistance = 1e7;
|
||||
layerState._labelCandidates = fires.map((fire) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
worldToWindowCoordinates,
|
||||
horizonOccluder,
|
||||
} from '../../../data/render/cesiumProjection.js';
|
||||
import { createHeatImagery } from '../heatImagery.js';
|
||||
|
||||
// Cesium ScreenSpaceEventType.LEFT_CLICK; available before the SDK loads.
|
||||
export const FIRMS_LEFT_CLICK = 2;
|
||||
@@ -22,6 +23,7 @@ export function createFirmsCesiumRenderer(
|
||||
let sprites = null,
|
||||
visible = false,
|
||||
destroyed = false;
|
||||
const fadeColor = new Cesium.Color();
|
||||
const spriteServices = services.sprites;
|
||||
function removeSprites() {
|
||||
if (!sprites) return;
|
||||
@@ -110,6 +112,27 @@ export function createFirmsCesiumRenderer(
|
||||
});
|
||||
},
|
||||
removeSprites,
|
||||
/**
|
||||
* The far-zoom heatmap draped on this globe: on the active map stack's
|
||||
* imagery host (`imageryHost()`), else on the viewer's imagery layers.
|
||||
*/
|
||||
createHeatImagery(imageryHost) {
|
||||
return createHeatImagery({
|
||||
cesium: Cesium,
|
||||
getHost: () =>
|
||||
imageryHost?.() ?? {
|
||||
collection: viewer.imageryLayers ?? null,
|
||||
kind: 'globe',
|
||||
},
|
||||
});
|
||||
},
|
||||
/** The hand-off fade: every marker at `alpha`, shown while `show`. */
|
||||
fadeSprites(alpha, show) {
|
||||
if (destroyed || !sprites) return;
|
||||
sprites.show = show;
|
||||
const color = Cesium.Color.WHITE.withAlpha(alpha, fadeColor);
|
||||
for (let i = 0; i < sprites.length; i += 1) sprites.get(i).color = color;
|
||||
},
|
||||
onEnable() {
|
||||
if (!destroyed)
|
||||
spriteServices.restoreSpriteOrderOnEnable('firms', viewer);
|
||||
|
||||
+223
-46
@@ -1,7 +1,20 @@
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { cloneRectangle } from './rectangle.js';
|
||||
import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js';
|
||||
import { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js';
|
||||
import { buildHeatRaster } from '../../data/firmsHeatRaster.js';
|
||||
import {
|
||||
LOD_LEVELS,
|
||||
CONTEXT_TOP_N,
|
||||
HANDOFF_FADE,
|
||||
HEAT_RASTER,
|
||||
HEAT_REBUILD_MS,
|
||||
} from './policy.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,
|
||||
@@ -28,8 +41,11 @@ export function createRendering({
|
||||
!layerState._dataSource ||
|
||||
!layerState._fires.length ||
|
||||
!layerState._viewer
|
||||
)
|
||||
) {
|
||||
// A filter that leaves nothing shown must not leave the old heat behind.
|
||||
if (!layerState._fires.length) layerState._heatImagery?.setShow(false);
|
||||
return false;
|
||||
}
|
||||
const lodIndex = components.viewport.selectLodIndex(
|
||||
components.viewport.cameraHeight(),
|
||||
);
|
||||
@@ -48,14 +64,58 @@ export function createRendering({
|
||||
? components.model.paddedDegreeBounds(viewRect)
|
||||
: null;
|
||||
|
||||
if (lod.mode === 'detections') {
|
||||
renderDetections(lod, bounds);
|
||||
} else {
|
||||
renderCells(aggregateFires(lod, bounds), lod, bounds);
|
||||
}
|
||||
renderBand(lod, bounds);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw one band. On a globe that can drape the heatmap (Cesium), markers
|
||||
* and heatmap stay built in every band and the hand-off only fades
|
||||
* between them. Elsewhere the far bands draw heat cells instead.
|
||||
* @param {Object} lod - Band descriptor.
|
||||
* @param {?Object} bounds - Padded view bounds in degrees, or null.
|
||||
*/
|
||||
|
||||
function renderBand(lod, bounds) {
|
||||
if (!heatImagery()) {
|
||||
if (lod.mode === 'detections') renderDetections(lod, bounds);
|
||||
else renderCells(aggregateFires(lod, bounds), lod, bounds);
|
||||
return;
|
||||
}
|
||||
prepareHeat(lod.mode === 'heat');
|
||||
if (lod.mode === 'detections') renderDetections(lod, bounds);
|
||||
else renderHeat(lod, bounds);
|
||||
applyHandoffFade(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. A
|
||||
* globe without the heatmap keeps its markers as they are.
|
||||
* @param {boolean} [force] Reapply, e.g. to freshly added markers.
|
||||
*/
|
||||
|
||||
function applyHandoffFade(force = false) {
|
||||
if (!layerState._heatImagery) return;
|
||||
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);
|
||||
layerState._renderer?.fadeSprites(
|
||||
1 - heat,
|
||||
layerState._enabled && heat < 1,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -116,10 +176,105 @@ export function createRendering({
|
||||
}
|
||||
|
||||
/**
|
||||
* Render aggregated heat cells (global/regional bands) as ground-clamped
|
||||
* rectangles. Cell labels do NOT live on the entities anymore — they go
|
||||
* through the same unclamped, screen-space-decluttered label pipeline as
|
||||
* detections (see {@link rebuildAmbientLabels}).
|
||||
* The heat imagery adapter, created on first use, or null when the
|
||||
* renderer's globe cannot drape it.
|
||||
*/
|
||||
|
||||
function heatImagery() {
|
||||
layerState._heatImagery ??=
|
||||
layerState._renderer?.createHeatImagery?.(
|
||||
() => layerState._imageryHost?.() ?? null,
|
||||
) ?? null;
|
||||
return layerState._heatImagery;
|
||||
}
|
||||
|
||||
/** Build the heat raster from the shown set and drape it. */
|
||||
|
||||
function rebuildHeatRaster() {
|
||||
clearTimeout(layerState._heatRebuildTimer);
|
||||
layerState._heatRebuildTimer = null;
|
||||
if (layerState._destroyed || !layerState._heatImagery) return;
|
||||
layerState._heatRaster = buildHeatRaster(layerState._fires, HEAT_RASTER);
|
||||
layerState._heatImagery.setRaster(layerState._heatRaster);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(now) {
|
||||
if (layerState._heatRaster) return;
|
||||
if (now && !heatImagery().hasRaster()) {
|
||||
rebuildHeatRaster();
|
||||
return;
|
||||
}
|
||||
clearTimeout(layerState._heatRebuildTimer);
|
||||
layerState._heatRebuildTimer = setTimeout(
|
||||
rebuildHeatRaster,
|
||||
HEAT_REBUILD_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) on a globe with the heatmap: a Snap
|
||||
* Map–style heatmap draped over the globe, transparent where nothing
|
||||
* burns, plus the decluttered "N FIRES" 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.
|
||||
* @param {?Object} bounds - Padded view bounds in degrees, or null.
|
||||
*/
|
||||
|
||||
function renderHeat(lod, bounds) {
|
||||
paintMarkers(HANDOFF_MARKER_LOD, bounds);
|
||||
|
||||
const cells = aggregateFires(lod, bounds);
|
||||
layerState._cellCount = cells.length;
|
||||
layerState._labelCandidates = [];
|
||||
layerState._labelLodDistance = lod.labelDistance;
|
||||
|
||||
// Cells arrive heat-sorted, so candidate order doubles as label priority.
|
||||
for (const cell of cells) {
|
||||
const centerLon = cell.lonCell + lod.gridDegrees / 2;
|
||||
const centerLat = cell.latCell + lod.gridDegrees / 2;
|
||||
layerState._labelCandidates.push({
|
||||
position: geo.cartesian3.fromDegrees(centerLon, centerLat, 0),
|
||||
cullPosition: components.model.cellCullPosition(centerLon, centerLat),
|
||||
fire: null,
|
||||
cell,
|
||||
accent: components.model.cellCardAccent(cell),
|
||||
});
|
||||
}
|
||||
|
||||
components.cards.rebuildAmbientLabels();
|
||||
components.selection.refreshContextRegistrations(
|
||||
components.selection.topFiresWithinBounds(bounds),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Far bands on a globe without the heatmap (Navara): aggregated heat cells
|
||||
* as ground rectangles colored on the FRP ramp by their strongest fire.
|
||||
* Labels go through the same screen-space pipeline as detections.
|
||||
* @param {Array<Object>} cells - Aggregated cells, heat-sorted descending.
|
||||
* @param {Object} lod - Active LOD descriptor.
|
||||
* @param {?Object} bounds - Padded view bounds in degrees, or null.
|
||||
@@ -141,7 +296,9 @@ export function createRendering({
|
||||
const score = components.model.heatScore(cell);
|
||||
const normalized = Math.min(1, Math.sqrt(score / maxScore));
|
||||
const alpha = 0.16 + normalized * 0.5;
|
||||
const color = components.model.heatColor(normalized, alpha);
|
||||
const color = components.model
|
||||
.frpColorStop(cell.maxFrp)
|
||||
.color.withAlpha(alpha);
|
||||
const centerLon = cell.lonCell + lod.gridDegrees / 2;
|
||||
const centerLat = cell.latCell + lod.gridDegrees / 2;
|
||||
const position = geo.cartesian3.fromDegrees(centerLon, centerLat, 0);
|
||||
@@ -162,13 +319,12 @@ export function createRendering({
|
||||
});
|
||||
|
||||
// Cells arrive heat-sorted, so candidate order doubles as label priority.
|
||||
// Accent is resolved here because `normalized` only exists in this walk.
|
||||
layerState._labelCandidates.push({
|
||||
position,
|
||||
cullPosition: components.model.cellCullPosition(centerLon, centerLat),
|
||||
fire: null,
|
||||
cell,
|
||||
accent: components.model.cellAccent(normalized),
|
||||
accent: components.model.cellCardAccent(cell),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -194,9 +350,48 @@ export function createRendering({
|
||||
*/
|
||||
|
||||
function renderDetections(lod, bounds) {
|
||||
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._renderer.clearCells();
|
||||
if (!layerState._billboards) return;
|
||||
if (!layerState._billboards) return null;
|
||||
layerState._billboards.removeAll();
|
||||
layerState._pickIndexById.clear();
|
||||
layerState._cullPositions.length = 0;
|
||||
@@ -218,17 +413,20 @@ export function createRendering({
|
||||
candidates = layerState._firesByFrp.slice(0, lod.maxDetections);
|
||||
}
|
||||
|
||||
layerState._cellCount = candidates.length;
|
||||
layerState._labelCandidates = [];
|
||||
layerState._labelLodDistance = lod.labelDistance;
|
||||
|
||||
const markers = [];
|
||||
// Only the close band warms terrain floors (renderDetections).
|
||||
const anchor =
|
||||
lod.id === 'close'
|
||||
? components.model.firePosition
|
||||
: components.model.fireBuiltPosition;
|
||||
for (const fire of candidates) {
|
||||
const coreSize = components.model.frpPixelSize(fire.frp);
|
||||
const position = components.model.firePosition(fire);
|
||||
const cullPosition = components.model.fireCullPosition(fire);
|
||||
const position = anchor(fire);
|
||||
const cullPosition = components.model.fireCullPosition(fire, position);
|
||||
const pickId = `firms-${fire.index}`;
|
||||
layerState._pickIndexById.set(pickId, fire);
|
||||
layerState._cullPositions.push(cullPosition);
|
||||
markers.push({ position, cullPosition, fire, cell: null });
|
||||
layerState._renderer.addSprite({
|
||||
id: pickId,
|
||||
position,
|
||||
@@ -237,37 +435,13 @@ export function createRendering({
|
||||
components.model.sizeBucket(coreSize),
|
||||
),
|
||||
});
|
||||
// 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() {
|
||||
@@ -319,9 +493,12 @@ export function createRendering({
|
||||
}
|
||||
return {
|
||||
renderCurrentLod,
|
||||
renderBand,
|
||||
aggregateFires,
|
||||
renderHeat,
|
||||
renderCells,
|
||||
renderDetections,
|
||||
applyHandoffFade,
|
||||
ensureDetectionCollections,
|
||||
removeDetectionCollections,
|
||||
fireHorizonOccluder,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { DEFAULT_FIRE_FILTER } from '../../data/firmsIntensity.js';
|
||||
|
||||
export function createFirmsState({ services, config }) {
|
||||
const layerState = {};
|
||||
@@ -41,12 +42,39 @@ export function createFirmsState({ services, config }) {
|
||||
|
||||
layerState._error = null;
|
||||
|
||||
/** Every adapted detection in the current payload, before the filter. */
|
||||
|
||||
layerState._allFires = [];
|
||||
|
||||
/** `_allFires` sorted by FRP descending, once per payload. */
|
||||
|
||||
layerState._allFiresByFrp = [];
|
||||
|
||||
/**
|
||||
* The detections that pass the intensity filter — the ONLY set rendering,
|
||||
* selection, cells, the top-fires query and the analyst records ever walk.
|
||||
*/
|
||||
|
||||
layerState._fires = [];
|
||||
|
||||
layerState._firesByFrp = [];
|
||||
|
||||
/** Shown (filtered) detection count. */
|
||||
|
||||
layerState._count = 0;
|
||||
|
||||
/** Loaded detection count before the filter ("N of M shown"). */
|
||||
|
||||
layerState._totalCount = 0;
|
||||
|
||||
/** Minimum FRP (MW) and confidence mode; see firmsIntensity.js. */
|
||||
|
||||
layerState._filter = { ...DEFAULT_FIRE_FILTER };
|
||||
|
||||
/** Panel callback: repaint the row controls (counter) after a load. */
|
||||
|
||||
layerState._rowControlsListener = null;
|
||||
|
||||
layerState._cellCount = 0;
|
||||
|
||||
layerState._lastUpdate = null;
|
||||
@@ -105,6 +133,29 @@ export function createFirmsState({ services, config }) {
|
||||
|
||||
layerState._cellCacheByGrid = new Map();
|
||||
|
||||
/**
|
||||
* Heat raster for the far bands (firmsHeatRaster.js); null = stale, rebuilt
|
||||
* on the next far render. Cleared with the cell cache on every new shown set.
|
||||
*/
|
||||
|
||||
layerState._heatRaster = null;
|
||||
|
||||
/** Pending debounced heat rebuild (see HEAT_REBUILD_MS). */
|
||||
|
||||
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;
|
||||
|
||||
/** Shell-provided imagery host resolver; falls back to viewer.imageryLayers. */
|
||||
|
||||
layerState._imageryHost = null;
|
||||
|
||||
/** Camera idle snapshot so the throttled LOD check is ~free when parked. */
|
||||
|
||||
layerState._camSnapValid = false;
|
||||
|
||||
@@ -165,6 +165,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;
|
||||
|
||||
@@ -254,6 +254,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' }),
|
||||
traffic: Object.freeze({ roadMode: null }),
|
||||
'weather-lightning': Object.freeze({ opacity: 'strong' }),
|
||||
'weather-radar': Object.freeze({ opacity: 'strong' }),
|
||||
|
||||
@@ -4,6 +4,14 @@ import { suggestView } from '../views.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
|
||||
import {
|
||||
FIRMS_CONFIDENCE_MODES,
|
||||
MIN_FRP_MW_MAX,
|
||||
MIN_FRP_MW_MIN,
|
||||
firePassesFilter,
|
||||
normalizeFireFilter,
|
||||
} from '../../data/firmsIntensity.js';
|
||||
import { normalizeConfidence } from '../../data/firmsAdapt.js';
|
||||
|
||||
function acquiredMsUtc(acqDate, acqTime) {
|
||||
if (typeof acqDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(acqDate))
|
||||
@@ -87,10 +95,30 @@ export const getActiveFires = defineTool({
|
||||
title: 'Active fires',
|
||||
description:
|
||||
'Satellite fire detections from NASA FIRMS in the last 24 hours within an ' +
|
||||
'area, most intense (fire radiative power) first.',
|
||||
'area, most intense (fire radiative power) first. Optionally limited to a ' +
|
||||
'minimum fire radiative power in megawatts and a confidence level — the ' +
|
||||
"same filter as the Active Fires layer's intensity controls.",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
|
||||
properties: {
|
||||
area: AREA_SCHEMA,
|
||||
min_frp_mw: {
|
||||
type: 'number',
|
||||
minimum: MIN_FRP_MW_MIN,
|
||||
maximum: MIN_FRP_MW_MAX,
|
||||
description:
|
||||
'Hide detections weaker than this fire radiative power (MW). ' +
|
||||
'A typical detection is ~6 MW; 20 MW keeps the top tenth, 100 MW the top percent.',
|
||||
},
|
||||
confidence: {
|
||||
type: 'string',
|
||||
enum: [...FIRMS_CONFIDENCE_MODES],
|
||||
description:
|
||||
'"all" (default), "nominal" (nominal and high), or "high". Many of the ' +
|
||||
'strongest fires are rated low confidence, so prefer "all" unless asked.',
|
||||
},
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
@@ -103,9 +131,31 @@ export const getActiveFires = defineTool({
|
||||
'unavailable',
|
||||
'Fire detections need a NASA FIRMS key configured for this server',
|
||||
);
|
||||
const filter = normalizeFireFilter({
|
||||
minFrp: args.min_frp_mw,
|
||||
confidence: args.confidence,
|
||||
});
|
||||
const fires = snapshot.fires
|
||||
.filter((fire) => areaContains(area, fire))
|
||||
.filter(
|
||||
(fire) =>
|
||||
areaContains(area, fire) &&
|
||||
firePassesFilter(
|
||||
{
|
||||
frp: Number(fire.frp),
|
||||
confidence: normalizeConfidence(fire.confidence),
|
||||
},
|
||||
filter,
|
||||
),
|
||||
)
|
||||
.sort((a, b) => (b.frp ?? 0) - (a.frp ?? 0));
|
||||
const filterNotes = [
|
||||
...(filter.minFrp > 0 ? [`≥ ${filter.minFrp} MW`] : []),
|
||||
...(filter.confidence !== 'all'
|
||||
? [
|
||||
`${filter.confidence === 'high' ? 'high' : 'nominal or high'} confidence`,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const result = capRows(fires, args.limit);
|
||||
// The proxy serves its last good snapshot when a refresh fails, and marks
|
||||
// satellites whose download failed.
|
||||
@@ -119,10 +169,16 @@ export const getActiveFires = defineTool({
|
||||
];
|
||||
return {
|
||||
summary:
|
||||
`${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours` +
|
||||
`${countNoun(fires.length, 'fire detection')}` +
|
||||
(filterNotes.length ? ` (${filterNotes.join(', ')})` : '') +
|
||||
` in ${area.label} in the last 24 hours` +
|
||||
(notes.length ? ` (${notes.join('; ')}).` : '.'),
|
||||
data: {
|
||||
view: suggestView(services, { area, layers: ['local-firms'] }),
|
||||
filter: {
|
||||
min_frp_mw: filter.minFrp,
|
||||
confidence: filter.confidence,
|
||||
},
|
||||
stale,
|
||||
missing_sources: missing,
|
||||
fetched_at: isoTime(snapshot.fetchedAt),
|
||||
|
||||
@@ -44,3 +44,39 @@ test('fire acquisition timestamps preserve valid leap days and UTC times', async
|
||||
assert.equal(result.data.rows[0].acquired, expected);
|
||||
}
|
||||
});
|
||||
|
||||
const WEEK = [
|
||||
{ lat: 24.1, lon: 121.1, frp: 864, confidence: 'l', acqDate: '2026-10-01', acqTime: '0100' },
|
||||
{ lat: 24.2, lon: 121.2, frp: 120, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' },
|
||||
{ lat: 24.3, lon: 121.3, frp: 20, confidence: 'n', acqDate: '2026-10-01', acqTime: '0100' },
|
||||
{ lat: 24.4, lon: 121.4, frp: 2, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' },
|
||||
{ lat: 30, lon: 100, frp: 500, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' },
|
||||
];
|
||||
const TAIWAN = { area: { bbox: [120, 23, 122, 25] } };
|
||||
|
||||
test('get_active_fires shows every detection by default and reports the filter it used', async () => {
|
||||
const result = await catalogWith(WEEK).call('get_active_fires', TAIWAN);
|
||||
assert.deepEqual(result.data.rows.map((row) => row.frp_mw), [864, 120, 20, 2]);
|
||||
assert.deepEqual(result.data.filter, { min_frp_mw: 0, confidence: 'all' });
|
||||
assert.match(result.summary, /^4 fire detections in /);
|
||||
});
|
||||
|
||||
test('get_active_fires applies the same minimum-FRP and confidence filter as the layer', async () => {
|
||||
const catalog = catalogWith(WEEK);
|
||||
const strong = await catalog.call('get_active_fires', { ...TAIWAN, min_frp_mw: 20 });
|
||||
assert.deepEqual(strong.data.rows.map((row) => row.frp_mw), [864, 120, 20]);
|
||||
assert.match(strong.summary, /^3 fire detections \(≥ 20 MW\) in /);
|
||||
|
||||
const nominal = await catalog.call('get_active_fires', { ...TAIWAN, confidence: 'nominal' });
|
||||
assert.deepEqual(nominal.data.rows.map((row) => row.frp_mw), [120, 20, 2], 'the 864 MW low-confidence fire drops');
|
||||
assert.match(nominal.summary, /\(nominal or high confidence\)/);
|
||||
|
||||
const both = await catalog.call('get_active_fires', {
|
||||
...TAIWAN,
|
||||
min_frp_mw: 19.6,
|
||||
confidence: 'high',
|
||||
});
|
||||
assert.deepEqual(both.data.rows.map((row) => row.frp_mw), [120]);
|
||||
assert.deepEqual(both.data.filter, { min_frp_mw: 20, confidence: 'high' });
|
||||
assert.match(both.summary, /\(≥ 20 MW, high confidence\)/);
|
||||
});
|
||||
@@ -117,3 +117,18 @@ test('a radio group moves from the focused option, an arrows group from the sele
|
||||
assert.deepEqual(arrows.emitted('update:modelValue'), [['a']]);
|
||||
arrows.unmount();
|
||||
});
|
||||
|
||||
test("an option's own tip wins; an icon option without one is titled by its label", () => {
|
||||
const wrapper = mountGroup({
|
||||
options: [
|
||||
{ value: 'a', label: 'Alpha', attrs: { title: 'Alpha tip' } },
|
||||
{ value: 'b', label: 'Beta', icon: 'star' },
|
||||
{ value: 'c', label: 'Gamma' },
|
||||
],
|
||||
});
|
||||
const titles = wrapper
|
||||
.findAll('button')
|
||||
.map((b) => b.attributes('title') ?? null);
|
||||
assert.deepEqual(titles, ['Alpha tip', 'Beta', null]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
@@ -8,7 +8,8 @@ import { computed } from 'vue';
|
||||
// End). Each option may carry `attrs` (an id, data attributes, a tip), and
|
||||
// Core's classes sit beside the kit's: `gev-segmented`, `gev-segment` and the
|
||||
// selected option's `activeClass`. An option with an `icon` shows the glyph,
|
||||
// and its label becomes the button's accessible name and tooltip.
|
||||
// and its label becomes the button's accessible name and tooltip, unless its
|
||||
// `attrs` carry a tip of their own.
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: undefined },
|
||||
/** `{value, label, icon?, disabled?, attrs?}`, labels already translated. */
|
||||
@@ -119,7 +120,7 @@ function onKey(event) {
|
||||
]"
|
||||
:aria-checked="String(option.value === props.modelValue)"
|
||||
:aria-label="option.icon ? option.label : undefined"
|
||||
:title="option.icon ? option.label : undefined"
|
||||
:title="option.attrs?.title ?? (option.icon ? option.label : undefined)"
|
||||
:disabled="optionOff(option)"
|
||||
:tabindex="tabindex(index)"
|
||||
@click="choose(index)"
|
||||
|
||||
@@ -148,6 +148,12 @@ export class LayerBindings {
|
||||
});
|
||||
this._weatherShellModules.push(layer);
|
||||
}
|
||||
// Active Fires drapes its far-zoom heatmap on the same imagery host.
|
||||
const fires = this._dataManager?.layers?.get('local-firms')?.module;
|
||||
if (typeof fires?.attachShellServices === 'function') {
|
||||
fires.attachShellServices({ imageryHost: this.services.imageryHost });
|
||||
this._weatherShellModules.push(fires);
|
||||
}
|
||||
}
|
||||
|
||||
_persistAwarenessSelection(event, cleared = false) {
|
||||
|
||||
+35
-5
@@ -455,6 +455,8 @@ export class LayerPanel {
|
||||
this._selectChip(layerId, chipId, generation),
|
||||
onListItem: (layerId, itemId) =>
|
||||
this._selectListItem(layerId, itemId, generation),
|
||||
onSlider: (layerId, sliderId, value) =>
|
||||
this._moveSlider(layerId, sliderId, value, generation),
|
||||
}),
|
||||
id === null ? this._toggleContainer : this._groupContainers.get(id),
|
||||
);
|
||||
@@ -484,7 +486,8 @@ export class LayerPanel {
|
||||
}
|
||||
|
||||
/**
|
||||
* A layer's row chips, color legend, info line and ordered list. The block
|
||||
* 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
|
||||
* keyed, so the refresh a chip's own click triggers keeps keyboard focus.
|
||||
@@ -508,11 +511,20 @@ export class LayerPanel {
|
||||
: `${item.label} ${formatCount(item.count)}`,
|
||||
}));
|
||||
const infoText = controls?.info ? String(controls.info) : '';
|
||||
const segmented = controls?.segmented || null;
|
||||
const slider = controls?.slider || null;
|
||||
// The info line, once shown, stays in place and hides when empty.
|
||||
if (infoText) row.info = true;
|
||||
return {
|
||||
hidden: chips.length === 0 && legend.length === 0 && !infoText,
|
||||
hidden:
|
||||
chips.length === 0 &&
|
||||
legend.length === 0 &&
|
||||
!infoText &&
|
||||
!segmented &&
|
||||
!slider,
|
||||
chips,
|
||||
segmented,
|
||||
slider,
|
||||
legend,
|
||||
info: row.info
|
||||
? { text: infoText, title: controls?.infoTitle || '' }
|
||||
@@ -550,15 +562,33 @@ export class LayerPanel {
|
||||
// listeners, so a click from a released generation is dropped.
|
||||
_selectChip(id, chipId, generation = this._generation) {
|
||||
if (this._destroyed || this._generation !== generation) return;
|
||||
const chip = this._rowControlsFor(id)?.chips?.find(
|
||||
(entry) => entry.id === chipId,
|
||||
);
|
||||
// A switch option carries the same fields as a chip.
|
||||
const live = this._rowControlsFor(id);
|
||||
const chip = [
|
||||
...(live?.chips || []),
|
||||
...(live?.segmented?.options || []),
|
||||
].find((entry) => entry.id === chipId);
|
||||
if (!chip || chip.disabled || !this.isEnabled(id)) return;
|
||||
if (typeof chip.onClick === 'function') chip.onClick();
|
||||
else if (chip.params)
|
||||
this.setLayerParams(id, chip.params, { origin: 'user' });
|
||||
}
|
||||
|
||||
// A row slider (e.g. the Active Fires minimum-FRP filter) maps its position
|
||||
// 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)
|
||||
)
|
||||
return;
|
||||
this.setLayerParams(id, slider.params(value), { origin: 'user' });
|
||||
}
|
||||
|
||||
_selectListItem(id, itemId, generation = this._generation) {
|
||||
if (this._destroyed || this._generation !== generation) return;
|
||||
const item = this._rowControlsFor(id)?.list?.items?.find(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import RailChips from '../rail/RailChips.vue';
|
||||
import RailRowList from '../rail/RailRowList.vue';
|
||||
import RowSlider from './RowSlider.vue';
|
||||
import RowSwitch from './RowSwitch.vue';
|
||||
import RowText from './RowText.vue';
|
||||
import { vClassToggle } from '../rail/classToggle.js';
|
||||
|
||||
@@ -12,6 +14,7 @@ defineProps({
|
||||
onToggle: { type: Function, default: () => {} },
|
||||
onChip: { type: Function, default: () => {} },
|
||||
onListItem: { type: Function, default: () => {} },
|
||||
onSlider: { type: Function, default: () => {} },
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -55,6 +58,16 @@ defineProps({
|
||||
><RailChips
|
||||
:chips="item.controls.chips"
|
||||
:on-select="(chip) => onChip(item.id, chip.id)"
|
||||
/><RowSwitch
|
||||
v-if="item.controls.segmented"
|
||||
: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)
|
||||
"
|
||||
/><span
|
||||
v-for="(entry, index) in item.controls.legend"
|
||||
:key="index"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
import UiSlider from '../kit/UiSlider.js';
|
||||
|
||||
// A row's quantitative slider (e.g. the Active Fires minimum-FRP filter).
|
||||
// `track` is a CSS background painting the runnable track, so a color ramp
|
||||
// doubles as the legend; each tick sits at `at` (0..1) along it. The input
|
||||
// holds its position while focused, so a refresh from its own tick never
|
||||
// moves the thumb under the pointer.
|
||||
defineProps({
|
||||
slider: { type: Object, required: true },
|
||||
onInput: { type: Function, default: () => {} },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="data-toggle-slider" :data-slider-id="slider.id"
|
||||
><div class="data-toggle-slider-head"
|
||||
><span class="data-toggle-slider-label">{{ slider.label }}</span
|
||||
><span class="data-toggle-slider-value gev-slider-value">{{
|
||||
slider.valueText
|
||||
}}</span></div
|
||||
><UiSlider
|
||||
class="data-toggle-slider-input"
|
||||
:data-slider-id="slider.id"
|
||||
:min="slider.min"
|
||||
:max="slider.max"
|
||||
:step="slider.step ?? 1"
|
||||
:model-value="slider.value"
|
||||
hold
|
||||
:aria-label="slider.ariaLabel || undefined"
|
||||
:aria-valuetext="slider.valueText || undefined"
|
||||
:style="slider.track ? { '--slider-track': slider.track } : undefined"
|
||||
@update:model-value="onInput"
|
||||
/><div class="data-toggle-slider-ticks"
|
||||
><span
|
||||
v-for="tick in slider.ticks || []"
|
||||
:key="tick.at"
|
||||
class="data-toggle-slider-tick"
|
||||
:style="{ '--at': `${(tick.at * 100).toFixed(2)}%` }"
|
||||
>{{ tick.label }}</span
|
||||
></div
|
||||
></div
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import UiSegmented from '../kit/UiSegmented.vue';
|
||||
|
||||
// A row's one-of-many switch (e.g. Active Fires confidence). Options carry
|
||||
// the same fields as chips, so the owner resolves a choice the way it
|
||||
// resolves a chip; `label` is an optional caption beside the group.
|
||||
defineProps({
|
||||
segmented: { type: Object, required: true },
|
||||
onSelect: { type: Function, default: () => {} },
|
||||
});
|
||||
|
||||
const options = (segmented) =>
|
||||
(segmented.options || []).map((option) => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
disabled: Boolean(option.disabled),
|
||||
attrs: { 'data-chip-id': option.id, title: option.title || undefined },
|
||||
}));
|
||||
const activeId = (segmented) =>
|
||||
(segmented.options || []).find((option) => option.active)?.id;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="segmented.label"
|
||||
class="data-toggle-segmented-label"
|
||||
:data-segmented-caption-for="segmented.id"
|
||||
>{{ segmented.label }}</span
|
||||
><UiSegmented
|
||||
class="data-toggle-segmented"
|
||||
:data-segmented-id="segmented.id"
|
||||
:model-value="activeId(segmented)"
|
||||
:options="options(segmented)"
|
||||
:label="segmented.ariaLabel || segmented.label || ''"
|
||||
item-class="data-toggle-segment"
|
||||
@update:model-value="onSelect"
|
||||
/>
|
||||
</template>
|
||||
@@ -39,8 +39,123 @@ test('the count-line class toggles without dropping the voice highlight', () =>
|
||||
assert.equal(element.classList.contains('gev-voice-focus'), true);
|
||||
|
||||
render(h(LayerRows, { items: [row(false)] }), container);
|
||||
assert.equal(element.classList.contains('data-toggle-row--count-line'), false);
|
||||
assert.equal(
|
||||
element.classList.contains('data-toggle-row--count-line'),
|
||||
false,
|
||||
);
|
||||
assert.equal(element.classList.contains('gev-voice-focus'), true);
|
||||
assert.equal(element.classList.contains('data-toggle-row'), true);
|
||||
render(null, container);
|
||||
});
|
||||
|
||||
const fireRow = (value, active = 'all') => ({
|
||||
...row(false),
|
||||
key: 'fires',
|
||||
id: 'fires',
|
||||
controls: {
|
||||
hidden: false,
|
||||
chips: [],
|
||||
segmented: {
|
||||
id: 'confidence',
|
||||
label: 'Confidence',
|
||||
ariaLabel: 'Detection confidence',
|
||||
options: ['all', 'high'].map((mode) => ({
|
||||
id: `confidence-${mode}`,
|
||||
label: mode.toUpperCase(),
|
||||
title: `${mode} title`,
|
||||
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' },
|
||||
],
|
||||
},
|
||||
legend: [],
|
||||
info: null,
|
||||
list: null,
|
||||
},
|
||||
});
|
||||
|
||||
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 = [];
|
||||
const moves = [];
|
||||
const props = {
|
||||
onChip: (id, chipId) => chips.push([id, chipId]),
|
||||
onSlider: (id, sliderId, value) => moves.push([id, sliderId, value]),
|
||||
};
|
||||
render(h(LayerRows, { items: [fireRow(0)], ...props }), container);
|
||||
|
||||
const caption = container.querySelector('.data-toggle-segmented-label');
|
||||
assert.equal(caption.textContent, 'Confidence');
|
||||
const group = container.querySelector('.gev-segmented.data-toggle-segmented');
|
||||
assert.equal(group.getAttribute('role'), 'radiogroup');
|
||||
assert.equal(group.getAttribute('aria-label'), 'Detection confidence');
|
||||
const segments = [
|
||||
...group.querySelectorAll('.gev-segment.data-toggle-segment'),
|
||||
];
|
||||
assert.deepEqual(
|
||||
segments.map((button) => button.dataset.chipId),
|
||||
['confidence-all', 'confidence-high'],
|
||||
);
|
||||
assert.equal(segments[0].classList.contains('active'), true);
|
||||
assert.equal(segments[1].title, 'high title');
|
||||
segments[1].click();
|
||||
assert.deepEqual(chips, [['fires', 'confidence-high']]);
|
||||
|
||||
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)',
|
||||
);
|
||||
assert.equal(
|
||||
block.querySelector('.data-toggle-slider-value').textContent,
|
||||
'All',
|
||||
);
|
||||
const input = block.querySelector('input.data-toggle-slider-input');
|
||||
assert.equal(input.classList.contains('gev-quantitative-slider'), true);
|
||||
assert.equal(input.type, 'range');
|
||||
assert.equal(input.max, '1000');
|
||||
assert.equal(
|
||||
input.getAttribute('aria-label'),
|
||||
'Minimum fire radiative power, megawatts',
|
||||
);
|
||||
assert.equal(input.getAttribute('aria-valuetext'), 'All');
|
||||
assert.match(
|
||||
input.style.getPropertyValue('--slider-track'),
|
||||
/^linear-gradient/,
|
||||
);
|
||||
const ticks = [...block.querySelectorAll('.data-toggle-slider-tick')];
|
||||
assert.deepEqual(
|
||||
ticks.map((tick) => tick.textContent),
|
||||
['0', '30'],
|
||||
);
|
||||
assert.equal(ticks[1].style.getPropertyValue('--at'), '50.00%');
|
||||
|
||||
input.value = '120';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
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);
|
||||
assert.equal(
|
||||
container.querySelector('input.data-toggle-slider-input'),
|
||||
input,
|
||||
);
|
||||
assert.equal(input.getAttribute('aria-valuetext'), '≥ 120 MW');
|
||||
assert.equal(segments[1].classList.contains('active'), true);
|
||||
render(null, container);
|
||||
});
|
||||
@@ -1042,6 +1042,118 @@ html:not([lang='en']) .data-row-list-text {
|
||||
padding-top: 3px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Optional per-layer one-of-many switch (Active Fires confidence). The shared
|
||||
.gev-segmented surface, sized to the chip row. */
|
||||
.data-toggle-controls .data-toggle-segmented {
|
||||
flex-basis: 100%;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.data-toggle-segmented-label {
|
||||
flex-basis: 100%;
|
||||
padding-top: 2px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.2px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.data-toggle-controls .data-toggle-segment {
|
||||
min-height: 20px;
|
||||
padding: 3px 6px;
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
|
||||
/* Optional per-layer quantitative slider (Active Fires minimum FRP). The
|
||||
track is painted by the layer (`--slider-track`, a stepped color ramp), so
|
||||
the slider doubles as the marker legend; ticks sit under their thresholds. */
|
||||
.data-toggle-slider {
|
||||
flex-basis: 100%;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.data-toggle-slider-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.2px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.data-toggle-slider-value {
|
||||
min-width: 0;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.data-toggle-slider-input {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.data-toggle-slider-input::-webkit-slider-runnable-track {
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--slider-track, rgba(255, 255, 255, 0.08));
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.data-toggle-slider-input::-moz-range-track,
|
||||
.data-toggle-slider-input::-moz-range-progress {
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--slider-track, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.data-toggle-slider-input::-webkit-slider-thumb {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
margin-top: -3px;
|
||||
background: var(--text-primary);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.6),
|
||||
0 0 6px rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.data-toggle-slider-input::-moz-range-thumb {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
background: var(--text-primary);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.6),
|
||||
0 0 6px rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.data-toggle-slider-ticks {
|
||||
position: relative;
|
||||
height: 10px;
|
||||
margin: 0 5px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 7px;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.data-toggle-slider-tick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: var(--at);
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-toggle-slider-tick:first-child {
|
||||
transform: none;
|
||||
}
|
||||
/* ── Local RTL-SDR card (FM + Local ADS-B receiver) ── */
|
||||
.sdr-radio-card {
|
||||
display: grid;
|
||||
|
||||
Reference in new issue
Block a user