From c4a97a653c1bc4135a4a9736690a75008bdc0e78 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 04:18:53 +0400 Subject: [PATCH] Keep fire and camera selections bound to their source records --- scripts/qa-fire-camera-switch.mjs | 137 +++++++++++++++++++ src/app/constructCatalog.js | 6 +- src/layers/alpr/index.js | 37 ++++- src/layers/alpr/render/cesium.js | 4 +- src/layers/alpr/render/cesiumPresentation.js | 4 +- src/layers/alpr/render/navara.js | 8 +- src/layers/firms/lifecycle.js | 55 ++++++-- 7 files changed, 220 insertions(+), 31 deletions(-) create mode 100644 scripts/qa-fire-camera-switch.mjs diff --git a/scripts/qa-fire-camera-switch.mjs b/scripts/qa-fire-camera-switch.mjs new file mode 100644 index 000000000..12ce71c5d --- /dev/null +++ b/scripts/qa-fire-camera-switch.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer'; +import { installAlprFixture } from './fixtures/w2/alpr.mjs'; +import { firmsFixture } from './fixtures/w2/firms.mjs'; +import { switchGlobe } from './qa-switch-globe.mjs'; +import { blockPaidQaMaps } from './qa-map-policy.mjs'; +const url = new URL(process.argv[2]), + fire = process.argv.includes('--fires'), + id = fire ? 'local-firms' : 'alpr-cameras'; +if ( + !['localhost', '127.0.0.1'].includes(url.hostname) || + ['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( + url.port, + ) +) + throw Error('Supply local keyless preview URL'); +url.search = '?engine=navara&welcome=0'; +url.hash = + 'v=2&' + + (fire + ? 'lat=37.77&lon=-122.42&alt=100000' + : 'lat=30.262&lon=-97.7431&alt=5500') + + '&pitch=-90&heading=0&map=sentinel2&l='; +const b = await puppeteer.launch({ + headless: true, + args: ['--use-angle=metal', '--enable-gpu'], +}); +try { + const p = await b.newPage(); + await blockPaidQaMaps(p); + await p.setViewport({ width: 1280, height: 900 }); + const errors = []; + p.on('pageerror', (e) => errors.push(e.message)); + await installAlprFixture(p); + await p.evaluateOnNewDocument((data) => { + window.qaFirmsReads = 0; + const fetch = window.fetch.bind(window); + window.fetch = (input, opt) => { + if ( + new URL(input.url ?? input, location.href).pathname === '/api/firms' + ) { + window.qaFirmsReads++; + return Promise.resolve(Response.json(data)); + } + return fetch(input, opt); + }; + }, firmsFixture()); + await p.goto(url.href); + await p.waitForFunction( + () => + window.__godsEyeView?.engine.kind === 'navara' && + document.querySelector('#loading-screen.hidden'), + ); + await p.evaluate(async (id) => { + const { dataManager, styleManager, tileset } = window.__godsEyeView; + if (window.__GOOGLE_MAPS_API_KEY__ || tileset) + throw Error('Keyless preview required'); + await styleManager.initialRestorePromise; + window.qaGround = dataManager.layers.get(id).module; + await dataManager.setEnabled(id, true, { origin: 'user' }); + }, id); + await p.waitForFunction(() => window.qaGround?.getStats().count > 0, { + timeout: 30000, + }); + const target = await p.waitForFunction( + (fire) => { + const a = window.__godsEyeView, + m = window.qaGround; + const points = fire + ? m + .getDetectableObjects({ maxCount: 1000 }) + .filter((v) => v.id === m.getAnalystRecords(1)[0]?.id) + : [...window.__gevContextStore.entities.values()] + .filter((v) => v.layerId === 'alpr-cameras') + .map((v) => ({ + id: v.id, + position: + v.entity.gevAlprCanvasPosition || + v.entity.gevDisplayPosition?.(), + })); + for (const row of points) { + if (!row.position) continue; + const s = a.engine.projector.worldToWindow(row.position); + if (!s || s.x < 430 || s.x > 850 || s.y < 220 || s.y > 730) continue; + const picked = a.engine.picker.pick(s); + if (fire || picked?.id === row.id || picked?.id?.id === row.id) + return { x: s.x, y: s.y, id: row.id }; + } + return false; + }, + { timeout: 30000 }, + fire, + ); + const xy = await target.jsonValue(); + await new Promise(r=>setTimeout(r,1000)); + await p.mouse.click(xy.x, xy.y); + await p.waitForFunction( + (id,fire) => fire ? window.qaGround.getSelectedInfo()?.frp === 999 : window.__gevContextStore?.selectedEntityId === id, + {}, + xy.id, fire, + ); + await new Promise((r) => setTimeout(r, 1600)); + const expectedId = fire ? await p.evaluate(()=>window.qaGround.getSelectedInfo().id) : xy.id; + const before = await p.evaluate(() => ({ + stamp: window.qaGround.getStats().lastUpdate, + records: window.qaGround.getAnalystRecords(1000).map((v) => v.id), + reads: window.qaFirmsReads, + })); + for (const kind of ['cesium', 'navara']) { + await switchGlobe(p, kind); + const result = await p.evaluate( + (id) => ({ + same: + window.qaGround === + window.__godsEyeView.dataManager.layers.get(id).module, + selected: window.__gevContextStore.selectedEntityId, + record: window.qaGround.getSelectedInfo?.()?.id, + stamp: window.qaGround.getStats().lastUpdate, + records: window.qaGround.getAnalystRecords(1000).map((v) => v.id), + reads: window.qaFirmsReads, + count: window.qaGround.getStats().count, + }), + id, + ); + console.log(kind, id, result.selected, result.count, result.records.length); + assert.ok(result.same && result.count); + assert.equal(result.selected, expectedId); + if (fire) assert.equal(result.record, expectedId); + assert.equal(result.reads, before.reads); + assert.deepEqual(result.records, before.records); + assert.equal(result.stamp, before.stamp); + } + assert.deepEqual(errors, []); + console.log('PASS retained source, stable clicked record on each planet'); +} finally { + await b.close(); +} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index ca7843be4..c87962e3f 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -219,7 +219,7 @@ export function createApplicationCatalog({ ], [ 'alpr-cameras', - () => createApplicationAlpr({ surface, source: sources.alpr }), + () => sessionModel('alpr-cameras', () => createApplicationAlpr({ surface, source: sources.alpr })), ], [ 'street-level', @@ -326,14 +326,14 @@ export function createApplicationCatalog({ [ 'local-firms', () => - createApplicationFirms({ + sessionModel('local-firms', () => createApplicationFirms({ surface, id: 'local-firms', name: 'FIRMS Active Fires', icon: '▲', source: 'NASA FIRMS · LIVE', feed: sources.firms, - }), + })), ], ]; const available = (id) => availability.isAvailable(id); diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index 13a77716f..127ae48a4 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -68,6 +68,7 @@ export function createAlprCamerasLayer({ renderSaturated: false, }; let renderer = null; + let restoringView = false; function setAlprStatus(status, error = null) { if (state.status === status && state.error === error) return; @@ -112,7 +113,7 @@ export function createAlprCamerasLayer({ } function scheduleLoad() { - if (!state.enabled) return; + if (!state.enabled || !renderer || restoringView) return; clearUnavailableRetry({ resetBackoff: false }); clearTimeout(state.debounceTimer); // A view the accepted tiles already cover is a local filter: show it now. @@ -130,7 +131,7 @@ export function createAlprCamerasLayer({ } async function loadCameras() { - if (!state.enabled || !renderer?.ready) return; + if (!state.enabled || !renderer?.ready || restoringView) return; clearTimeout(state.debounceTimer); state.debounceTimer = null; clearUnavailableRetry({ resetBackoff: false }); @@ -197,14 +198,14 @@ export function createAlprCamerasLayer({ state.lastQueryBox = null; state.lastQueryZoom = null; clearUnavailableRetry(); - renderer.renderRecords(); + if (!restoringView) renderer?.renderRecords(); setAlprStatus('zoom-in'); return; } const { records, stale, saturated } = validateAlprSnapshot(snapshot); // Resolve marker floors (bounded wait, warm cells answer at once) so new // markers are placed on the floor instead of clamped then moved. - await renderer.prepareFloors(records); + await renderer?.prepareFloors(records); if ( requestAbort.signal.aborted || state.abort !== requestAbort || @@ -230,8 +231,8 @@ export function createAlprCamerasLayer({ clearUnavailableRetry(); // Stale and saturated are independent facts; a cached response that also // hit the cap must still warn about coverage, not just about freshness. - renderer.renderRecords(); - setAlprStatus(renderer.count() ? (stale ? 'stale' : 'ready') : 'empty'); + if (!restoringView) renderer?.renderRecords(); + setAlprStatus((renderer?.count() ?? records.length) ? (stale ? 'stale' : 'ready') : 'empty'); } catch (error) { if ( error?.name === 'AbortError' || @@ -265,7 +266,11 @@ export function createAlprCamerasLayer({ source: source.label || msg('Mapped camera locations'), updateInterval: 0, statsRefreshInterval: 1000, - init(viewer, options = {}) { + init(viewer, options) { + this.attachRenderer(viewer, options); + }, + attachRenderer(viewer, options = {}) { + restoringView = Boolean(options.waitForCameraRestore); if (renderer?.ready) throw new Error('ALPR layer is already initialized'); if (typeof rendererFactory !== 'function') throw new TypeError('An ALPR renderer factory is required'); @@ -276,6 +281,24 @@ export function createAlprCamerasLayer({ engine: options.engine, }); renderer.init(viewer, { onMove: scheduleLoad }); + if (state.enabled) { + renderer.setVisible(true); + if (!restoringView) renderer.renderRecords(); + } + }, + detachRenderer() { + restoringView = true; + clearTimeout(state.debounceTimer); + renderer?.destroy({ retainContext: true }); + renderer = null; + }, + resumeViewport() { + if (!restoringView) return; + restoringView = false; + if (state.enabled && renderer) { + renderer.renderRecords(); + if (!state.loading) scheduleLoad(); + } }, enable() { if (state.enabled) return; diff --git a/src/layers/alpr/render/cesium.js b/src/layers/alpr/render/cesium.js index 54e719df8..3c6d7fbe2 100644 --- a/src/layers/alpr/render/cesium.js +++ b/src/layers/alpr/render/cesium.js @@ -44,7 +44,7 @@ export function createAlprCesiumRenderer({ state, services, source }) { if (visible) native.creditPresented = false; if (native.dataSource) native.dataSource.show = visible; }, - destroy() { + destroy(options) { presentation.destroyOverlay(); removeMove?.(); removeMove = null; @@ -52,7 +52,7 @@ export function createAlprCesiumRenderer({ state, services, source }) { removeFrame = null; native.clickHandler?.destroy(); native.clickHandler = null; - presentation.clearRendered(); + presentation.clearRendered(options); if (native.dataSource && native.viewer) native.viewer.dataSources.remove(native.dataSource, true); native.dataSource = null; diff --git a/src/layers/alpr/render/cesiumPresentation.js b/src/layers/alpr/render/cesiumPresentation.js index 10a4b31a1..39965190e 100644 --- a/src/layers/alpr/render/cesiumPresentation.js +++ b/src/layers/alpr/render/cesiumPresentation.js @@ -304,7 +304,7 @@ export function createAlprPresentation({ state, native, services, source }) { return { south, west, north, east }; } - function clearRendered() { + function clearRendered({ retainContext = false } = {}) { hideOnMapCredit(); floors.cancel(); floorResolve = null; @@ -312,7 +312,7 @@ export function createAlprPresentation({ state, native, services, source }) { visibleRecords = []; clampedMarkers.clear(); if (native.dataSource?.entities) native.dataSource.entities.removeAll(); - removeEntityContextsForLayer(LAYER_ID); + if (!retainContext) removeEntityContextsForLayer(LAYER_ID); } function hideOnMapCredit() { diff --git a/src/layers/alpr/render/navara.js b/src/layers/alpr/render/navara.js index a70e38120..fcb5e8fd2 100644 --- a/src/layers/alpr/render/navara.js +++ b/src/layers/alpr/render/navara.js @@ -449,7 +449,7 @@ export function createAlprNavaraRenderer(engine, { state, services, source }) { else hideOnMapCredit(); request(); } - function clearRendered() { + function clearRendered({ retainContext = false } = {}) { hideOnMapCredit(); generation++; floors.cancel(); @@ -464,7 +464,7 @@ export function createAlprNavaraRenderer(engine, { state, services, source }) { selected.commit(); spritesDirty = false; } - services.context.removeEntityContextsForLayer(LAYER_ID); + if (!retainContext) services.context.removeEntityContextsForLayer(LAYER_ID); } function frame() { const camera = engine.camera.livePosition; @@ -582,9 +582,9 @@ export function createAlprNavaraRenderer(engine, { state, services, source }) { request(); return true; }, - destroy() { + destroy(options) { if (!initialized) return; - clearRendered(); + clearRendered(options); overlay.destroy(); removeClick?.(); removeClick = null; diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index b29939b3b..d25af749c 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -12,7 +12,10 @@ export function createLifecycle({ const { id, overlayHost } = config; const methods = { - init(viewer, { engine = null } = {}) { + init(viewer, options) { + this.attachRenderer(viewer, options); + }, + attachRenderer(viewer, { engine = null, waitForCameraRestore = false } = {}) { if (layerState._destroyed) return; if (layerState._renderer) return; layerState._renderer = config.renderer.create({ @@ -25,14 +28,36 @@ export function createLifecycle({ layerState._viewer = layerState._renderer.view; layerState._dataSource = layerState._renderer.dataSource; layerState._renderer.setVisible(false); + layerState._restoringViewport = waitForCameraRestore; + if (layerState._enabled && !waitForCameraRestore) { + this.showRenderer(); + layerState._renderer.onEnable(); + } }, - async enable(viewer) { - if (layerState._destroyed) return; - layerState._enabled = true; - if (!layerState._dataSource) this.init(viewer); - const renderer = layerState._renderer; - layerState._viewer = layerState._renderer.view; + resumeViewport() { + if (!layerState._restoringViewport) return; + layerState._restoringViewport = false; + if (layerState._enabled && layerState._renderer) { + this.showRenderer(); + layerState._renderer.onEnable(); + } + }, + + detachRenderer() { + components.selection.removeClickHandler(); + components.viewport.removeMoveEndWatcher(); + components.viewport.removeLodWatcher(); + unregisterPickOwner(id); + components.rendering.removeDetectionCollections(layerState._viewer); + layerState._renderer?.destroy(); + layerState._renderer = layerState._viewer = layerState._dataSource = null; + layerState._pickIndexById.clear(); + layerState._labelCandidates = []; + layerState._camSnapValid = false; + }, + + showRenderer() { 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 @@ -51,14 +76,18 @@ export function createLifecycle({ registerPickOwner(id, (pickedId) => layerState._pickIndexById.has(pickedId), ); + components.rendering.renderCurrentLod(true); + }, + + async enable(viewer) { + if (layerState._destroyed) return; + if (!layerState._dataSource) this.init(viewer); + layerState._enabled = true; + const renderer = layerState._renderer; + this.showRenderer(); if (!layerState._fires.length && !layerState._loading) await components.ingestion.loadHeatmap(); - if ( - layerState._enabled && - !layerState._destroyed && - layerState._renderer === renderer - ) - renderer.onEnable(); + if (layerState._enabled && layerState._renderer === renderer) renderer.onEnable(); }, disable() {