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.
const RENDERED_LAYER_DIRS = [
'src/layers/earthquakes/',
'src/layers/firms/',
'src/layers/flights/',
'src/layers/military/',
'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",
"scripts/qa-w2-ground-mesh.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": [
"src/data/firmsAdapt.js",
"src/data/firmsLabels.js",
"src/data/iconOrientation.js",
"src/data/render/cesiumProjection.js",
"src/layers/firms/anchors.js",
"src/layers/firms/cards.js",
"src/layers/firms/index.js",
@@ -1145,10 +1143,10 @@
"src/geo/check.js",
"src/geo/debug.js",
"src/geo/developerError.js",
"src/globe/contract.js",
"src/globe/cesium/engineFromViewer.js"
"src/layers/firms/rectangle.js",
"src/layers/shared/color.js"
],
"external": ["cesium"]
"external": []
},
"satellites-layer": {
"exports": ["./layers/satellites"],
@@ -2698,7 +2696,9 @@
"src/layers/earthquakes/render/cesium.js",
"src/layers/earthquakes/render/navara.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": [
"hls.js",
@@ -3291,7 +3291,9 @@
"src/celestialRing/view.js",
"src/layers/shared/render/engineView.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": [
"hls.js",
+29 -1
View File
@@ -30,6 +30,7 @@
*/
import puppeteer from 'puppeteer';
import { redact } from './qa-browserEvidence.mjs';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -46,6 +47,11 @@ const getOpt = (name, dflt) => {
const APP_URL = getOpt('--url', 'http://localhost:4420');
const HEADFUL = argv.includes('--headful');
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 = [
process.env.PUPPETEER_EXECUTABLE_PATH,
@@ -247,7 +253,7 @@ async function main() {
headless: HEADFUL ? false : 'new',
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
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-background-timer-throttling', '--disable-renderer-backgrounding',
'--window-size=1440,900',
@@ -261,6 +267,19 @@ async function main() {
await page.setRequestInterception(true);
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();
page.on('request', (req) => {
const url = req.url();
@@ -509,12 +528,21 @@ async function main() {
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 fail = results.filter((r) => r.ok === false).length;
console.log('\n' + '─'.repeat(60));
console.log(` RESULT: ${pass} passed, ${fail} failed`);
console.log(` Shots : ${SHOTS_DIR}/firms-*.png`);
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));
}
+16
View File
@@ -1,4 +1,11 @@
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 * as render from '../../renderGovernor.js';
import * as sprites from '../../data/spriteOrder.js';
@@ -23,6 +30,15 @@ export function createApplicationFirms({ surface, ...options }) {
source: options.source ?? 'NASA FIRMS',
feed: options.feed,
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 ?? {
setEntries: overlays.setOverlayEntries,
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 { createLaunchesCesiumRenderer } from '../../layers/launches/render/cesium.js';
// 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). */
export const CESIUM_LAYER_RENDERERS = Object.freeze({
earthquakes: createEarthquakesCesiumRenderer,
'local-firms': createFirmsCesiumRenderer,
'rocket-launches': createLaunchesCesiumRenderer,
satellites: createSatellitesCesiumRenderer,
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', () => {
assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), [
'earthquakes',
'local-firms',
'rocket-launches',
'satellites',
'flights',
+3 -1
View File
@@ -406,9 +406,11 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
'vessel focus helper',
);
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(
firms,
/_clickHandler\.setInputAction\(\(click\) => \{([\s\S]*?)\n \}, Cesium\.ScreenSpaceEventType\.LEFT_CLICK\);/,
/_clickHandler\.setInputAction\(\(click\) => \{([\s\S]*?)\n \}, clickType\);/,
'fire click',
);
ordered(fireClick, [
+8
View File
@@ -76,6 +76,8 @@ function harness({
const viewer = {
scene: {
pick: () => picked,
frameState: { mode: Cesium.SceneMode.SCENE3D },
primitives: new Cesium.PrimitiveCollection(),
canvas: { clientWidth: 1280, clientHeight: 800 },
camera: cameraPosition ? { positionWC: cameraPosition } : undefined,
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
@@ -99,6 +101,7 @@ function harness({
fires,
click: () => handler.click({ position: { x: 400, y: 300 } }),
cleanup() {
layer.destroy();
Cesium.SceneTransforms.worldToWindowCoordinates = priorProjection;
if (hadWindow) globalThis.window = priorWindow;
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();
assert.equal(
h.layer.getStats().error,
null,
'the refreshed geometry must succeed too',
);
assert.equal(
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 assert from 'node:assert/strict';
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'/,
);
assert.match(aisLiveVesselsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('ais', activeViewer\)/);
assert.match(firmsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('firms', viewer\)/);
assert.match(
createFirmsRendering.toString(),
/registerSpriteCollection\('firms', layerState\._billboards\);\s*restoreSpriteOrder\(layerState\._viewer\);/,
firmsLayer.enable.toString(),
/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',
);
});
+6 -8
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import {
FIRMS_OVERLAY_SOURCE_ID,
FIRMS_AMBIENT_COHORT_LIMIT,
@@ -30,6 +29,10 @@ export function createCards({
function rebuildAmbientLabels() {
if (!layerState._viewer) return;
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 entries = [];
/** @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);
entries.push(selectedCard);
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
const screen = project(
components.model.firePosition(layerState._selectedFire),
layerState.scratchWindowCoord,
);
@@ -82,11 +84,7 @@ export function createCards({
// LOD, far-side cells can still project on-canvas; accepting those first
// starves front-side cards before the host applies its authoritative cull.
if (beyondHorizon(candidate.cullPosition || candidate.position)) continue;
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
candidate.position,
layerState.scratchWindowCoord,
);
const screen = project(candidate.position, layerState.scratchWindowCoord);
if (!screen) continue; // projection failed (e.g. behind camera) — drop silently
if (
screen.x < -LABEL_VIEW_MARGIN_PX ||
+2
View File
@@ -17,6 +17,8 @@ export function createFirmsHelpers({ services }) {
export function createFirmsHeatmapLayer({ services, feed, ...config }) {
if (typeof feed?.getSnapshot !== 'function')
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 components = {};
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';
export function createLifecycle({
@@ -9,25 +8,31 @@ export function createLifecycle({
feed,
}) {
const { registerPickOwner, unregisterPickOwner } = services.picking;
const { restoreSpriteOrderOnEnable } = services.sprites;
const { clearSelectedEntityContextForLayer } = services.context;
const { id, overlayHost } = config;
const methods = {
init(viewer) {
init(viewer, { engine = null } = {}) {
if (layerState._destroyed) return;
layerState._viewer = viewer;
layerState._dataSource = new Cesium.CustomDataSource(id);
layerState._dataSource.show = false;
viewer.dataSources.add(layerState._dataSource);
if (layerState._renderer) return;
layerState._renderer = config.renderer.create({
viewer,
engine,
id,
services,
screenSpaceEventHandlerFactory: config.screenSpaceEventHandlerFactory,
});
layerState._viewer = layerState._renderer.view;
layerState._dataSource = layerState._renderer.dataSource;
layerState._renderer.setVisible(false);
},
async enable(viewer) {
if (layerState._destroyed) return;
layerState._enabled = true;
layerState._viewer = 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) {
// The camera can have moved anywhere while the layer was off: moveEnd
// 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
// near-side ones hidden) until the fetch resolves.
components.rendering.refreshHorizonCulling();
layerState._billboards.show = true;
}
layerState._renderer.setVisible(true);
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true);
components.viewport.installLodWatcher();
components.viewport.installMoveEndWatcher();
@@ -51,9 +56,9 @@ export function createLifecycle({
if (
layerState._enabled &&
!layerState._destroyed &&
layerState._viewer === viewer
layerState._renderer === renderer
)
restoreSpriteOrderOnEnable('firms', viewer);
renderer.onEnable();
},
disable() {
@@ -62,8 +67,7 @@ export function createLifecycle({
layerState._loading = false;
layerState._enabled = false;
components.selection.clearFireSelection();
if (layerState._dataSource) layerState._dataSource.show = false;
if (layerState._billboards) layerState._billboards.show = false;
layerState._renderer?.setVisible(false);
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
clearSelectedEntityContextForLayer(id);
@@ -84,11 +88,11 @@ export function createLifecycle({
components.viewport.removeMoveEndWatcher();
components.selection.removeClickHandler();
unregisterPickOwner(id);
if (layerState._dataSource && viewer) {
viewer.dataSources.remove(layerState._dataSource, true);
}
layerState._dataSource = null;
components.rendering.removeDetectionCollections(viewer);
layerState._renderer?.destroy();
layerState._renderer = null;
layerState._viewer = null;
layerState._dataSource = null;
components.selection.clearContextRegistrations();
clearSelectedEntityContextForLayer(id);
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 {
satelliteShortName,
fireDetectionKey,
@@ -11,6 +13,9 @@ import {
CULL_LIFT_M,
LABEL_MIN_SEP_PX,
} from './policy.js';
const FIRE_RED = constantRgba(1, 0, 0);
const FIRE_ORANGE = constantRgba(1, 165 / 255, 0);
const FIRE_YELLOW = constantRgba(1, 1, 0);
export function createModel({
layerState,
@@ -61,30 +66,30 @@ export function createModel({
*/
function paddedDegreeBounds(rect) {
const width = Cesium.Rectangle.computeWidth(rect);
const height = Cesium.Rectangle.computeHeight(rect);
const width = rectangleWidth(rect);
const height = rectangleHeight(rect);
if (width >= Math.PI * 1.85) return null;
const padLon = width * VIEW_PADDING;
const padLat = height * VIEW_PADDING;
const south = Math.max(-Cesium.Math.PI_OVER_TWO, rect.south - padLat);
const north = Math.min(Cesium.Math.PI_OVER_TWO, rect.north + padLat);
const south = Math.max(-geo.math.PI_OVER_TWO, rect.south - padLat);
const north = Math.min(geo.math.PI_OVER_TWO, rect.north + padLat);
let west = rect.west - padLon;
let east = rect.east + padLon;
let wraps;
if (width + padLon * 2 >= Cesium.Math.TWO_PI) {
if (width + padLon * 2 >= geo.math.TWO_PI) {
west = -Math.PI;
east = Math.PI;
wraps = false;
} else {
west = Cesium.Math.negativePiToPi(west);
east = Cesium.Math.negativePiToPi(east);
west = geo.math.negativePiToPi(west);
east = geo.math.negativePiToPi(east);
wraps = west > east;
}
return {
west: Cesium.Math.toDegrees(west),
south: Cesium.Math.toDegrees(south),
east: Cesium.Math.toDegrees(east),
north: Cesium.Math.toDegrees(north),
west: geo.math.toDegrees(west),
south: geo.math.toDegrees(south),
east: geo.math.toDegrees(east),
north: geo.math.toDegrees(north),
wraps,
};
}
@@ -118,9 +123,9 @@ export function createModel({
}
function heatColor(value, alpha) {
if (value > 0.72) return Cesium.Color.RED.withAlpha(alpha);
if (value > 0.42) return Cesium.Color.ORANGE.withAlpha(alpha);
return Cesium.Color.YELLOW.withAlpha(alpha);
if (value > 0.72) return FIRE_RED.withAlpha(alpha);
if (value > 0.42) return FIRE_ORANGE.withAlpha(alpha);
return FIRE_YELLOW.withAlpha(alpha);
}
/**
@@ -219,7 +224,7 @@ export function createModel({
function firePosition(fire) {
const height = fireAnchorHeight(fire.lat, fire.lon);
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;
}
return fire.position;
@@ -239,7 +244,7 @@ export function createModel({
const position = firePosition(fire);
if (fire.positionHeight >= CULL_LIFT_THRESHOLD_M) return position;
if (!fire.cullPosition) {
fire.cullPosition = Cesium.Cartesian3.fromDegrees(
fire.cullPosition = geo.cartesian3.fromDegrees(
fire.lon,
fire.lat,
CULL_LIFT_M,
@@ -258,7 +263,7 @@ export function createModel({
*/
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 * 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. */
@@ -91,7 +94,7 @@ export const CULL_LIFT_M = 12;
/** Color stops shared by cell heat fills and detection glow sprites. */
export const DETECTION_COLOR_STOPS = [
{ name: 'red', color: Cesium.Color.RED },
{ name: 'orange', color: Cesium.Color.ORANGE },
{ name: 'yellow', color: Cesium.Color.YELLOW },
{ name: 'red', color: FIRE_RED },
{ name: 'orange', color: FIRE_ORANGE },
{ 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 { horizonOccluder } from '../../data/iconOrientation.js';
import { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js';
export function createRendering({
@@ -11,7 +11,6 @@ export function createRendering({
feed,
}) {
const { warmFireAnchorFloors } = services.anchors;
const { registerSpriteCollection, restoreSpriteOrder } = services.sprites;
const { overlayHost } = config;
/**
@@ -44,9 +43,7 @@ export function createRendering({
return false;
layerState._currentLodIndex = lodIndex;
layerState._currentLodId = lod.id;
layerState._lastViewRect = viewRect
? Cesium.Rectangle.clone(viewRect)
: null;
layerState._lastViewRect = viewRect ? cloneRectangle(viewRect) : null;
const bounds = viewRect
? components.model.paddedDegreeBounds(viewRect)
: null;
@@ -130,7 +127,7 @@ export function createRendering({
function renderCells(cells, lod, bounds) {
ensureDetectionCollections();
layerState._dataSource.entities.removeAll();
layerState._renderer.clearCells();
if (layerState._billboards) layerState._billboards.removeAll();
layerState._pickIndexById.clear();
layerState._cullPositions.length = 0;
@@ -147,19 +144,14 @@ export function createRendering({
const color = components.model.heatColor(normalized, alpha);
const centerLon = cell.lonCell + 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({
rectangle: {
coordinates: Cesium.Rectangle.fromDegrees(
cell.lonCell,
cell.latCell,
cell.lonCell + lod.gridDegrees,
cell.latCell + lod.gridDegrees,
),
material: new Cesium.ColorMaterialProperty(color),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
},
layerState._renderer.addCell({
west: cell.lonCell,
south: cell.latCell,
east: cell.lonCell + lod.gridDegrees,
north: cell.latCell + lod.gridDegrees,
color,
position,
properties: {
count: cell.count,
@@ -203,7 +195,7 @@ export function createRendering({
function renderDetections(lod, bounds) {
ensureDetectionCollections();
layerState._dataSource.entities.removeAll();
layerState._renderer.clearCells();
if (!layerState._billboards) return;
layerState._billboards.removeAll();
layerState._pickIndexById.clear();
@@ -237,16 +229,13 @@ export function createRendering({
const pickId = `firms-${fire.index}`;
layerState._pickIndexById.set(pickId, fire);
layerState._cullPositions.push(cullPosition);
layerState._billboards.add({
layerState._renderer.addSprite({
id: pickId,
position,
image: components.model.glowSprite(
components.model.detectionColorStop(fire),
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.
layerState._labelCandidates.push({
@@ -283,27 +272,12 @@ export function createRendering({
function ensureDetectionCollections() {
if (!layerState._viewer || layerState._billboards) return;
layerState._billboards = new Cesium.BillboardCollection({
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);
layerState._billboards = layerState._renderer.ensureSprites();
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, layerState._enabled);
}
function removeDetectionCollections(viewer) {
const scene = viewer?.scene || layerState._viewer?.scene;
if (scene && !scene.isDestroyed?.()) {
try {
if (layerState._billboards)
scene.primitives.remove(layerState._billboards);
} catch {
/* already torn down */
}
}
layerState._renderer?.removeSprites();
layerState._billboards = null;
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
@@ -319,11 +293,12 @@ export function createRendering({
*/
function fireHorizonOccluder() {
if (layerState._renderer) return layerState._renderer.horizon();
const camera = layerState._viewer?.camera?.positionWC
? layerState._viewer.camera
: layerState._viewer?.scene?.camera;
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,
fireDetectionKey,
} from '../../data/firmsLabels.js';
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
import { CONTEXT_TOP_N } from './policy.js';
@@ -32,13 +31,17 @@ export function createSelection({
function installClickHandler() {
if (layerState._clickHandler || !layerState._viewer) return;
layerState._clickHandler = screenSpaceEventHandlerFactory(
layerState._viewer,
);
layerState._clickHandler = layerState._renderer
? layerState._renderer.createInputHandler()
: screenSpaceEventHandlerFactory(layerState._viewer);
const clickType =
layerState._renderer?.clickType ?? config.leftClickEventType;
layerState._clickHandler.setInputAction((click) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
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);
if (fire) {
selectAndFocusFire(fire);
@@ -63,7 +66,7 @@ export function createSelection({
return;
}
clearFireSelection();
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}, clickType);
}
function removeClickHandler() {
+3 -2
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
export function createFirmsState({ services, config }) {
@@ -8,7 +7,7 @@ export function createFirmsState({ services, config }) {
layerState.glowSpriteCache = new Map();
layerState.scratchViewRect = new Cesium.Rectangle();
layerState.scratchViewRect = { west: 0, south: 0, east: 0, north: 0 };
layerState.scratchCenterA = geo.cartographic.create();
@@ -20,6 +19,8 @@ export function createFirmsState({ services, config }) {
layerState._dataSource = null;
layerState._renderer = null;
layerState._billboards = null;
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 {
LOD_LEVELS,
@@ -15,6 +19,11 @@ export function createViewport({
feed,
}) {
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
@@ -89,6 +98,8 @@ export function createViewport({
function computeViewRect() {
try {
if (layerState._renderer)
return layerState._renderer.viewRectangle(layerState.scratchViewRect);
const rect = layerState._viewer?.camera?.computeViewRectangle(
layerState._viewer?.scene?.globe?.ellipsoid,
layerState.scratchViewRect,
@@ -110,17 +121,17 @@ export function createViewport({
function viewChangedEnough(viewRect) {
if (!viewRect) return false; // sky/horizon — keep the current render
if (!layerState._lastViewRect) return true; // previous render was global fallback
const lastWidth = Cesium.Rectangle.computeWidth(layerState._lastViewRect);
const lastHeight = Cesium.Rectangle.computeHeight(layerState._lastViewRect);
const width = Cesium.Rectangle.computeWidth(viewRect);
const height = Cesium.Rectangle.computeHeight(viewRect);
const lastWidth = rectangleWidth(layerState._lastViewRect);
const lastHeight = rectangleHeight(layerState._lastViewRect);
const width = rectangleWidth(viewRect);
const height = rectangleHeight(viewRect);
if (width > lastWidth * 1.3 || width < lastWidth * 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.scratchCenterA,
);
const center = Cesium.Rectangle.center(viewRect, layerState.scratchCenterB);
const center = rectangleCenter(viewRect, layerState.scratchCenterB);
const latShift = Math.abs(center.latitude - lastCenter.latitude);
let lonShift = Math.abs(center.longitude - lastCenter.longitude);
if (lonShift > Math.PI) lonShift = geo.math.TWO_PI - lonShift;
@@ -139,10 +150,12 @@ export function createViewport({
layerState._moveEndSettleRemover =
layerState._viewer.camera.moveEnd.addEventListener(() => {
if (!layerState._enabled) return;
setTimeout(
() => governorRequestRender('firms-lod-settle'),
LOD_CHECK_MS + 40,
);
clearSettleTimer();
settleTimer = setTimeout(() => {
settleTimer = null;
if (layerState._enabled && !layerState._destroyed)
governorRequestRender('firms-lod-settle');
}, LOD_CHECK_MS + 40);
});
}
layerState._preRenderRemover =
@@ -194,6 +207,7 @@ export function createViewport({
}
function removeLodWatcher() {
clearSettleTimer();
if (layerState._preRenderRemover) {
layerState._preRenderRemover();
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();
});