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