Extract the native FIRMS renderer and own its settle timer

Keep fire normalization, LOD, anchors, cards and selection portable while the native renderer owns ground cells, sprites, input and scene disposal. Cancel superseded/removed settle timers and unregister the original sprite collection.

Preserve all ten native raster pairs exactly in dev and preview; record source/click/focus/lifecycle, legacy Metal and full regression evidence with strict comparator sensitivity checks.
This commit is contained in:
German Khokhlov committed 2026-10-27 14:40:08 +04:00
1 parent a3152ab719
commit a9b3125a99
25 files changed
+672 -123

No files matched your search

+1
View File
@@ -35,6 +35,7 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
// Navara only in render/navara*.js. The list grows with each moved layer. // Navara only in render/navara*.js. The list grows with each moved layer.
const RENDERED_LAYER_DIRS = [ const RENDERED_LAYER_DIRS = [
'src/layers/earthquakes/', 'src/layers/earthquakes/',
'src/layers/firms/',
'src/layers/flights/', 'src/layers/flights/',
'src/layers/military/', 'src/layers/military/',
'src/layers/localAdsb/', 'src/layers/localAdsb/',
+57
View File
@@ -0,0 +1,57 @@
// Real /api/firms envelope; fixed source receipt and acquisition clocks.
export const FIRMS_EPOCH_MS = Date.UTC(2026, 9, 16, 12);
export const FIRMS_VALID_COUNT = 371;
export function firmsFixture() {
const fires = [];
const regions = [
[37.77, -122.42],
[30.27, -97.74],
[61.2, -149.9],
];
for (const [region, [latitude, longitude]] of regions.entries()) {
for (let row = -5; row <= 5; row++) {
for (let column = -5; column <= 5; column++) {
const index = fires.length;
fires.push({
lat: latitude + row * 0.12,
lon: longitude + column * 0.12,
frp:
region === 0 && row === 0 && column === 0 ? 999 : 5 + (index % 120),
confidence: ['low', 'nominal', 'high', 85][index % 4],
brightness: 280 + (index % 90),
daynight: index % 2 ? 'N' : 'D',
acqDate: '2026-10-16',
acqTime: String(800 + (index % 60)),
instrument: index % 4 === 3 ? 'MODIS' : 'VIIRS',
satellite: index % 4 === 3 ? 'Terra' : 'NOAA-20',
});
}
}
}
for (const [lat, lon] of [
[10, 179.7],
[10, -179.7],
[89.5, 45],
[-89.5, -135],
[0.2, 179.8],
[-0.2, -179.8],
[70, 179.5],
[-70, -179.5],
]) {
fires.push({
lat,
lon,
frp: 30,
confidence: 'high',
brightness: 320,
daynight: 'D',
acqDate: '2026-10-16',
acqTime: '930',
instrument: 'VIIRS',
satellite: 'NOAA-20',
});
}
fires.push({ lat: 'invalid', lon: 0 }, { lat: 0, lon: 'invalid' });
return { fires, fetchedAt: FIRMS_EPOCH_MS - 120000, stale: false };
}
+8 -1
View File
@@ -1194,5 +1194,12 @@
"src/bench/w2GroundProbe.js", "src/bench/w2GroundProbe.js",
"scripts/qa-w2-ground-mesh.mjs", "scripts/qa-w2-ground-mesh.mjs",
"scripts/qa-w2-ground-perf.mjs", "scripts/qa-w2-ground-perf.mjs",
"scripts/qa-w2-ground-equivalence.mjs" "scripts/qa-w2-ground-equivalence.mjs",
"src/layers/firms/render/cesium.js",
"src/layers/firms/render/cesium.test.mjs",
"src/layers/firms/rectangle.js",
"src/layers/firms/rectangle.test.mjs",
"src/layers/firms/viewport.test.mjs",
"scripts/qa-w2-firms.mjs",
"scripts/fixtures/w2/firms.mjs"
] ]
+9 -7
View File
@@ -1113,8 +1113,6 @@
"modules": [ "modules": [
"src/data/firmsAdapt.js", "src/data/firmsAdapt.js",
"src/data/firmsLabels.js", "src/data/firmsLabels.js",
"src/data/iconOrientation.js",
"src/data/render/cesiumProjection.js",
"src/layers/firms/anchors.js", "src/layers/firms/anchors.js",
"src/layers/firms/cards.js", "src/layers/firms/cards.js",
"src/layers/firms/index.js", "src/layers/firms/index.js",
@@ -1145,10 +1143,10 @@
"src/geo/check.js", "src/geo/check.js",
"src/geo/debug.js", "src/geo/debug.js",
"src/geo/developerError.js", "src/geo/developerError.js",
"src/globe/contract.js", "src/layers/firms/rectangle.js",
"src/globe/cesium/engineFromViewer.js" "src/layers/shared/color.js"
], ],
"external": ["cesium"] "external": []
}, },
"satellites-layer": { "satellites-layer": {
"exports": ["./layers/satellites"], "exports": ["./layers/satellites"],
@@ -2698,7 +2696,9 @@
"src/layers/earthquakes/render/cesium.js", "src/layers/earthquakes/render/cesium.js",
"src/layers/earthquakes/render/navara.js", "src/layers/earthquakes/render/navara.js",
"src/layers/earthquakes/render/navaraGeometry.js", "src/layers/earthquakes/render/navaraGeometry.js",
"src/layers/earthquakes/render/navaraGrid.js" "src/layers/earthquakes/render/navaraGrid.js",
"src/layers/firms/rectangle.js",
"src/layers/firms/render/cesium.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -3291,7 +3291,9 @@
"src/celestialRing/view.js", "src/celestialRing/view.js",
"src/layers/shared/render/engineView.js", "src/layers/shared/render/engineView.js",
"src/layers/satellites/render/cesium.js", "src/layers/satellites/render/cesium.js",
"src/layers/earthquakes/render/cesium.js" "src/layers/earthquakes/render/cesium.js",
"src/layers/firms/rectangle.js",
"src/layers/firms/render/cesium.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
+29 -1
View File
@@ -30,6 +30,7 @@
*/ */
import puppeteer from 'puppeteer'; import puppeteer from 'puppeteer';
import { redact } from './qa-browserEvidence.mjs';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -46,6 +47,11 @@ const getOpt = (name, dflt) => {
const APP_URL = getOpt('--url', 'http://localhost:4420'); const APP_URL = getOpt('--url', 'http://localhost:4420');
const HEADFUL = argv.includes('--headful'); const HEADFUL = argv.includes('--headful');
const FIXTURES = argv.includes('--fixtures'); const FIXTURES = argv.includes('--fixtures');
const ANGLE = getOpt('--angle', 'swiftshader');
const OUT = getOpt('--out', null);
if (!['metal', 'swiftshader'].includes(ANGLE)) throw new Error('Unknown ANGLE backend');
const runtimeErrors = [];
const expectedSourceErrors = [];
const CHROME_EXECUTABLE_CANDIDATES = [ const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH, process.env.PUPPETEER_EXECUTABLE_PATH,
@@ -247,7 +253,7 @@ async function main() {
headless: HEADFUL ? false : 'new', headless: HEADFUL ? false : 'new',
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}), ...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
args: [ args: [
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader', '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', `--use-angle=${ANGLE}`,
'--disable-dev-shm-usage', '--disable-web-security', '--disable-dev-shm-usage', '--disable-web-security',
'--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-background-timer-throttling', '--disable-renderer-backgrounding',
'--window-size=1440,900', '--window-size=1440,900',
@@ -261,6 +267,19 @@ async function main() {
await page.setRequestInterception(true); await page.setRequestInterception(true);
let interceptMode = FIXTURES ? 'fixture' : 'live'; let interceptMode = FIXTURES ? 'fixture' : 'live';
page.on('pageerror', error => runtimeErrors.push(redact(error.message)));
page.on('error', error => runtimeErrors.push(redact(error.message)));
page.on('console', message => {
if (message.type() !== 'error') return;
let expected = false;
try {
const url = new URL(message.location()?.url);
expected = interceptMode === 'keyless' && url.origin === new URL(APP_URL).origin
&& url.pathname === '/api/firms' && /^Failed to load resource:.*\b503\b/.test(message.text());
} catch { /* an unknown location is never waived */ }
if (expected) expectedSourceErrors.push('/api/firms: intentional keyless fixture503');
else runtimeErrors.push(redact(message.text()));
});
const stableStalePayload = stalePayload(); const stableStalePayload = stalePayload();
page.on('request', (req) => { page.on('request', (req) => {
const url = req.url(); const url = req.url();
@@ -509,12 +528,21 @@ async function main() {
await browser.close(); await browser.close();
} }
record('RUNTIME: no unexpected browser errors', runtimeErrors.length === 0,
runtimeErrors.length ? JSON.stringify(runtimeErrors) : `expected source errors=${expectedSourceErrors.length}`);
if (runtimeErrors.length) exitCode = 1;
const pass = results.filter((r) => r.ok === true).length; const pass = results.filter((r) => r.ok === true).length;
const fail = results.filter((r) => r.ok === false).length; const fail = results.filter((r) => r.ok === false).length;
console.log('\n' + '─'.repeat(60)); console.log('\n' + '─'.repeat(60));
console.log(` RESULT: ${pass} passed, ${fail} failed`); console.log(` RESULT: ${pass} passed, ${fail} failed`);
console.log(` Shots : ${SHOTS_DIR}/firms-*.png`); console.log(` Shots : ${SHOTS_DIR}/firms-*.png`);
console.log('─'.repeat(60) + '\n'); console.log('─'.repeat(60) + '\n');
if (OUT) {
const file = path.resolve(OUT);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ pass:exitCode === 0 && fail === 0, fixture:FIXTURES, angle:ANGLE,
results, browserErrors:runtimeErrors, expectedSourceErrors }, null, 2) + '\n');
}
process.exit(exitCode || (fail > 0 ? 1 : 0)); process.exit(exitCode || (fail > 0 ? 1 : 0));
} }
+16
View File
@@ -1,4 +1,11 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { rendererFor } from './renderers.js';
import {
createFirmsCesiumRenderer,
projectFirmsWindow,
firmsHorizonOccluder,
FIRMS_LEFT_CLICK,
} from '../../layers/firms/render/cesium.js';
import { createFirmsHeatmapLayer as createLayer } from '../../layers/firms/index.js'; import { createFirmsHeatmapLayer as createLayer } from '../../layers/firms/index.js';
import * as render from '../../renderGovernor.js'; import * as render from '../../renderGovernor.js';
import * as sprites from '../../data/spriteOrder.js'; import * as sprites from '../../data/spriteOrder.js';
@@ -23,6 +30,15 @@ export function createApplicationFirms({ surface, ...options }) {
source: options.source ?? 'NASA FIRMS', source: options.source ?? 'NASA FIRMS',
feed: options.feed, feed: options.feed,
services: { ...firmsServices, anchors: surface.anchors }, services: { ...firmsServices, anchors: surface.anchors },
renderer: options.renderer ?? {
create: ({ engine = null, ...renderOptions }) =>
engine
? rendererFor('local-firms', engine, renderOptions)
: createFirmsCesiumRenderer(null, renderOptions),
},
projectToWindow: options.projectToWindow ?? projectFirmsWindow,
horizonOccluder: options.horizonOccluder ?? firmsHorizonOccluder,
leftClickEventType: options.leftClickEventType ?? FIRMS_LEFT_CLICK,
overlayHost: options.overlayHost ?? { overlayHost: options.overlayHost ?? {
setEntries: overlays.setOverlayEntries, setEntries: overlays.setOverlayEntries,
setVisible: overlays.setOverlaySourceVisible, setVisible: overlays.setOverlaySourceVisible,
+2
View File
@@ -1,3 +1,4 @@
import { createFirmsCesiumRenderer } from '../../layers/firms/render/cesium.js';
import { createEarthquakesCesiumRenderer } from '../../layers/earthquakes/render/cesium.js'; import { createEarthquakesCesiumRenderer } from '../../layers/earthquakes/render/cesium.js';
import { createLaunchesCesiumRenderer } from '../../layers/launches/render/cesium.js'; import { createLaunchesCesiumRenderer } from '../../layers/launches/render/cesium.js';
// Which engine draws which layer: the layer renderer registry (plan, "layer // Which engine draws which layer: the layer renderer registry (plan, "layer
@@ -91,6 +92,7 @@ export const layerAvailableOn = (layerId, engineKind) =>
/** Cesium renderers extracted from their layers (render/cesium.js). */ /** Cesium renderers extracted from their layers (render/cesium.js). */
export const CESIUM_LAYER_RENDERERS = Object.freeze({ export const CESIUM_LAYER_RENDERERS = Object.freeze({
earthquakes: createEarthquakesCesiumRenderer, earthquakes: createEarthquakesCesiumRenderer,
'local-firms': createFirmsCesiumRenderer,
'rocket-launches': createLaunchesCesiumRenderer, 'rocket-launches': createLaunchesCesiumRenderer,
satellites: createSatellitesCesiumRenderer, satellites: createSatellitesCesiumRenderer,
flights: createFlightsCesiumRenderer, flights: createFlightsCesiumRenderer,
+1
View File
@@ -59,6 +59,7 @@ test('the default registry: every layer on Cesium, none yet on Navara', () => {
test('the aircraft, vessels, satellites, launches and earthquakes have extracted Cesium renderers', () => { test('the aircraft, vessels, satellites, launches and earthquakes have extracted Cesium renderers', () => {
assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), [ assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), [
'earthquakes', 'earthquakes',
'local-firms',
'rocket-launches', 'rocket-launches',
'satellites', 'satellites',
'flights', 'flights',
+3 -1
View File
@@ -406,9 +406,11 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
'vessel focus helper', 'vessel focus helper',
); );
assert.match(vesselFocus, /requestWorldFocus\(\{/); assert.match(vesselFocus, /requestWorldFocus\(\{/);
assert.match(firms, /const clickType\s*=\s*layerState\._renderer\?\.clickType\s*\?\?\s*config\.leftClickEventType/);
assert.match(firms, /FIRMS_LEFT_CLICK = Cesium\.ScreenSpaceEventType\.LEFT_CLICK/);
const fireClick = body( const fireClick = body(
firms, firms,
/_clickHandler\.setInputAction\(\(click\) => \{([\s\S]*?)\n \}, Cesium\.ScreenSpaceEventType\.LEFT_CLICK\);/, /_clickHandler\.setInputAction\(\(click\) => \{([\s\S]*?)\n \}, clickType\);/,
'fire click', 'fire click',
); );
ordered(fireClick, [ ordered(fireClick, [
+8
View File
@@ -76,6 +76,8 @@ function harness({
const viewer = { const viewer = {
scene: { scene: {
pick: () => picked, pick: () => picked,
frameState: { mode: Cesium.SceneMode.SCENE3D },
primitives: new Cesium.PrimitiveCollection(),
canvas: { clientWidth: 1280, clientHeight: 800 }, canvas: { clientWidth: 1280, clientHeight: 800 },
camera: cameraPosition ? { positionWC: cameraPosition } : undefined, camera: cameraPosition ? { positionWC: cameraPosition } : undefined,
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
@@ -99,6 +101,7 @@ function harness({
fires, fires,
click: () => handler.click({ position: { x: 400, y: 300 } }), click: () => handler.click({ position: { x: 400, y: 300 } }),
cleanup() { cleanup() {
layer.destroy();
Cesium.SceneTransforms.worldToWindowCoordinates = priorProjection; Cesium.SceneTransforms.worldToWindowCoordinates = priorProjection;
if (hadWindow) globalThis.window = priorWindow; if (hadWindow) globalThis.window = priorWindow;
else delete globalThis.window; else delete globalThis.window;
@@ -332,6 +335,11 @@ test('a refresh that drops the selected fire emits an eviction the readout can a
}), }),
}); });
await h.layer.update(); await h.layer.update();
assert.equal(
h.layer.getStats().error,
null,
'the refreshed geometry must succeed too',
);
assert.equal( assert.equal(
cleared.length, cleared.length,
+16 -4
View File
@@ -1,4 +1,3 @@
import { createRendering as createFirmsRendering } from '../layers/firms/rendering.js';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
@@ -124,10 +123,23 @@ test('flights, AIS, and FIRMS enable paths are wired through the shared sprite r
/spriteLayer: 'flights'/, /spriteLayer: 'flights'/,
); );
assert.match(aisLiveVesselsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('ais', activeViewer\)/); assert.match(aisLiveVesselsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('ais', activeViewer\)/);
assert.match(firmsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('firms', viewer\)/);
assert.match( assert.match(
createFirmsRendering.toString(), firmsLayer.enable.toString(),
/registerSpriteCollection\('firms', layerState\._billboards\);\s*restoreSpriteOrder\(layerState\._viewer\);/, /renderer\.onEnable\(\)/,
);
assert.match(
readFileSync(
new URL('../layers/firms/render/cesium.js', import.meta.url),
'utf8',
),
/restoreSpriteOrderOnEnable\('firms', viewer\)/,
);
assert.match(
readFileSync(
new URL('../layers/firms/render/cesium.js', import.meta.url),
'utf8',
),
/registerSpriteCollection\('firms', sprites\);\s*spriteServices\.restoreSpriteOrder\(viewer\);/,
'lazy FIRMS registration must restore order immediately', 'lazy FIRMS registration must restore order immediately',
); );
}); });
+6 -8
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import { import {
FIRMS_OVERLAY_SOURCE_ID, FIRMS_OVERLAY_SOURCE_ID,
FIRMS_AMBIENT_COHORT_LIMIT, FIRMS_AMBIENT_COHORT_LIMIT,
@@ -30,6 +29,10 @@ export function createCards({
function rebuildAmbientLabels() { function rebuildAmbientLabels() {
if (!layerState._viewer) return; if (!layerState._viewer) return;
const scene = layerState._viewer.scene; const scene = layerState._viewer.scene;
const project = (position, result) =>
layerState._renderer
? layerState._renderer.project(position, result)
: config.projectToWindow(scene, position, result);
const now = Date.now(); const now = Date.now();
const entries = []; const entries = [];
/** @type {Array<{x: number, y: number}>} Screen positions of accepted labels. */ /** @type {Array<{x: number, y: number}>} Screen positions of accepted labels. */
@@ -52,8 +55,7 @@ export function createCards({
); );
layerState._fireByCardId.set(selectedCard.id, layerState._selectedFire); layerState._fireByCardId.set(selectedCard.id, layerState._selectedFire);
entries.push(selectedCard); entries.push(selectedCard);
const screen = Cesium.SceneTransforms.worldToWindowCoordinates( const screen = project(
scene,
components.model.firePosition(layerState._selectedFire), components.model.firePosition(layerState._selectedFire),
layerState.scratchWindowCoord, layerState.scratchWindowCoord,
); );
@@ -82,11 +84,7 @@ export function createCards({
// LOD, far-side cells can still project on-canvas; accepting those first // LOD, far-side cells can still project on-canvas; accepting those first
// starves front-side cards before the host applies its authoritative cull. // starves front-side cards before the host applies its authoritative cull.
if (beyondHorizon(candidate.cullPosition || candidate.position)) continue; if (beyondHorizon(candidate.cullPosition || candidate.position)) continue;
const screen = Cesium.SceneTransforms.worldToWindowCoordinates( const screen = project(candidate.position, layerState.scratchWindowCoord);
scene,
candidate.position,
layerState.scratchWindowCoord,
);
if (!screen) continue; // projection failed (e.g. behind camera) — drop silently if (!screen) continue; // projection failed (e.g. behind camera) — drop silently
if ( if (
screen.x < -LABEL_VIEW_MARGIN_PX || screen.x < -LABEL_VIEW_MARGIN_PX ||
+2
View File
@@ -17,6 +17,8 @@ export function createFirmsHelpers({ services }) {
export function createFirmsHeatmapLayer({ services, feed, ...config }) { export function createFirmsHeatmapLayer({ services, feed, ...config }) {
if (typeof feed?.getSnapshot !== 'function') if (typeof feed?.getSnapshot !== 'function')
throw new TypeError('Fires require a snapshot source'); throw new TypeError('Fires require a snapshot source');
if (typeof config.renderer?.create !== 'function')
throw new TypeError('Fires require a renderer factory');
const layerState = createFirmsState({ services, config }); const layerState = createFirmsState({ services, config });
const components = {}; const components = {};
const context = { layerState, services, config, components, feed }; const context = { layerState, services, config, components, feed };
+22 -18
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js';
export function createLifecycle({ export function createLifecycle({
@@ -9,25 +8,31 @@ export function createLifecycle({
feed, feed,
}) { }) {
const { registerPickOwner, unregisterPickOwner } = services.picking; const { registerPickOwner, unregisterPickOwner } = services.picking;
const { restoreSpriteOrderOnEnable } = services.sprites;
const { clearSelectedEntityContextForLayer } = services.context; const { clearSelectedEntityContextForLayer } = services.context;
const { id, overlayHost } = config; const { id, overlayHost } = config;
const methods = { const methods = {
init(viewer) { init(viewer, { engine = null } = {}) {
if (layerState._destroyed) return; if (layerState._destroyed) return;
layerState._viewer = viewer; if (layerState._renderer) return;
layerState._dataSource = new Cesium.CustomDataSource(id); layerState._renderer = config.renderer.create({
layerState._dataSource.show = false; viewer,
viewer.dataSources.add(layerState._dataSource); engine,
id,
services,
screenSpaceEventHandlerFactory: config.screenSpaceEventHandlerFactory,
});
layerState._viewer = layerState._renderer.view;
layerState._dataSource = layerState._renderer.dataSource;
layerState._renderer.setVisible(false);
}, },
async enable(viewer) { async enable(viewer) {
if (layerState._destroyed) return; if (layerState._destroyed) return;
layerState._enabled = true; layerState._enabled = true;
layerState._viewer = viewer;
if (!layerState._dataSource) this.init(viewer); if (!layerState._dataSource) this.init(viewer);
if (layerState._dataSource) layerState._dataSource.show = true; const renderer = layerState._renderer;
layerState._viewer = layerState._renderer.view;
if (layerState._billboards) { if (layerState._billboards) {
// The camera can have moved anywhere while the layer was off: moveEnd // The camera can have moved anywhere while the layer was off: moveEnd
// was not being listened to, and the preRender watcher is inert while // was not being listened to, and the preRender watcher is inert while
@@ -37,8 +42,8 @@ export function createLifecycle({
// new location flashes far-side fires through the planet (and keeps // new location flashes far-side fires through the planet (and keeps
// near-side ones hidden) until the fetch resolves. // near-side ones hidden) until the fetch resolves.
components.rendering.refreshHorizonCulling(); components.rendering.refreshHorizonCulling();
layerState._billboards.show = true;
} }
layerState._renderer.setVisible(true);
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true); overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true);
components.viewport.installLodWatcher(); components.viewport.installLodWatcher();
components.viewport.installMoveEndWatcher(); components.viewport.installMoveEndWatcher();
@@ -51,9 +56,9 @@ export function createLifecycle({
if ( if (
layerState._enabled && layerState._enabled &&
!layerState._destroyed && !layerState._destroyed &&
layerState._viewer === viewer layerState._renderer === renderer
) )
restoreSpriteOrderOnEnable('firms', viewer); renderer.onEnable();
}, },
disable() { disable() {
@@ -62,8 +67,7 @@ export function createLifecycle({
layerState._loading = false; layerState._loading = false;
layerState._enabled = false; layerState._enabled = false;
components.selection.clearFireSelection(); components.selection.clearFireSelection();
if (layerState._dataSource) layerState._dataSource.show = false; layerState._renderer?.setVisible(false);
if (layerState._billboards) layerState._billboards.show = false;
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID); overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
clearSelectedEntityContextForLayer(id); clearSelectedEntityContextForLayer(id);
@@ -84,11 +88,11 @@ export function createLifecycle({
components.viewport.removeMoveEndWatcher(); components.viewport.removeMoveEndWatcher();
components.selection.removeClickHandler(); components.selection.removeClickHandler();
unregisterPickOwner(id); unregisterPickOwner(id);
if (layerState._dataSource && viewer) {
viewer.dataSources.remove(layerState._dataSource, true);
}
layerState._dataSource = null;
components.rendering.removeDetectionCollections(viewer); components.rendering.removeDetectionCollections(viewer);
layerState._renderer?.destroy();
layerState._renderer = null;
layerState._viewer = null;
layerState._dataSource = null;
components.selection.clearContextRegistrations(); components.selection.clearContextRegistrations();
clearSelectedEntityContextForLayer(id); clearSelectedEntityContextForLayer(id);
layerState._fires = []; layerState._fires = [];
+23 -18
View File
@@ -1,4 +1,6 @@
import * as Cesium from 'cesium'; import { rectangleWidth, rectangleHeight } from './rectangle.js';
import * as geo from '../../geo/index.js';
import { constantRgba } from '../shared/color.js';
import { import {
satelliteShortName, satelliteShortName,
fireDetectionKey, fireDetectionKey,
@@ -11,6 +13,9 @@ import {
CULL_LIFT_M, CULL_LIFT_M,
LABEL_MIN_SEP_PX, LABEL_MIN_SEP_PX,
} from './policy.js'; } 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({ export function createModel({
layerState, layerState,
@@ -61,30 +66,30 @@ export function createModel({
*/ */
function paddedDegreeBounds(rect) { function paddedDegreeBounds(rect) {
const width = Cesium.Rectangle.computeWidth(rect); const width = rectangleWidth(rect);
const height = Cesium.Rectangle.computeHeight(rect); const height = rectangleHeight(rect);
if (width >= Math.PI * 1.85) return null; if (width >= Math.PI * 1.85) return null;
const padLon = width * VIEW_PADDING; const padLon = width * VIEW_PADDING;
const padLat = height * VIEW_PADDING; const padLat = height * VIEW_PADDING;
const south = Math.max(-Cesium.Math.PI_OVER_TWO, rect.south - padLat); const south = Math.max(-geo.math.PI_OVER_TWO, rect.south - padLat);
const north = Math.min(Cesium.Math.PI_OVER_TWO, rect.north + padLat); const north = Math.min(geo.math.PI_OVER_TWO, rect.north + padLat);
let west = rect.west - padLon; let west = rect.west - padLon;
let east = rect.east + padLon; let east = rect.east + padLon;
let wraps; let wraps;
if (width + padLon * 2 >= Cesium.Math.TWO_PI) { if (width + padLon * 2 >= geo.math.TWO_PI) {
west = -Math.PI; west = -Math.PI;
east = Math.PI; east = Math.PI;
wraps = false; wraps = false;
} else { } else {
west = Cesium.Math.negativePiToPi(west); west = geo.math.negativePiToPi(west);
east = Cesium.Math.negativePiToPi(east); east = geo.math.negativePiToPi(east);
wraps = west > east; wraps = west > east;
} }
return { return {
west: Cesium.Math.toDegrees(west), west: geo.math.toDegrees(west),
south: Cesium.Math.toDegrees(south), south: geo.math.toDegrees(south),
east: Cesium.Math.toDegrees(east), east: geo.math.toDegrees(east),
north: Cesium.Math.toDegrees(north), north: geo.math.toDegrees(north),
wraps, wraps,
}; };
} }
@@ -118,9 +123,9 @@ export function createModel({
} }
function heatColor(value, alpha) { function heatColor(value, alpha) {
if (value > 0.72) return Cesium.Color.RED.withAlpha(alpha); if (value > 0.72) return FIRE_RED.withAlpha(alpha);
if (value > 0.42) return Cesium.Color.ORANGE.withAlpha(alpha); if (value > 0.42) return FIRE_ORANGE.withAlpha(alpha);
return Cesium.Color.YELLOW.withAlpha(alpha); return FIRE_YELLOW.withAlpha(alpha);
} }
/** /**
@@ -219,7 +224,7 @@ export function createModel({
function firePosition(fire) { function firePosition(fire) {
const height = fireAnchorHeight(fire.lat, fire.lon); const height = fireAnchorHeight(fire.lat, fire.lon);
if (!fire.position || fire.positionHeight !== height) { if (!fire.position || fire.positionHeight !== height) {
fire.position = Cesium.Cartesian3.fromDegrees(fire.lon, fire.lat, height); fire.position = geo.cartesian3.fromDegrees(fire.lon, fire.lat, height);
fire.positionHeight = height; fire.positionHeight = height;
} }
return fire.position; return fire.position;
@@ -239,7 +244,7 @@ export function createModel({
const position = firePosition(fire); const position = firePosition(fire);
if (fire.positionHeight >= CULL_LIFT_THRESHOLD_M) return position; if (fire.positionHeight >= CULL_LIFT_THRESHOLD_M) return position;
if (!fire.cullPosition) { if (!fire.cullPosition) {
fire.cullPosition = Cesium.Cartesian3.fromDegrees( fire.cullPosition = geo.cartesian3.fromDegrees(
fire.lon, fire.lon,
fire.lat, fire.lat,
CULL_LIFT_M, CULL_LIFT_M,
@@ -258,7 +263,7 @@ export function createModel({
*/ */
function cellCullPosition(lon, lat) { function cellCullPosition(lon, lat) {
return Cesium.Cartesian3.fromDegrees(lon, lat, CULL_LIFT_M); return geo.cartesian3.fromDegrees(lon, lat, CULL_LIFT_M);
} }
/** /**
+7 -4
View File
@@ -1,5 +1,8 @@
import { FIRMS_AMBIENT_COHORT_LIMIT } from '../../data/firmsLabels.js'; import { FIRMS_AMBIENT_COHORT_LIMIT } from '../../data/firmsLabels.js';
import * as Cesium from 'cesium'; 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);
/** Client poll interval; the proxy's 30 min TTL is what guards upstream quota. */ /** Client poll interval; the proxy's 30 min TTL is what guards upstream quota. */
@@ -91,7 +94,7 @@ export const CULL_LIFT_M = 12;
/** Color stops shared by cell heat fills and detection glow sprites. */ /** Color stops shared by cell heat fills and detection glow sprites. */
export const DETECTION_COLOR_STOPS = [ export const DETECTION_COLOR_STOPS = [
{ name: 'red', color: Cesium.Color.RED }, { name: 'red', color: FIRE_RED },
{ name: 'orange', color: Cesium.Color.ORANGE }, { name: 'orange', color: FIRE_ORANGE },
{ name: 'yellow', color: Cesium.Color.YELLOW }, { name: 'yellow', color: FIRE_YELLOW },
]; ];
+27
View File
@@ -0,0 +1,27 @@
import * as geo from '../../geo/index.js';
// The small rectangle operations used by FIRMS, on plain radian bounds.
export function rectangleWidth(rectangle) {
let east = rectangle.east;
if (east < rectangle.west) east += geo.math.TWO_PI;
return east - rectangle.west;
}
export function rectangleHeight(rectangle) {
return rectangle.north - rectangle.south;
}
export function rectangleCenter(rectangle, result) {
let east = rectangle.east;
if (east < rectangle.west) east += geo.math.TWO_PI;
result.longitude = geo.math.negativePiToPi((rectangle.west + east) * 0.5);
result.latitude = (rectangle.south + rectangle.north) * 0.5;
result.height = 0;
return result;
}
export function cloneRectangle(rectangle) {
return {
west: rectangle.west,
south: rectangle.south,
east: rectangle.east,
north: rectangle.north,
};
}
+45
View File
@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
rectangleWidth,
rectangleHeight,
rectangleCenter,
cloneRectangle,
} from './rectangle.js';
test('plain FIRMS viewport bounds preserve native date-line, pole and global rectangle arithmetic', () => {
for (const bounds of [
[-123, 37, -121, 39],
[179, -20, -179, 20],
[-180, -90, 180, 90],
[170, 85, -170, 90],
[-170, -90, 170, -85],
]) {
const native = Cesium.Rectangle.fromDegrees(...bounds),
plain = cloneRectangle(native);
assert.equal(rectangleWidth(plain), Cesium.Rectangle.computeWidth(native));
assert.equal(
rectangleHeight(plain),
Cesium.Rectangle.computeHeight(native),
);
const center = rectangleCenter(plain, {
longitude: 0,
latitude: 0,
height: 123,
});
const expected = Cesium.Rectangle.center(native);
assert.deepEqual(center, {
longitude: expected.longitude,
latitude: expected.latitude,
height: expected.height,
});
assert.notEqual(plain, native);
assert.deepEqual(plain, {
west: native.west,
south: native.south,
east: native.east,
north: native.north,
});
}
});
+123
View File
@@ -0,0 +1,123 @@
import * as Cesium from 'cesium';
import {
worldToWindowCoordinates,
horizonOccluder,
} from '../../../data/render/cesiumProjection.js';
export const FIRMS_LEFT_CLICK = Cesium.ScreenSpaceEventType.LEFT_CLICK;
export const projectFirmsWindow = worldToWindowCoordinates;
export const firmsHorizonOccluder = horizonOccluder;
/** Own the native ground rectangles, translucent sprites and their scene. */
export function createFirmsCesiumRenderer(
engine,
{ viewer, id, services, screenSpaceEventHandlerFactory } = {},
) {
if (!viewer?.dataSources)
throw new TypeError('FIRMS requires a native viewer');
const dataSource = new Cesium.CustomDataSource(id);
dataSource.show = false;
viewer.dataSources.add(dataSource);
let sprites = null,
visible = false,
destroyed = false;
const spriteServices = services.sprites;
function removeSprites() {
if (!sprites) return;
spriteServices.unregisterSpriteCollection?.('firms', sprites);
if (!viewer.scene?.isDestroyed?.()) {
try {
viewer.scene?.primitives?.remove(sprites);
} catch {
/* scene already removed it */
}
}
sprites = null;
}
return {
kind: 'cesium',
view: viewer,
dataSource,
clickType: FIRMS_LEFT_CLICK,
project(position, result) {
return projectFirmsWindow(viewer.scene, position, result);
},
viewRectangle(result) {
return viewer.camera.computeViewRectangle(
viewer.scene?.globe?.ellipsoid,
result,
);
},
horizon() {
const camera = viewer.camera?.positionWC
? viewer.camera
: viewer.scene?.camera;
return camera?.positionWC ? horizonOccluder(camera) : null;
},
createInputHandler() {
return screenSpaceEventHandlerFactory
? screenSpaceEventHandlerFactory(viewer)
: new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
},
pick(position) {
return viewer.scene.pick(position);
},
setVisible(show) {
if (destroyed) return;
visible = show !== false;
dataSource.show = visible;
if (sprites) sprites.show = visible;
},
clearCells() {
if (!destroyed) dataSource.entities.removeAll();
},
addCell({ west, south, east, north, color, position, properties }) {
if (destroyed) return null;
return dataSource.entities.add({
rectangle: {
coordinates: Cesium.Rectangle.fromDegrees(west, south, east, north),
material: new Cesium.ColorMaterialProperty(
new Cesium.Color(color.red, color.green, color.blue, color.alpha),
),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
},
position,
properties,
});
},
ensureSprites() {
if (destroyed) return null;
if (!sprites) {
sprites = new Cesium.BillboardCollection({
scene: viewer.scene,
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
sprites.show = visible;
viewer.scene.primitives.add(sprites);
spriteServices.registerSpriteCollection('firms', sprites);
spriteServices.restoreSpriteOrder(viewer);
}
return sprites;
},
addSprite(description) {
if (destroyed) return null;
return sprites.add({
...description,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
},
removeSprites,
onEnable() {
if (!destroyed)
spriteServices.restoreSpriteOrderOnEnable('firms', viewer);
},
destroy() {
if (destroyed) return;
destroyed = true;
removeSprites();
viewer.dataSources.remove(dataSource, true);
},
};
}
+137
View File
@@ -0,0 +1,137 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { constantRgba } from '../../shared/color.js';
import { createFirmsCesiumRenderer } from './cesium.js';
function harness() {
const sources = [],
primitives = new Cesium.PrimitiveCollection(),
registrations = [],
unregistered = [],
restored = [];
const viewer = {
scene: {
primitives,
frameState: { mode: Cesium.SceneMode.SCENE3D },
isDestroyed: () => false,
},
dataSources: {
add(source) {
sources.push(source);
},
remove(source, destroy) {
const i = sources.indexOf(source);
assert.ok(i >= 0);
sources.splice(i, 1);
assert.equal(destroy, true);
},
},
};
const services = {
sprites: {
registerSpriteCollection: (...args) => registrations.push(args),
unregisterSpriteCollection: (...args) => unregistered.push(args),
restoreSpriteOrder: (value) => restored.push(value),
restoreSpriteOrderOnEnable: (id, value) => {
assert.equal(id, 'firms');
restored.push(value);
},
},
};
const renderer = createFirmsCesiumRenderer(null, {
viewer,
id: 'firms-fixture',
services,
});
return {
viewer,
renderer,
sources,
primitives,
registrations,
unregistered,
restored,
};
}
test('native FIRMS renderer retains exact ground rectangle, color, metadata and translucent sprite policy', () => {
const h = harness(),
r = h.renderer,
epoch = Cesium.JulianDate.fromDate(new Date(0));
assert.equal(r.dataSource.show, false);
r.setVisible(true);
const entity = r.addCell({
west: 179,
south: 40,
east: 180,
north: 42,
color: constantRgba(1, 165 / 255, 0, 0.66),
position: geo.cartesian3.fromDegrees(179.5, 41),
properties: { count: 7, intensity: 12.5, maxFrp: 999, night: 3 },
});
const rectangle = entity.rectangle.coordinates.getValue(epoch),
color = entity.rectangle.material.color.getValue(epoch);
assert.deepEqual(
[rectangle.west, rectangle.south, rectangle.east, rectangle.north],
[179, 40, 180, 42].map(Cesium.Math.toRadians),
);
assert.deepEqual(
[color.red, color.green, color.blue, color.alpha],
[1, 165 / 255, 0, 0.66],
);
assert.equal(
entity.rectangle.heightReference.getValue(epoch),
Cesium.HeightReference.CLAMP_TO_GROUND,
);
for (const [key, value] of Object.entries({
count: 7,
intensity: 12.5,
maxFrp: 999,
night: 3,
}))
assert.equal(entity.properties[key].getValue(epoch), value);
const sprites = r.ensureSprites();
assert.equal(sprites, r.ensureSprites());
const sprite = r.addSprite({
id: 'firms-7',
position: geo.cartesian3.fromDegrees(179.5, 41, 105),
image: 'fixture.png',
});
assert.equal(sprite.id, 'firms-7');
assert.equal(sprite.disableDepthTestDistance, Infinity);
assert.equal(sprite.verticalOrigin, Cesium.VerticalOrigin.CENTER);
assert.equal(sprite.horizontalOrigin, Cesium.HorizontalOrigin.CENTER);
assert.equal(sprites.blendOption, Cesium.BlendOption.TRANSLUCENT);
assert.equal(h.registrations.length, 1);
r.setVisible(false);
assert.equal(r.dataSource.show, false);
assert.equal(sprites.show, false);
r.destroy();
});
test('native FIRMS destroys and unregisters only its original scene resources, once', () => {
const h = harness(),
r = h.renderer,
sprites = r.ensureSprites();
const foreign = {
dataSources: {
remove() {
assert.fail('foreign scene must not own FIRMS teardown');
},
},
};
r.onEnable();
assert.ok(h.restored.every((viewer) => viewer === h.viewer));
r.destroy(foreign);
r.destroy();
assert.equal(h.sources.length, 0);
assert.equal(h.primitives.length, 0);
assert.equal(sprites.isDestroyed(), true);
assert.deepEqual(h.unregistered, [['firms', sprites]]);
assert.equal(r.ensureSprites(), null);
r.onEnable();
assert.equal(h.primitives.length, 0);
assert.equal(h.registrations.length, 1);
});
+17 -42
View File
@@ -1,6 +1,6 @@
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { cloneRectangle } from './rectangle.js';
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 { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js'; import { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js';
export function createRendering({ export function createRendering({
@@ -11,7 +11,6 @@ export function createRendering({
feed, feed,
}) { }) {
const { warmFireAnchorFloors } = services.anchors; const { warmFireAnchorFloors } = services.anchors;
const { registerSpriteCollection, restoreSpriteOrder } = services.sprites;
const { overlayHost } = config; const { overlayHost } = config;
/** /**
@@ -44,9 +43,7 @@ export function createRendering({
return false; return false;
layerState._currentLodIndex = lodIndex; layerState._currentLodIndex = lodIndex;
layerState._currentLodId = lod.id; layerState._currentLodId = lod.id;
layerState._lastViewRect = viewRect layerState._lastViewRect = viewRect ? cloneRectangle(viewRect) : null;
? Cesium.Rectangle.clone(viewRect)
: null;
const bounds = viewRect const bounds = viewRect
? components.model.paddedDegreeBounds(viewRect) ? components.model.paddedDegreeBounds(viewRect)
: null; : null;
@@ -130,7 +127,7 @@ export function createRendering({
function renderCells(cells, lod, bounds) { function renderCells(cells, lod, bounds) {
ensureDetectionCollections(); ensureDetectionCollections();
layerState._dataSource.entities.removeAll(); layerState._renderer.clearCells();
if (layerState._billboards) layerState._billboards.removeAll(); if (layerState._billboards) layerState._billboards.removeAll();
layerState._pickIndexById.clear(); layerState._pickIndexById.clear();
layerState._cullPositions.length = 0; layerState._cullPositions.length = 0;
@@ -147,19 +144,14 @@ export function createRendering({
const color = components.model.heatColor(normalized, alpha); const color = components.model.heatColor(normalized, alpha);
const centerLon = cell.lonCell + lod.gridDegrees / 2; const centerLon = cell.lonCell + lod.gridDegrees / 2;
const centerLat = cell.latCell + lod.gridDegrees / 2; const centerLat = cell.latCell + lod.gridDegrees / 2;
const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0); const position = geo.cartesian3.fromDegrees(centerLon, centerLat, 0);
layerState._dataSource.entities.add({ layerState._renderer.addCell({
rectangle: { west: cell.lonCell,
coordinates: Cesium.Rectangle.fromDegrees( south: cell.latCell,
cell.lonCell, east: cell.lonCell + lod.gridDegrees,
cell.latCell, north: cell.latCell + lod.gridDegrees,
cell.lonCell + lod.gridDegrees, color,
cell.latCell + lod.gridDegrees,
),
material: new Cesium.ColorMaterialProperty(color),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
},
position, position,
properties: { properties: {
count: cell.count, count: cell.count,
@@ -203,7 +195,7 @@ export function createRendering({
function renderDetections(lod, bounds) { function renderDetections(lod, bounds) {
ensureDetectionCollections(); ensureDetectionCollections();
layerState._dataSource.entities.removeAll(); layerState._renderer.clearCells();
if (!layerState._billboards) return; if (!layerState._billboards) return;
layerState._billboards.removeAll(); layerState._billboards.removeAll();
layerState._pickIndexById.clear(); layerState._pickIndexById.clear();
@@ -237,16 +229,13 @@ 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);
layerState._billboards.add({ layerState._renderer.addSprite({
id: pickId, id: pickId,
position, position,
image: components.model.glowSprite( image: components.model.glowSprite(
components.model.detectionColorStop(fire), components.model.detectionColorStop(fire),
components.model.sizeBucket(coreSize), components.model.sizeBucket(coreSize),
), ),
verticalOrigin: Cesium.VerticalOrigin.CENTER,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
}); });
// FRP-sorted walk: candidate order doubles as label priority. // FRP-sorted walk: candidate order doubles as label priority.
layerState._labelCandidates.push({ layerState._labelCandidates.push({
@@ -283,27 +272,12 @@ export function createRendering({
function ensureDetectionCollections() { function ensureDetectionCollections() {
if (!layerState._viewer || layerState._billboards) return; if (!layerState._viewer || layerState._billboards) return;
layerState._billboards = new Cesium.BillboardCollection({ layerState._billboards = layerState._renderer.ensureSprites();
scene: layerState._viewer.scene,
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
layerState._billboards.show = layerState._enabled;
layerState._viewer.scene.primitives.add(layerState._billboards);
registerSpriteCollection('firms', layerState._billboards);
restoreSpriteOrder(layerState._viewer);
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, layerState._enabled); overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, layerState._enabled);
} }
function removeDetectionCollections(viewer) { function removeDetectionCollections(viewer) {
const scene = viewer?.scene || layerState._viewer?.scene; layerState._renderer?.removeSprites();
if (scene && !scene.isDestroyed?.()) {
try {
if (layerState._billboards)
scene.primitives.remove(layerState._billboards);
} catch {
/* already torn down */
}
}
layerState._billboards = null; layerState._billboards = null;
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID); overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
@@ -319,11 +293,12 @@ export function createRendering({
*/ */
function fireHorizonOccluder() { function fireHorizonOccluder() {
if (layerState._renderer) return layerState._renderer.horizon();
const camera = layerState._viewer?.camera?.positionWC const camera = layerState._viewer?.camera?.positionWC
? layerState._viewer.camera ? layerState._viewer.camera
: layerState._viewer?.scene?.camera; : layerState._viewer?.scene?.camera;
if (!camera?.positionWC) return null; if (!camera?.positionWC) return null;
return horizonOccluder(camera); return config.horizonOccluder(camera);
} }
/** /**
+9 -6
View File
@@ -2,7 +2,6 @@ import {
FIRMS_OVERLAY_SOURCE_ID, FIRMS_OVERLAY_SOURCE_ID,
fireDetectionKey, fireDetectionKey,
} from '../../data/firmsLabels.js'; } from '../../data/firmsLabels.js';
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js'; import { isPointerFree } from '../../data/inputOwnership.js';
import { CONTEXT_TOP_N } from './policy.js'; import { CONTEXT_TOP_N } from './policy.js';
@@ -32,13 +31,17 @@ export function createSelection({
function installClickHandler() { function installClickHandler() {
if (layerState._clickHandler || !layerState._viewer) return; if (layerState._clickHandler || !layerState._viewer) return;
layerState._clickHandler = screenSpaceEventHandlerFactory( layerState._clickHandler = layerState._renderer
layerState._viewer, ? layerState._renderer.createInputHandler()
); : screenSpaceEventHandlerFactory(layerState._viewer);
const clickType =
layerState._renderer?.clickType ?? config.leftClickEventType;
layerState._clickHandler.setInputAction((click) => { layerState._clickHandler.setInputAction((click) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click. // A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return; if (!isPointerFree()) return;
const picked = layerState._viewer.scene.pick(click.position); const picked = layerState._renderer
? layerState._renderer.pick(click.position, click)
: layerState._viewer.scene.pick(click.position);
const fire = pickedFire(picked); const fire = pickedFire(picked);
if (fire) { if (fire) {
selectAndFocusFire(fire); selectAndFocusFire(fire);
@@ -63,7 +66,7 @@ export function createSelection({
return; return;
} }
clearFireSelection(); clearFireSelection();
}, Cesium.ScreenSpaceEventType.LEFT_CLICK); }, clickType);
} }
function removeClickHandler() { function removeClickHandler() {
+3 -2
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
export function createFirmsState({ services, config }) { export function createFirmsState({ services, config }) {
@@ -8,7 +7,7 @@ export function createFirmsState({ services, config }) {
layerState.glowSpriteCache = new Map(); layerState.glowSpriteCache = new Map();
layerState.scratchViewRect = new Cesium.Rectangle(); layerState.scratchViewRect = { west: 0, south: 0, east: 0, north: 0 };
layerState.scratchCenterA = geo.cartographic.create(); layerState.scratchCenterA = geo.cartographic.create();
@@ -20,6 +19,8 @@ export function createFirmsState({ services, config }) {
layerState._dataSource = null; layerState._dataSource = null;
layerState._renderer = null;
layerState._billboards = null; layerState._billboards = null;
layerState._enabled = false; layerState._enabled = false;
+25 -11
View File
@@ -1,4 +1,8 @@
import * as Cesium from 'cesium'; import {
rectangleWidth,
rectangleHeight,
rectangleCenter,
} from './rectangle.js';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { import {
LOD_LEVELS, LOD_LEVELS,
@@ -15,6 +19,11 @@ export function createViewport({
feed, feed,
}) { }) {
const { governorRequestRender } = services.render; const { governorRequestRender } = services.render;
let settleTimer = null;
function clearSettleTimer() {
if (settleTimer !== null) clearTimeout(settleTimer);
settleTimer = null;
}
/** /**
* Ambient labels are placed via screen-space projection, so they need a * Ambient labels are placed via screen-space projection, so they need a
@@ -89,6 +98,8 @@ export function createViewport({
function computeViewRect() { function computeViewRect() {
try { try {
if (layerState._renderer)
return layerState._renderer.viewRectangle(layerState.scratchViewRect);
const rect = layerState._viewer?.camera?.computeViewRectangle( const rect = layerState._viewer?.camera?.computeViewRectangle(
layerState._viewer?.scene?.globe?.ellipsoid, layerState._viewer?.scene?.globe?.ellipsoid,
layerState.scratchViewRect, layerState.scratchViewRect,
@@ -110,17 +121,17 @@ export function createViewport({
function viewChangedEnough(viewRect) { function viewChangedEnough(viewRect) {
if (!viewRect) return false; // sky/horizon — keep the current render if (!viewRect) return false; // sky/horizon — keep the current render
if (!layerState._lastViewRect) return true; // previous render was global fallback if (!layerState._lastViewRect) return true; // previous render was global fallback
const lastWidth = Cesium.Rectangle.computeWidth(layerState._lastViewRect); const lastWidth = rectangleWidth(layerState._lastViewRect);
const lastHeight = Cesium.Rectangle.computeHeight(layerState._lastViewRect); const lastHeight = rectangleHeight(layerState._lastViewRect);
const width = Cesium.Rectangle.computeWidth(viewRect); const width = rectangleWidth(viewRect);
const height = Cesium.Rectangle.computeHeight(viewRect); const height = rectangleHeight(viewRect);
if (width > lastWidth * 1.3 || width < lastWidth * 0.75) return true; if (width > lastWidth * 1.3 || width < lastWidth * 0.75) return true;
if (height > lastHeight * 1.3 || height < lastHeight * 0.75) return true; if (height > lastHeight * 1.3 || height < lastHeight * 0.75) return true;
const lastCenter = Cesium.Rectangle.center( const lastCenter = rectangleCenter(
layerState._lastViewRect, layerState._lastViewRect,
layerState.scratchCenterA, layerState.scratchCenterA,
); );
const center = Cesium.Rectangle.center(viewRect, layerState.scratchCenterB); const center = rectangleCenter(viewRect, layerState.scratchCenterB);
const latShift = Math.abs(center.latitude - lastCenter.latitude); const latShift = Math.abs(center.latitude - lastCenter.latitude);
let lonShift = Math.abs(center.longitude - lastCenter.longitude); let lonShift = Math.abs(center.longitude - lastCenter.longitude);
if (lonShift > Math.PI) lonShift = geo.math.TWO_PI - lonShift; if (lonShift > Math.PI) lonShift = geo.math.TWO_PI - lonShift;
@@ -139,10 +150,12 @@ export function createViewport({
layerState._moveEndSettleRemover = layerState._moveEndSettleRemover =
layerState._viewer.camera.moveEnd.addEventListener(() => { layerState._viewer.camera.moveEnd.addEventListener(() => {
if (!layerState._enabled) return; if (!layerState._enabled) return;
setTimeout( clearSettleTimer();
() => governorRequestRender('firms-lod-settle'), settleTimer = setTimeout(() => {
LOD_CHECK_MS + 40, settleTimer = null;
); if (layerState._enabled && !layerState._destroyed)
governorRequestRender('firms-lod-settle');
}, LOD_CHECK_MS + 40);
}); });
} }
layerState._preRenderRemover = layerState._preRenderRemover =
@@ -194,6 +207,7 @@ export function createViewport({
} }
function removeLodWatcher() { function removeLodWatcher() {
clearSettleTimer();
if (layerState._preRenderRemover) { if (layerState._preRenderRemover) {
layerState._preRenderRemover(); layerState._preRenderRemover();
layerState._preRenderRemover = null; layerState._preRenderRemover = null;
+76
View File
@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createViewport } from './viewport.js';
import { LOD_CHECK_MS } from './policy.js';
function signal() {
const listeners = new Set();
return {
addEventListener(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
raise() {
for (const listener of [...listeners]) listener();
},
get size() {
return listeners.size;
},
};
}
function harness() {
const moveEnd = signal(),
preRender = signal(),
requests = [];
const layerState = {
_enabled: true,
_destroyed: false,
_viewer: { camera: { moveEnd }, scene: { preRender } },
};
const viewport = createViewport({
layerState,
services: {
render: { governorRequestRender: (reason) => requests.push(reason) },
},
components: {},
config: {},
});
viewport.installLodWatcher();
return { viewport, layerState, moveEnd, preRender, requests };
}
for (const operation of ['disable', 'destroy']) {
test(`FIRMS ${operation} cancels its pending settled-camera frame`, (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const h = harness();
h.moveEnd.raise();
h.layerState._enabled = false;
if (operation === 'destroy') h.layerState._destroyed = true;
h.viewport.removeLodWatcher();
t.mock.timers.tick(LOD_CHECK_MS + 100);
assert.deepEqual(h.requests, [], 'removed layer must never wake the scene');
assert.equal(h.moveEnd.size, 0);
assert.equal(h.preRender.size, 0);
});
}
test('FIRMS coalesces repeated moveEnd events into one final settled frame', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const h = harness();
h.moveEnd.raise();
t.mock.timers.tick(300);
h.moveEnd.raise();
t.mock.timers.tick(LOD_CHECK_MS + 40 - 300);
assert.deepEqual(h.requests, [], 'the earlier settle request was superseded');
t.mock.timers.tick(300);
assert.deepEqual(h.requests, ['firms-lod-settle']);
h.viewport.removeLodWatcher();
});
test('FIRMS active layer still gets the requested final LOD frame', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const h = harness();
h.moveEnd.raise();
t.mock.timers.tick(LOD_CHECK_MS + 39);
assert.deepEqual(h.requests, []);
t.mock.timers.tick(1);
assert.deepEqual(h.requests, ['firms-lod-settle']);
h.viewport.removeLodWatcher();
});