diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index ad57a5931..802890154 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -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/', diff --git a/scripts/fixtures/w2/firms.mjs b/scripts/fixtures/w2/firms.mjs new file mode 100644 index 000000000..addddb095 --- /dev/null +++ b/scripts/fixtures/w2/firms.mjs @@ -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 }; +} diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 9597fdf73..9a6b82f86 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b2fce2320..558054881 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/scripts/qa-firms.mjs b/scripts/qa-firms.mjs index d32ede533..8f38045d5 100644 --- a/scripts/qa-firms.mjs +++ b/scripts/qa-firms.mjs @@ -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)); } diff --git a/src/app/layers/firms.js b/src/app/layers/firms.js index 6e6414878..631b4e152 100644 --- a/src/app/layers/firms.js +++ b/src/app/layers/firms.js @@ -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, diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index 3c026f034..41e98c8ea 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -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, diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index 72a1d2c71..804b06c18 100644 --- a/src/app/layers/renderers.test.mjs +++ b/src/app/layers/renderers.test.mjs @@ -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', diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index ec0e08624..a070a04fc 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -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, [ diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index 442af60b2..2044b1a29 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -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, diff --git a/src/data/spriteOrder.test.mjs b/src/data/spriteOrder.test.mjs index 878593413..438abccdc 100644 --- a/src/data/spriteOrder.test.mjs +++ b/src/data/spriteOrder.test.mjs @@ -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', ); }); diff --git a/src/layers/firms/cards.js b/src/layers/firms/cards.js index 6b8a72ab6..eb2ba1572 100644 --- a/src/layers/firms/cards.js +++ b/src/layers/firms/cards.js @@ -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 || diff --git a/src/layers/firms/index.js b/src/layers/firms/index.js index 080755872..c7a19937d 100644 --- a/src/layers/firms/index.js +++ b/src/layers/firms/index.js @@ -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 }; diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 7e0b42a00..0b0a744aa 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -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 = []; diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 407204f06..3bd1e1680 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -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); } /** diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 673aab871..fe2fab377 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -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 }, ]; diff --git a/src/layers/firms/rectangle.js b/src/layers/firms/rectangle.js new file mode 100644 index 000000000..5e7e55c64 --- /dev/null +++ b/src/layers/firms/rectangle.js @@ -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, + }; +} diff --git a/src/layers/firms/rectangle.test.mjs b/src/layers/firms/rectangle.test.mjs new file mode 100644 index 000000000..f05aee458 --- /dev/null +++ b/src/layers/firms/rectangle.test.mjs @@ -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, + }); + } +}); diff --git a/src/layers/firms/render/cesium.js b/src/layers/firms/render/cesium.js new file mode 100644 index 000000000..0ccb20449 --- /dev/null +++ b/src/layers/firms/render/cesium.js @@ -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); + }, + }; +} diff --git a/src/layers/firms/render/cesium.test.mjs b/src/layers/firms/render/cesium.test.mjs new file mode 100644 index 000000000..8a5ff6f1d --- /dev/null +++ b/src/layers/firms/render/cesium.test.mjs @@ -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); +}); diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 8ae445f91..c1c0a402d 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -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); } /** diff --git a/src/layers/firms/selection.js b/src/layers/firms/selection.js index 5665c615a..43e52e0e8 100644 --- a/src/layers/firms/selection.js +++ b/src/layers/firms/selection.js @@ -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() { diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index 7a2e58b77..603d3453a 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -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; diff --git a/src/layers/firms/viewport.js b/src/layers/firms/viewport.js index 0270def94..dba1e5042 100644 --- a/src/layers/firms/viewport.js +++ b/src/layers/firms/viewport.js @@ -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; diff --git a/src/layers/firms/viewport.test.mjs b/src/layers/firms/viewport.test.mjs new file mode 100644 index 000000000..921917f31 --- /dev/null +++ b/src/layers/firms/viewport.test.mjs @@ -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(); +});