From a2905eb7001b4b6c0476bce99c88f2cf762423b4 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 16:33:29 +0400 Subject: [PATCH] Move the engine switch protocol from the layers into the scene binding attachRenderer takes one context { engine, signal, viewRestored, ... } and bindCatalog no longer takes a viewer; no layer module receives Cesium's viewer any more, and a rehearsal check fails if one reads it. The handoff between engines is one typed object (layers, camera owners, selection, director) created when the old scene leaves, replacing the session's handoffLayers, restoringView and selectionContext. viewRestored resolves where the camera is restored, and the nine layers that kept their own restoring-view flag wait on it instead. After attach the binding applies a disabled layer's visibility, registers the picks each module declares, and resumes the camera owners, so engineSwitch.js no longer names directions or satellites. --- scripts/engine-coupling-baseline.json | 10 +- src/app/data.js | 10 +- src/app/layerSession.js | 12 +- src/app/layerSession.test.mjs | 14 ++- src/app/layers/displayRehearsal.test.mjs | 2 +- src/app/layers/lifecycleRehearsal.test.mjs | 75 ++++++------- src/app/scene.js | 6 +- src/app/sessionHandoff.js | 34 ++++++ src/data/bhoteKoshiEvent.js | 18 ++- src/data/bhoteKoshiEvent.test.mjs | 6 +- src/data/bhoteKoshiLocator.edgeCases.test.mjs | 4 +- src/data/bhoteKoshiLocator.js | 20 +++- src/data/layerSceneBinding.js | 67 +++++++++-- src/data/layerStateCoordinator.js | 18 +-- src/data/layerStateHandoffOptions.test.mjs | 28 ++--- src/data/lifecycleScene.test.mjs | 102 +++++++++++------ src/data/osmOverlays.js | 4 +- src/data/osmOverlays.test.mjs | 4 +- src/data/sceneServices.test.mjs | 4 +- src/data/trackedReadoutScenes.test.mjs | 33 ++++-- src/layers/airQuality/index.js | 10 +- src/layers/airQuality/ownership.test.mjs | 2 +- src/layers/aircraft/sceneBinding.js | 35 ++++-- src/layers/alpr/index.js | 44 ++++---- src/layers/awareness/lifecycle.js | 4 +- src/layers/bikeshare/edgeCases.test.mjs | 2 +- src/layers/bikeshare/index.js | 14 ++- src/layers/bikeshare/viewport.js | 6 +- src/layers/cctv/attachDisabled.test.mjs | 2 +- src/layers/cctv/lifecycle.js | 58 +++++----- src/layers/cyclones/index.js | 15 +-- src/layers/cyclones/index.test.mjs | 2 +- src/layers/directions/edgeCases.test.mjs | 4 +- src/layers/directions/index.js | 27 +++-- src/layers/directions/render/display.test.mjs | 2 +- src/layers/droneAirspace/index.js | 10 +- src/layers/droneAirspace/ownership.test.mjs | 2 +- src/layers/earthquakes/index.js | 10 +- src/layers/earthquakes/ownership.test.mjs | 2 +- .../earthquakes/render/display.test.mjs | 2 +- src/layers/firms/lifecycle.js | 31 ++++-- src/layers/firms/queries.js | 2 +- src/layers/flights/lifecycle.js | 13 ++- src/layers/flights/ownership.test.mjs | 4 +- src/layers/flights/renderer.test.mjs | 12 +- src/layers/ghostMaps/edgeCases.test.mjs | 4 +- src/layers/ghostMaps/index.js | 17 +-- src/layers/ghostMaps/lifecycle.test.mjs | 2 +- src/layers/ghostMaps/pendingPaint.test.mjs | 2 +- src/layers/gnss/index.js | 9 +- src/layers/gnss/ownership.test.mjs | 2 +- src/layers/infrastructure/index.js | 4 +- src/layers/infrastructure/session.test.mjs | 8 +- src/layers/installations/index.js | 9 +- src/layers/launches/lifecycle.js | 43 ++++--- src/layers/launches/render/display.js | 1 + src/layers/launches/render/display.test.mjs | 11 +- src/layers/localAdsb/edgeCases.test.mjs | 2 +- src/layers/localAdsb/index.js | 19 ++-- src/layers/localAdsb/index.test.mjs | 2 +- src/layers/military/lifecycle.js | 13 ++- src/layers/military/renderer.test.mjs | 10 +- src/layers/osmPlaces/index.js | 12 +- src/layers/osmPlaces/render/display.test.mjs | 2 +- src/layers/perimeters/index.js | 10 +- src/layers/perimeters/ownership.test.mjs | 2 +- src/layers/radio/lifecycle.js | 8 +- src/layers/radio/render/display.js | 3 +- src/layers/radio/render/display.test.mjs | 5 + src/layers/recentImagery/index.js | 4 +- src/layers/satellites/cockpitSubject.test.mjs | 18 ++- src/layers/satellites/controls.js | 5 +- src/layers/satellites/detachedToggle.test.mjs | 4 +- src/layers/satellites/edgeCases.test.mjs | 2 +- src/layers/satellites/lifecycle.js | 47 +++++--- src/layers/streetLevel/index.js | 41 ++++--- src/layers/streetLevel/lineCoverage.js | 2 +- src/layers/streetLevel/lineCoverage.test.mjs | 12 ++ src/layers/streetLevel/sequenceCones.js | 2 +- src/layers/streetLevel/sequenceCones.test.mjs | 2 +- src/layers/submarineCables/session.js | 4 +- src/layers/submarineCables/session.test.mjs | 6 +- src/layers/traffic/handoff.test.mjs | 1 - src/layers/traffic/lifecycle.js | 19 ++-- src/layers/traffic/viewport.js | 4 +- src/layers/transit/lifecycle.js | 31 ++++-- src/layers/transit/viewport.js | 8 +- src/layers/vessels/lifecycle.js | 31 +++--- src/layers/vessels/renderer.test.mjs | 2 +- src/layers/weather/index.js | 4 +- src/layers/wildlifeSightings/lifecycle.js | 19 ++-- .../wildlifeSightings/ownership.test.mjs | 2 +- src/layers/wind/index.js | 7 +- src/layers/wind/index.test.mjs | 4 +- src/main.js | 1 - src/standalone/engineSwitch.js | 38 ++----- src/standalone/engineSwitch.test.mjs | 29 +++-- src/standalone/sceneSwitcher.js | 22 ++-- src/standalone/sceneSwitcher.test.mjs | 105 +++++++++++++++++- src/ui/applicationShell.js | 4 +- src/ui/shareRestoration.js | 8 +- src/ui/shareRestorationEngine.test.mjs | 86 +++++++++++++- src/ui/shellScene.js | 4 +- src/voice/voiceWorkflowHarness.mjs | 2 +- 104 files changed, 1029 insertions(+), 537 deletions(-) create mode 100644 src/app/sessionHandoff.js diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index 3d2914c0f..b1a1487fc 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -1,16 +1,16 @@ { "metrics": { - "attachRendererWithViewer": 33, - "restoreFlagCopies": 9, + "attachRendererWithViewer": 0, + "restoreFlagCopies": 0, "surfaceCallsOutsideGlobe": 41, - "engineSwitchLayerIds": 3, + "engineSwitchLayerIds": 0, "cesiumLayerRendererLines": 0, "navaraLayerRendererLines": 0, "cesiumImportFiles": 3, - "viewerLines": 784, + "viewerLines": 735, "navaraKindChecks": 1, "engineSniffCalls": 0, - "engineExtensionReads": 3, + "engineExtensionReads": 4, "groundEffectPasses": 0, "cesiumSdkLoads": 3, "screenSpaceHandlerFiles": 2, diff --git a/src/app/data.js b/src/app/data.js index bab002c61..3bcabaa30 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -91,12 +91,10 @@ export function createApplicationData({ current = nextCatalog; for (const layer of nextCatalog.layers) layer.attachMapStackController?.(next.mapStackController); - const binding = dataManager.bindCatalog( - next.viewer, - next.engine, - nextCatalog, - { waitForCameraRestore: layerSession?.restoringView === true }, - ); + dataManager.viewer = next.viewer; + const binding = dataManager.bindCatalog(next.engine, nextCatalog, { + viewRestored: layerSession?.viewRestored, + }); for (const layer of nextCatalog.layers) layer.attachDataManager?.(dataManager); presentation.mount( diff --git a/src/app/layerSession.js b/src/app/layerSession.js index a6be32659..fda0cd851 100644 --- a/src/app/layerSession.js +++ b/src/app/layerSession.js @@ -15,10 +15,9 @@ * @property {{draw?: ReturnType, geoImport?: ReturnType}} tools Session tools. * @property {ReturnType | null} voice Session voice. * @property {{locale?: () => void, placeSearch?: object, sceneChrome?: boolean}} chrome Standalone startup and scene factory. - * @property {Map | null} handoffLayers Switch coordinator: outgoing layer intentions; visit restores the incoming address's options instead of keeping the live options. - * @property {boolean} restoringView Switch coordinator: incoming address restoration. + * @property {import('./sessionHandoff.js').SessionHandoff | null} handoff Switch coordinator: retained intentions and scene owners. + * @property {Promise} viewRestored Switch coordinator: incoming camera restoration. * @property {() => boolean} switching Switch coordinator: voice waits while busy. - * @property {object | null} selectionContext Scene factory: selected record carried to the next attachment. * @property {object | null} contextScene Shell: retained Context state. */ @@ -37,15 +36,14 @@ export function createLayerSession() { tools: {}, voice: null, chrome: {}, - handoffLayers: null, - restoringView: false, + handoff: null, + viewRestored: Promise.resolve(), switching: () => false, contextScene: null, - selectionContext: null, }; } /** Whether teardown preserves the session for another globe. */ export function retainsLayerSession(session) { - return Boolean(session?.handoffLayers && !session.lifetime.signal.aborted); + return Boolean(session?.handoff && !session.lifetime.signal.aborted); } diff --git a/src/app/layerSession.test.mjs b/src/app/layerSession.test.mjs index 15ff8cc54..3b5654287 100644 --- a/src/app/layerSession.test.mjs +++ b/src/app/layerSession.test.mjs @@ -1,9 +1,10 @@ +import { createSessionHandoff } from './sessionHandoff.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createLayerSession, retainsLayerSession } from './layerSession.js'; import { handOffSession, resumeSession } from '../standalone/engineSwitch.js'; -test('a session retains layer intentions through handoff but never after page teardown', () => { +test('a session retains layer intentions through handoff but never after page teardown', async () => { const session = createLayerSession(); const other = createLayerSession(); const calls = []; @@ -17,21 +18,24 @@ test('a session retains layer intentions through handoff but never after page te session.manager = { layers: new Map([['flights', entry]]), isEffectivelyEnabled: () => true, + resumeScene: () => calls.push('viewport'), }; session.director = { suspendScene: () => calls.push('suspend'), resumeScene: () => calls.push('resume'), }; assert.equal(retainsLayerSession(session), false); - handOffSession(session, { addressLayerIds: ['flights'] }); + session.handoff = createSessionHandoff(session, { + addressLayerIds: ['flights'], + }); + handOffSession(session); assert.equal(retainsLayerSession(session), true); - assert.deepEqual(session.handoffLayers.get('flights'), { - entry, + assert.deepEqual(session.handoff.layers.get('flights'), { enabled: true, durable: true, visit: false, }); - resumeSession(session); + await resumeSession(session); assert.deepEqual(calls, ['suspend', 'viewport', 'resume']); assert.equal(session.models.get('flights'), entry.module); assert.equal(other.models.size, 0); diff --git a/src/app/layers/displayRehearsal.test.mjs b/src/app/layers/displayRehearsal.test.mjs index a9eb4fcd4..b4ec8621c 100644 --- a/src/app/layers/displayRehearsal.test.mjs +++ b/src/app/layers/displayRehearsal.test.mjs @@ -256,7 +256,7 @@ const cases = { return make(value); }, }); - layer.attachRenderer(null, { engine }); + layer.attachRenderer({ engine }); const renderer = context.parts.renderer; const station = { id: '00000000-0000-4000-8000-000000000000', diff --git a/src/app/layers/lifecycleRehearsal.test.mjs b/src/app/layers/lifecycleRehearsal.test.mjs index 2d6024437..7cbf808f5 100644 --- a/src/app/layers/lifecycleRehearsal.test.mjs +++ b/src/app/layers/lifecycleRehearsal.test.mjs @@ -1,3 +1,4 @@ +import { LayerLifecycle } from '../../data/lifecycle.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import { createApplicationCatalog } from '../constructCatalog.js'; @@ -30,11 +31,6 @@ const retainedCredits = { }; const primitives = ['movers', 'lines', 'ground', 'imagery', 'meshes']; -const expectedReaders = { - 'bhote-koshi-2026': ['scene'], // The imagery split control still reads the native scene. - 'ais-live-vessels': ['isDestroyed'], // Sprite ordering still accepts the native viewer. -}; - function sourceFixtures(calls) { return Object.fromEntries( Object.entries(CATALOG_SOURCE_CONTRACTS).map(([id, { methods }]) => [ @@ -274,7 +270,6 @@ test('every catalog layer releases its scene and attaches to another engine', as const discoveryLifetime = new AbortController(); const discovery = buildCatalog(discoveryLifetime, [], new Map()); t.after(() => discoveryLifetime.abort()); - const seenReaders = []; const seenCredits = new Set(); for (const { id } of discovery.layers) { await t.test(id, async (t) => { @@ -284,20 +279,23 @@ test('every catalog layer releases its scene and attaches to another engine', as const visibility = new Map(); const catalog = buildCatalog(lifetime, calls, visibility); const layer = catalog.get(id); + assert.doesNotMatch( + layer.attachRenderer.toString(), + /attachRenderer\s*\(\s*(?:viewer|_viewer|nextViewer|_scene|scene)\b/, + 'attachRenderer accepts one engine context', + ); + const manager = new LayerLifecycle(null); + manager.register(layer); + const entry = manager.layers.get(id); + entry.initialized = true; + const attachedCatalog = { + layers: [layer], + metadata: catalog.metadata.filter((item) => item.id === id), + }; const a = strictEngine(); const b = strictEngine(); const beforeA = a.resources(); const beforeB = b.resources(); - const reads = new Set(); - const poisoned = new Proxy( - {}, - { - get(_target, property) { - reads.add(String(property)); - throw new Error(`viewer read: ${String(property)}`); - }, - }, - ); const drive = async (scene) => { scene.engine.fake.emit('camera.moveStart'); scene.engine.fake.emit('camera.changed'); @@ -320,19 +318,16 @@ test('every catalog layer releases its scene and attaches to another engine', as ].includes(layer.id) ) await layer.init(null); - const attach = async (engine, options = {}) => { - try { - await layer.attachRenderer(poisoned, { engine, ...options }); - } catch (error) { - if ( - !expectedReaders[layer.id]?.includes([...reads].at(-1)) || - !error.message.startsWith('viewer read:') - ) - throw error; - await layer.detachRenderer(); - await layer.attachRenderer(null, { engine, ...options }); - } + const attachRenderer = layer.attachRenderer; + layer.attachRenderer = function (context) { + return attachRenderer.call(this, new Proxy(context, { + get(target, property) { + assert.notEqual(property, 'viewer', 'attachment context must not read a viewer'); + return Reflect.get(target, property); + }, + })); }; + const attach = (engine, options = {}) => manager.bindCatalog(engine, attachedCatalog, { services: sceneServicesFor(engine), ...options }); await attach(a.engine); await flushLifecycleTasks(t); if (layer.id === 'cctv') @@ -342,9 +337,10 @@ test('every catalog layer releases its scene and attaches to another engine', as 'disabled CCTV markers must be hidden after attach', ); assertHidden(a.engine); + entry.enabled = true; await layer.enable(); await drive(a); - await layer.detachRenderer(); + await manager.detachScene(); if (retainedCredits[id]) { beforeA.credits = [ { html: retainedCredits[id].html, onScreen: false }, @@ -363,7 +359,9 @@ test('every catalog layer releases its scene and attaches to another engine', as const destroyed = a.snapshot(); const sourceCount = calls.length; const networkCount = environment.network.length; - await attach(b.engine, { waitForCameraRestore: true }); + let restoreView; + const viewRestored = new Promise((resolve) => { restoreView = resolve; }); + await attach(b.engine, { viewRestored }); await drive(b); if (layer.resumeViewport) { assert.deepEqual( @@ -377,17 +375,19 @@ test('every catalog layer releases its scene and attaches to another engine', as 'no transport requests before resumeViewport', ); } - await layer.resumeViewport?.(); + restoreView(); + await manager.resumeScene(); await drive(b); if (id === 'local-firms') assert.ok( calls.slice(sourceCount).some((call) => call.id === 'firms'), 'empty FIRMS cache is fetched after camera restoration', ); + entry.enabled = false; await layer.disable(); await drive(b); assertHidden(b.engine); - await layer.detachRenderer(); + await manager.detachScene(); if (retainedCredits[id]) beforeB.credits = [ { html: retainedCredits[id].html, onScreen: false }, @@ -404,12 +404,6 @@ test('every catalog layer releases its scene and attaches to another engine', as ); assert.deepEqual(a.violations, []); assert.deepEqual(b.violations, []); - assert.deepEqual( - [...reads].sort(), - expectedReaders[layer.id] ?? [], - 'viewer reader table must only shrink', - ); - if (reads.size) seenReaders.push(id); } finally { lifetime.abort(); for (const entry of catalog.layers) await entry.destroy?.(); @@ -420,11 +414,6 @@ test('every catalog layer releases its scene and attaches to another engine', as } }); } - assert.deepEqual( - seenReaders.sort(), - Object.keys(expectedReaders).sort(), - 'remove stale viewer reader entries', - ); assert.deepEqual( [...seenCredits].sort(), Object.keys(retainedCredits).sort(), diff --git a/src/app/scene.js b/src/app/scene.js index 57920e142..2e84485c4 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -87,14 +87,14 @@ export async function createApplicationScene({ ); defer(worldClock.subscribe(() => engine.render.request('world-time'))); const services = createSceneServices(engine, { - retainedSelection: layerSession?.selectionContext, + retainedSelection: layerSession?.handoff?.selection, }); - if (layerSession) layerSession.selectionContext = null; + if (layerSession?.handoff) layerSession.handoff.selection = null; defer(() => { const retained = services.destroy({ retainSelection: retainsLayerSession(layerSession), }); - if (layerSession) layerSession.selectionContext = retained; + if (layerSession?.handoff) layerSession.handoff.selection = retained; }); if (layerSession) { // DEM cells belong to the session. Mesh samples came from the outgoing diff --git a/src/app/sessionHandoff.js b/src/app/sessionHandoff.js new file mode 100644 index 000000000..216dacc55 --- /dev/null +++ b/src/app/sessionHandoff.js @@ -0,0 +1,34 @@ +/** + * @typedef {object} SessionHandoff + * @property {Map} layers + * @property {string[]} cameraOwners + * @property {object | null} selection + * @property {import('../scenes/director.js').SceneDirector | null} director + */ + +/** Capture session intentions before any outgoing scene owner is released. + * @returns {SessionHandoff} + */ +export function createSessionHandoff( + session, + { addressLayerIds = null, visit = false } = {}, +) { + const listed = addressLayerIds ? new Set(addressLayerIds) : null; + return { + layers: new Map( + [...session.manager.layers].map(([id]) => [ + id, + { + enabled: session.manager.isEffectivelyEnabled(id), + durable: listed ? listed.has(id) : undefined, + visit, + }, + ]), + ), + cameraOwners: [...session.manager.layers] + .filter(([, entry]) => entry.module.cameraOwner) + .map(([id]) => id), + selection: null, + director: session.director, + }; +} diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index 5082bc48e..73b5754fe 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -1010,6 +1010,7 @@ export function createBhoteKoshiEventLayer({ panelStore = useBhoteKoshiPanelStore(applicationPinia()), } = {}) { let _viewer = null; + let _attachmentContext = null; overlayHost = bindOverlayHost(overlayHost, () => _engine); let _engine = null; let _native = null; @@ -2690,6 +2691,7 @@ export function createBhoteKoshiEventLayer({ function applyCinematicCamera() { if ( + _attachmentContext?.viewReady === false || !_cinematicActive || !cameraForScene() || _cinematicKeyframes.length === 0 @@ -3538,16 +3540,18 @@ export function createBhoteKoshiEventLayer({ }); } - async function attachRenderer(viewer, { engine = null, signal = null } = {}) { + async function attachRenderer(context = {}) { + const { engine = null, signal = null } = context; + _attachmentContext = context; const generation = ++_attachGeneration; const current = () => generation === _attachGeneration && signal?.aborted !== true; const stack = _mapStackController; - _viewer = viewer; + _viewer = null; _engine = engine; // The engine's display (src/layers/bhoteKoshi/render/display.js). _native = createNativeRenderer(engine); - _embeddedMedia?.attachScene?.(viewer, engine); + _embeddedMedia?.attachScene?.(null, engine); if (!_enabled) return; // Its enable has loaded the SDK; a restored attachment waits for it too. if (!cesiumSdkReady()) { @@ -3622,7 +3626,7 @@ export function createBhoteKoshiEventLayer({ } async function init(viewer, options) { - await attachRenderer(viewer, options); + await attachRenderer(options); _embeddedMedia = embeddedMediaFactory({ viewer, engine: _engine }); await loadEvent(); console.log('[Data:BhoteKoshi] Event pack ready'); @@ -4020,6 +4024,12 @@ export function createBhoteKoshiEventLayer({ updateInterval: 0, init, attachRenderer, + cameraOwner: { + suspend() { + _lastCinematicProgress = null; + }, + resume: applyCinematicCamera, + }, detachRenderer, enable, disable, diff --git a/src/data/bhoteKoshiEvent.test.mjs b/src/data/bhoteKoshiEvent.test.mjs index 3e16b77d3..129e868b1 100644 --- a/src/data/bhoteKoshiEvent.test.mjs +++ b/src/data/bhoteKoshiEvent.test.mjs @@ -3070,7 +3070,7 @@ for (const stop of ['detach', 'scene-abort']) { const gate = new Promise((resolve) => { openGate = resolve; }); layer.attachMapStackController(stack('photoreal', gate)); const scene = new AbortController(); - const attaching = layer.attachRenderer(second, { signal: scene.signal }); + const attaching = layer.attachRenderer({ signal: scene.signal }); let detaching = null; if (stop === 'detach') detaching = layer.detachRenderer(); else scene.abort(); @@ -3121,7 +3121,7 @@ test('Bhote Koshi stops waiting for a stalled map switch when its scene is relea setStack: (id) => (id === 'esri-imagery' ? new Promise(() => {}) : Promise.resolve()), }); const scene = new AbortController(); - const attaching = layer.attachRenderer(second, { signal: scene.signal }); + const attaching = layer.attachRenderer({ signal: scene.signal }); scene.abort(); await attaching; await layer.detachRenderer(); @@ -3196,7 +3196,7 @@ test('Bhote Koshi republishes its evidence cards to the incoming engine overlay' 'first engine got the cards', ); await layer.detachRenderer(); - await layer.attachRenderer(second, { engine: engineB }); + await layer.attachRenderer({ engine: engineB }); const b = hostFor(engineB); assert.ok( [...b.entries.values()].some((e) => e.length > 0), diff --git a/src/data/bhoteKoshiLocator.edgeCases.test.mjs b/src/data/bhoteKoshiLocator.edgeCases.test.mjs index 66fb27baa..c28847104 100644 --- a/src/data/bhoteKoshiLocator.edgeCases.test.mjs +++ b/src/data/bhoteKoshiLocator.edgeCases.test.mjs @@ -65,7 +65,7 @@ function locator(engine) { cancelDelay() {}, }); const scene = engine === 'cesium' ? viewer : null; - layer.attachRenderer(scene, { + layer.attachRenderer({ engine: engine === 'navara' ? navara : null, }); return { @@ -175,7 +175,7 @@ test('edge:bhote-koshi-locator: an engine swap while the boundary resolves lands layer.attachRenderer(viewer); const enabling = layer.enable(viewer); layer.detachRenderer(); - layer.attachRenderer(null, { + layer.attachRenderer({ engine: { kind: 'navara', render: { request() {} }, diff --git a/src/data/bhoteKoshiLocator.js b/src/data/bhoteKoshiLocator.js index 5f6562473..3648955e4 100644 --- a/src/data/bhoteKoshiLocator.js +++ b/src/data/bhoteKoshiLocator.js @@ -1043,22 +1043,27 @@ export function createBhoteKoshiLocatorLayer({ showOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID); } - function attachRenderer(viewer, { engine = null } = {}) { - _viewer = viewer; + function attachRenderer({ engine = null, viewRestored } = {}) { + _viewer = null; _engine = engine; if (!_enabled) return true; connectDisplay(); publishOverlayEntries({ moving: _placeRevealMoving }); + if (!viewRestored) resumeCamera(); + return true; + } + + function resumeCamera() { + if (!_enabled || !_engine) return; if (_pausedApproach) { const { phase, remaining } = _pausedApproach; _pausedApproach = null; if (phase === 'armed') armTriggerApproach(); else flyTriggerStage((_triggerApproachAttempt = {}), phase, remaining); } - return true; } - function detachRenderer() { + function suspendCamera() { const phase = _triggerApproachAttempt?.phase; if (phase && phase !== 'finished') { _pausedApproach = { @@ -1067,6 +1072,10 @@ export function createBhoteKoshiLocatorLayer({ }; clearTriggerApproach({ preserveCallout: true }); } + } + + function detachRenderer() { + suspendCamera(); _native?.destroy(); _native = null; hideOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID); @@ -1151,8 +1160,9 @@ export function createBhoteKoshiLocatorLayer({ icon: '◎', source: 'OpenStreetMap + GeoPera', updateInterval: 0, - init: attachRenderer, + init: (_viewer, context) => attachRenderer(context), attachRenderer, + cameraOwner: { suspend: suspendCamera, resume: resumeCamera }, detachRenderer, enable, disable, diff --git a/src/data/layerSceneBinding.js b/src/data/layerSceneBinding.js index f621e9ca2..d5e873f13 100644 --- a/src/data/layerSceneBinding.js +++ b/src/data/layerSceneBinding.js @@ -2,6 +2,21 @@ // A late attachment sees its scene signal aborted and releases its own work. export const ATTACH_SETTLE_TIMEOUT_MS = 5000; +/** One readiness gate shared by every layer attached to this scene. */ +export function createLayerSceneContext(options = {}) { + let viewReady = !options.viewRestored; + const context = { + ...options, + get viewReady() { + return viewReady && options.signal?.aborted !== true; + }, + viewRestored: Promise.resolve(options.viewRestored).then(() => { + viewReady = true; + }), + }; + return context; +} + /** Scene attachment and parked work for the layer lifecycle. */ export class LayerSceneBinding { constructor(attachSettleTimeoutMs) { @@ -12,6 +27,7 @@ export class LayerSceneBinding { this._sceneController = null; this._sceneDetached = false; this._pendingAttachments = new Set(); + this._viewResumptions = []; this._attachSettleTimeoutMs = attachSettleTimeoutMs; // Lifecycle work and explicit refreshes that wait for the next scene. // detachScene() does not wait for parked work, which waits for it. @@ -92,16 +108,19 @@ export class LayerSceneBinding { } /** Bind the next catalog only after the preceding scene released its entries. */ - bindCatalog(viewer, engine, catalog, options = {}) { + bindCatalog(engine, catalog, options = {}) { if (this._registrationsFinalized) throw new Error('Scene already attached'); - this.viewer = viewer; this.engine = engine ?? null; this._catalogSourceAvailability = catalog.getSourceAvailability; const scene = new AbortController(); this._sceneController = scene; // An attachment that is still running when the scene detaches sees this // signal aborted and must stop touching the scene. - const context = { ...this._moduleContext(scene.signal), ...options }; + const context = createLayerSceneContext({ + ...this._moduleContext(scene.signal), + ...options, + }); + this._viewResumptions = []; const attachments = []; for (const layer of catalog.layers) { const entry = this.layers.get(layer.id); @@ -110,7 +129,7 @@ export class LayerSceneBinding { if (entry.module !== layer) throw new Error(`Layer owner changed: ${layer.id}`); if (entry.initialized) { - attachments.push(this._attachEntryRenderer(entry, viewer, context)); + attachments.push(this._attachEntryRenderer(entry, context)); } } } @@ -221,7 +240,7 @@ export class LayerSceneBinding { * the previous scene is retried first. Asynchronous attachments are tracked * so detachScene() releases them only after they stopped. */ - _attachEntryRenderer(entry, viewer, context) { + _attachEntryRenderer(entry, context) { let ready; try { const pending = this._failedReleases.get(entry); @@ -232,13 +251,40 @@ export class LayerSceneBinding { .then(() => { this._failedReleases.delete(entry); if (context.signal.aborted) return undefined; - return entry.module.attachRenderer(viewer, context); + return entry.module.attachRenderer(context); }); - } else ready = entry.module.attachRenderer(viewer, context); + } else ready = entry.module.attachRenderer(context); } catch (error) { ready = Promise.reject(error); } - if (!ready?.then) return ready; + const attached = () => { + if (context.signal.aborted) return; + entry.module.show?.(entry.enabled); + const picks = entry.module.picks; + if (picks) { + const picking = context.services?.picking; + picking?.registerPickOwner( + picks.owner, + (id) => entry.enabled && picks.predicate(id), + ); + context.signal.addEventListener( + 'abort', + () => picking?.unregisterPickOwner(picks.owner), + { once: true }, + ); + } + const resumed = context.viewRestored.then(() => { + if (context.signal.aborted || entry.sceneDetached) return; + if (entry.module.cameraOwner) return entry.module.cameraOwner.resume(); + return entry.module.resumeViewport?.(); + }); + // Resume failures belong to the switch transaction, including when the + // camera settles before the switcher starts awaiting its owners. + resumed.catch(() => {}); + this._viewResumptions.push(resumed); + }; + if (!ready?.then) return attached(); + ready = Promise.resolve(ready).then(attached); const tracked = Promise.resolve(ready); this._pendingAttachments.add(tracked); const untrack = () => this._pendingAttachments.delete(tracked); @@ -246,6 +292,11 @@ export class LayerSceneBinding { return tracked; } + /** Await camera owners after the switcher resolves the incoming view. */ + resumeScene() { + return Promise.all(this._viewResumptions); + } + /** Run the periodic refreshes that came due while no scene was bound. */ _runDeferredRefreshes() { for (const [layerId, entry] of this.layers) { diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index 85f2a64cd..7fd5f2f7f 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -120,7 +120,7 @@ export class LayerStateCoordinator { start({ shareLayerState = null, allowLocalState = true, - handoffLayers = null, + handoff = null, shareCreatedAtMs = null, } = {}) { if (this._destroyed) @@ -156,12 +156,12 @@ export class LayerStateCoordinator { ); this.shareLinkManager?.onLayerStateChange?.(); this._notifyDurableState(); - if (!selected && !handoffLayers) return this.restorePromise; + if (!selected && !handoff) return this.restorePromise; this.restorePromise = this._restoreSelectedState( this._source === 'share' ? LAYER_RESTORE_ORIGINS.share : LAYER_RESTORE_ORIGINS.local, - handoffLayers, + handoff, ); return this.restorePromise; } @@ -400,7 +400,7 @@ export class LayerStateCoordinator { : this.restoreGate); } - async _restoreSelectedState(origin, handoffLayers = null) { + async _restoreSelectedState(origin, handoff = null) { // One controller set per run: a superseded run must not clear the newer // run's controllers when it settles. const controllers = new Map(); @@ -414,7 +414,8 @@ export class LayerStateCoordinator { const settled = await Promise.allSettled( LAYER_STATE_REGISTRY.map(async (entry) => { const controller = controllers.get(entry.id); - const live = handoffLayers?.get(entry.id); + const live = handoff?.layers.get(entry.id); + const retained = this.dataManager.layers.get(entry.id); const targetEnabled = handoffTargetEnabled( live, enabled.has(entry.id), @@ -447,9 +448,10 @@ export class LayerStateCoordinator { // apply; a pick keeps the live layer as it is. if ( live && - this.dataManager.layers.get(entry.id) === live.entry && - !live.entry.lifecycleUncertain && - live.entry.enabled === targetEnabled + retained?.module.attachRenderer && + retained.initialized && + !retained.lifecycleUncertain && + retained.enabled === targetEnabled ) { const current = this.dataManager.getLayerParams(entry.id); const changed = diff --git a/src/data/layerStateHandoffOptions.test.mjs b/src/data/layerStateHandoffOptions.test.mjs index c995d9c9d..e1b602ae4 100644 --- a/src/data/layerStateHandoffOptions.test.mjs +++ b/src/data/layerStateHandoffOptions.test.mjs @@ -1,3 +1,4 @@ +import { createSessionHandoff } from '../app/sessionHandoff.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { DataLayerManager } from './manager.js'; @@ -68,31 +69,16 @@ test('same-engine Back to a gfs entry restores gfs (reference)', async () => { assert.equal(m.getLayerParams('wind').model, 'gfs'); }); -const handoffOf = (m) => - new Map( - [...m.layers].map(([id, entry]) => [ - id, - { entry, enabled: m.isEffectivelyEnabled(id), durable: id === 'wind' }, - ]), - ); +const handoffOf = (manager, visit = false) => + createSessionHandoff({ manager, director: null }, { addressLayerIds: ['wind'], visit }); test('cross-engine Back to a gfs entry restores gfs', async () => { const m = manager(); await m.setEnabled('wind', true, { origin: 'user' }); m.setLayerParams('wind', { model: 'ifs' }, { origin: 'user' }); - const handoffLayers = new Map( - [...m.layers].map(([id, entry]) => [ - id, - { - entry, - enabled: m.isEffectivelyEnabled(id), - durable: id === 'wind', - visit: true, - }, - ]), - ); + const handoff = handoffOf(m, true); const c = new LayerStateCoordinator(m, sink(), { storage: storage() }); - await c.start({ shareLayerState: cesiumEntry, handoffLayers }); + await c.start({ shareLayerState: cesiumEntry, handoff }); assert.equal(c.getDurableState().options.wind.model, 'gfs'); assert.equal( m.getLayerParams('wind').model, @@ -107,7 +93,7 @@ test('a picked engine keeps the live layer options, not the address', async () = m.setLayerParams('wind', { model: 'ifs' }, { origin: 'user' }); const writes = windParamWrites; const c = new LayerStateCoordinator(m, sink(), { storage: storage() }); - await c.start({ shareLayerState: cesiumEntry, handoffLayers: handoffOf(m) }); + await c.start({ shareLayerState: cesiumEntry, handoff: handoffOf(m) }); assert.equal(windParamWrites, writes, 'a pick writes no params'); assert.equal(m.getLayerParams('wind').model, 'ifs'); }); @@ -120,7 +106,7 @@ test('an engine switch leaves a retained layer whose options match alone', async const c = new LayerStateCoordinator(m, sink(), { storage: storage() }); const results = await c.start({ shareLayerState: cesiumEntry, - handoffLayers: handoffOf(m), + handoff: handoffOf(m), }); assert.equal(windParamWrites, writes, 'no params write for equal options'); const wind = results.find((result) => result.layerId === 'wind'); diff --git a/src/data/lifecycleScene.test.mjs b/src/data/lifecycleScene.test.mjs index 40dbb00dc..add4e36ae 100644 --- a/src/data/lifecycleScene.test.mjs +++ b/src/data/lifecycleScene.test.mjs @@ -13,12 +13,12 @@ function sceneLayer(id, hooks = {}) { source: 'test', init(viewer, context) { calls.push('init'); - module.attachRenderer(viewer, context); + module.attachRenderer(context); return true; }, - attachRenderer(viewer, context) { - calls.push(['attach', viewer]); - return hooks.attach?.(viewer, context); + attachRenderer(context) { + calls.push(['attach', context.engine]); + return hooks.attach?.(context.engine?.name === 'second' ? secondViewer : firstViewer, context); }, detachRenderer(viewer) { calls.push(['detach', viewer]); @@ -50,6 +50,11 @@ const engine = (name) => ({ kind: 'test', name }); const firstViewer = { name: 'first viewer' }; const secondViewer = { name: 'second viewer' }; +function bindCatalog(manager, engine, catalog, options) { + manager.viewer = engine?.name === 'second' ? secondViewer : firstViewer; + return manager.bindCatalog(engine, catalog, options); +} + async function boundManager(...layers) { const options = layers.at(-1)?.module ? {} : layers.pop(); const manager = new LayerLifecycle(firstViewer, { @@ -57,7 +62,7 @@ async function boundManager(...layers) { ...options, }); const modules = layers.map(({ module }) => module); - await manager.bindCatalog(firstViewer, engine('first'), catalog(...modules)); + await bindCatalog(manager, engine('first'), catalog(...modules)); for (const { module } of layers) assert.equal( await manager.setEnabled(module.id, true, { origin: 'user' }), @@ -115,14 +120,14 @@ test('one failing detachRenderer leaves the others released and the manager unbo assert.equal(detaches(after.calls).length, 1); // The next scene binds and both retained layers attach to it. - await manager.bindCatalog( - secondViewer, + await bindCatalog( + manager, engine('second'), catalog(...modules), ); assert.equal(manager.viewer, secondViewer); - assert.deepEqual(attaches(broken.calls).at(-1), ['attach', secondViewer]); - assert.deepEqual(attaches(after.calls).at(-1), ['attach', secondViewer]); + assert.deepEqual(attaches(broken.calls).at(-1), ['attach', engine('second')]); + assert.deepEqual(attaches(after.calls).at(-1), ['attach', engine('second')]); assert.equal(manager.isEnabled('broken'), true); }); @@ -139,15 +144,15 @@ test('bindCatalog after a failed detach retries the release before attaching', a AggregateError, ); - await manager.bindCatalog( - secondViewer, + await bindCatalog( + manager, engine('second'), catalog(...modules), ); assert.deepEqual(broken.calls.slice(-3), [ ['detach', firstViewer], ['detach', firstViewer], - ['attach', secondViewer], + ['attach', engine('second')], ]); // The scene is settled: the next detach releases the second viewer once. await manager.detachScene(); @@ -180,8 +185,8 @@ test('an asynchronous attach detached mid-flight stops before the release', asyn const { manager, modules } = await boundManager(slow); await manager.detachScene(); - const bound = manager.bindCatalog( - secondViewer, + const bound = bindCatalog( + manager, engine('second'), catalog(...modules), ); @@ -218,8 +223,8 @@ test('periodic refreshes due while no scene is bound run after the next bind', a assert.equal(updates(), before, 'no refresh against a detached scene'); assert.equal(entry.refreshDeferred, true); - await manager.bindCatalog( - secondViewer, + await bindCatalog( + manager, engine('second'), catalog(...modules), ); @@ -245,8 +250,8 @@ test('a legacy layer that cannot be destroyed is dropped so the next catalog bin }; const retained = sceneLayer('retained'); const manager = new LayerLifecycle(firstViewer, { engine: engine('first') }); - await manager.bindCatalog( - firstViewer, + await bindCatalog( + manager, engine('first'), catalog(legacy, retained.module), ); @@ -263,8 +268,8 @@ test('a legacy layer that cannot be destroyed is dropped so the next catalog bin assert.deepEqual(detaches(retained.calls), [['detach', firstViewer]]); const fresh = { ...legacy, disable: () => true }; - await manager.bindCatalog( - secondViewer, + await bindCatalog( + manager, engine('second'), catalog(fresh, retained.module), ); @@ -277,8 +282,8 @@ test('QA layers are reported when a scene switch drops them', async () => { engine: engine('first'), allowQaRegistration: true, }); - await manager.bindCatalog( - firstViewer, + await bindCatalog( + manager, engine('first'), catalog(retained.module), ); @@ -322,7 +327,7 @@ test('a detach does not wait forever for an attachment that never settles', asyn }); await manager.detachScene(); const bound = quietly(() => - manager.bindCatalog(secondViewer, engine('second'), catalog(...modules)), + bindCatalog(manager, engine('second'), catalog(...modules)), ); await bound; assert.equal(manager.viewer, secondViewer, 'the bind went on without it'); @@ -345,8 +350,8 @@ test('a refresh due while attachments run is deferred once, not run twice', asyn const entry = manager.layers.get('feed'); const before = updatesOf(feed.calls).length; await manager.detachScene(); - const bound = manager.bindCatalog( - secondViewer, + const bound = bindCatalog( + manager, engine('second'), catalog(...modules), ); @@ -372,8 +377,8 @@ test('a normal refresh clears a deferred one', async () => { assert.equal(entry.refreshDeferred, false); manager._sceneDetached = true; const before = updatesOf(feed.calls).length; - await manager.bindCatalog( - secondViewer, + await bindCatalog( + manager, engine('second'), catalog(...modules), ); @@ -391,8 +396,8 @@ test('an explicit refresh between scenes waits for the next one and runs once', const refreshed = manager.refreshLayer('feed'); await tick(); assert.equal(updatesOf(feed.calls).length, before); - await manager.bindCatalog( - secondViewer, + await bindCatalog( + manager, engine('second'), catalog(...modules), ); @@ -424,9 +429,9 @@ test('a layer enabled between scenes initializes on the next scene', async () => const enabled = manager.setEnabled('late', true, { origin: 'user' }); await tick(); assert.deepEqual(late.calls, [], 'no init against a missing scene'); - await manager.bindCatalog(secondViewer, engine('second'), catalog(...all)); + await bindCatalog(manager, engine('second'), catalog(...all)); assert.equal(await enabled, true); - assert.deepEqual(late.calls.slice(0, 2), ['init', ['attach', secondViewer]]); + assert.deepEqual(late.calls.slice(0, 2), ['init', ['attach', engine('second')]]); }); test('a detach does not deadlock on an enable parked for the next scene', async () => { @@ -442,10 +447,10 @@ test('a detach does not deadlock on an enable parked for the next scene', async const other = sceneLayer('other'); const manager = new LayerLifecycle(firstViewer, { engine: engine('first') }); const modules = [slow.module, other.module]; - await manager.bindCatalog(firstViewer, engine('first'), catalog(...modules)); + await bindCatalog(manager, engine('first'), catalog(...modules)); await manager.setEnabled('slow', true, { origin: 'user' }); await manager.detachScene(); - void manager.bindCatalog(secondViewer, engine('second'), catalog(...modules)); + void bindCatalog(manager, engine('second'), catalog(...modules)); // Enabled while the second scene is still attaching: parked. const enabled = manager.setEnabled('other', true, { origin: 'user' }); await tick(); @@ -457,3 +462,34 @@ test('a detach does not deadlock on an enable parked for the next scene', async assert.equal(await manager.destroyLayer('other'), true); assert.equal(await enabled, false); }); + +test('the binding applies visibility and pick declarations and cancels a detached resume', async () => { + const { createFakeEngine } = await import('../globe/testing/fakeEngine.js'); + const { sceneServicesFor } = await import('./sceneServices.js'); + const globe = createFakeEngine(); + const services = sceneServicesFor(globe); + const calls = []; + let restore; + const viewRestored = new Promise((resolve) => { restore = resolve; }); + const layer = sceneLayer('declared').module; + layer.show = (enabled) => calls.push(['show', enabled]); + layer.picks = { owner: 'declared', predicate: (id) => id === 'marker' }; + layer.cameraOwner = { suspend() {}, resume: () => calls.push('resume') }; + const manager = new LayerLifecycle(null); + manager.register(layer); + const entry = manager.layers.get(layer.id); + entry.initialized = true; + await manager.bindCatalog(globe, catalog(layer), { viewRestored, services }); + assert.deepEqual(calls, [['show', false]]); + assert.equal(services.picking.findPickOwner('marker'), null); + entry.enabled = true; + assert.equal(services.picking.findPickOwner('marker'), 'declared'); + await manager.detachScene(); + restore(); + await manager.resumeScene(); + assert.deepEqual(calls, [['show', false]]); + assert.equal(services.picking.owners().includes('declared'), false); + await manager.destroyAll(); + services.destroy(); + globe.destroy(); +}); diff --git a/src/data/osmOverlays.js b/src/data/osmOverlays.js index 9d245bf93..e706c5282 100644 --- a/src/data/osmOverlays.js +++ b/src/data/osmOverlays.js @@ -125,10 +125,10 @@ export function createOsmOverlayLayer(overlay) { source: overlay.source, updateInterval: 0, init(viewer, options) { - this.attachRenderer(viewer, options); + this.attachRenderer(options); }, /** Drape on a globe engine's imagery. */ - attachRenderer(_viewer, { engine: nextEngine = null } = {}) { + attachRenderer({ engine: nextEngine = null } = {}) { release(); engine = nextEngine; sync(); diff --git a/src/data/osmOverlays.test.mjs b/src/data/osmOverlays.test.mjs index 68b6a50c1..d9d49fa9d 100644 --- a/src/data/osmOverlays.test.mjs +++ b/src/data/osmOverlays.test.mjs @@ -108,7 +108,7 @@ test('destroy and a renderer detach remove the owned imagery', () => { layer.enable(); layer.detachRenderer(); assert.equal(layers().length, 0); - layer.attachRenderer(null, { engine }); + layer.attachRenderer({ engine }); assert.equal( layers().length, 1, @@ -140,7 +140,7 @@ test('on Navara an overlay drapes through engine.maps and follows the globe', () // An engine switch: the drape leaves with the old globe, comes back on the next. seaMarks.detachRenderer(); assert.equal(added[0].removed, true); - seaMarks.attachRenderer(null, { engine: engineWith() }); + seaMarks.attachRenderer({ engine: engineWith() }); assert.equal(added.length, 2); assert.equal(added[1].removed, false); seaMarks.disable(); diff --git a/src/data/sceneServices.test.mjs b/src/data/sceneServices.test.mjs index 9c8fa61d6..a8bcbff13 100644 --- a/src/data/sceneServices.test.mjs +++ b/src/data/sceneServices.test.mjs @@ -45,7 +45,7 @@ for (const makeHarness of GROUND_HARNESSES) { assert.equal(b.picking.findPickOwner('camera'), null); layer.detachRenderer(); assert.equal(a.picking.findPickOwner('camera'), null); - layer.attachRenderer(null, { engine: second.engine }); + layer.attachRenderer({ engine: second.engine }); assert.equal(b.picking.findPickOwner('camera'), 'alpr-cameras'); layer.disable(); assert.equal(b.picking.findPickOwner('camera'), null); @@ -185,7 +185,7 @@ for (const makeHarness of GROUND_HARNESSES) { assert.equal(b.overlay.getDiagnostics().entryCount, 0); layer.detachRenderer(); assert.equal(a.overlay.getDiagnostics().entryCount, 0); - layer.attachRenderer(null, { engine: second.engine }); + layer.attachRenderer({ engine: second.engine }); assert.equal(requests, 1); assert.equal(b.overlay.getDiagnostics().entryCount, 1); a.destroy(); diff --git a/src/data/trackedReadoutScenes.test.mjs b/src/data/trackedReadoutScenes.test.mjs index 4f3e323da..ab299e487 100644 --- a/src/data/trackedReadoutScenes.test.mjs +++ b/src/data/trackedReadoutScenes.test.mjs @@ -1,3 +1,4 @@ +import { createLayerSceneContext } from './layerSceneBinding.js'; import * as geo from '../geo/index.js'; import { createApplicationScene } from '../app/scene.js'; import { createLayerSession } from '../app/layerSession.js'; @@ -275,7 +276,12 @@ for (const makeHarness of GROUND_HARNESSES) { }, }, }; - session.handoffLayers = new Map(); + session.handoff = { + layers: new Map(), + cameraOwners: [], + selection: null, + director: null, + }; const cleanups = []; const open = (engine) => createApplicationScene({ @@ -297,10 +303,10 @@ for (const makeHarness of GROUND_HARNESSES) { const outgoing = await open(first.engine); const record = select(outgoing.services.context, subject()); cleanups.pop()(); - assert.equal(session.selectionContext, record); + assert.equal(session.handoff.selection, record); assert.equal(getSelectedEntityContext(), record); const incoming = await open(second.engine); - assert.equal(session.selectionContext, null); + assert.equal(session.handoff.selection, null); assert.equal(incoming.services.context.getSelectedEntityContext(), record); assert.equal(getSelectedEntityContext(), record); session.lifetime.abort(); @@ -451,7 +457,7 @@ for (const makeHarness of GROUND_HARNESSES) { assert.equal(hosts[0].last().title, 'LOCAL2'); layer.detachRenderer(); assert.equal(a.trackedReadout.activeId(), null); - await layer.attachRenderer(null, { engine: second.engine }); + await layer.attachRenderer({ engine: second.engine }); assert.equal(layer.selectById('abc123'), true); assert.equal(b.trackedReadout.activeId(), 'local-adsb:abc123'); assert.equal(a.trackedReadout.activeId(), null); @@ -490,7 +496,12 @@ for (const makeHarness of GROUND_HARNESSES) { }, }, }; - session.handoffLayers = new Map(); + session.handoff = { + layers: new Map(), + cameraOwners: [], + selection: null, + director: null, + }; const cleanups = []; const open = (engine) => createApplicationScene({ @@ -564,12 +575,16 @@ for (const makeHarness of GROUND_HARNESSES) { const selectedAt = window.__gevContextStore.selectedAt; layer.detachRenderer(); cleanups.pop()(); - assert.equal(session.selectionContext.id, id); + assert.equal(session.handoff.selection.id, id); const incoming = await open(second.engine); - layer.attachRenderer(null, { + let restoreView; + const attachmentContext = createLayerSceneContext({ engine: second.engine, - waitForCameraRestore: true, + viewRestored: new Promise((resolve) => { + restoreView = resolve; + }), }); + layer.attachRenderer(attachmentContext); // The shell initializes readouts before restoring the camera and resuming layers. incoming.services.trackedReadout.init(); assert.equal(window.__gevContextStore.selectedEntityId, id); @@ -577,6 +592,8 @@ for (const makeHarness of GROUND_HARNESSES) { second.engine.camera.setView({ destination: geo.cartesian3.fromDegrees(fire.lon, fire.lat, 100_000), }); + restoreView(); + await attachmentContext.viewRestored; layer.resumeViewport(); assert.equal(layer.getSelectedInfo().id, id); assert.equal(incoming.services.context.getSelectedEntityContext().id, id); diff --git a/src/layers/airQuality/index.js b/src/layers/airQuality/index.js index e4f36cd83..660bcc1b2 100644 --- a/src/layers/airQuality/index.js +++ b/src/layers/airQuality/index.js @@ -72,19 +72,23 @@ export function createAirQualityLayer({ throw new Error('Air quality layer is already initialized'); initialized = true; try { - if (engine) this.attachRenderer(viewer, { engine }); + if (engine) this.attachRenderer({ engine }); } catch (error) { this.destroy(); throw error; } }, - attachRenderer(viewer, { engine = null } = {}) { + show(enabled) { + renderer?.setVisible(enabled); + overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, enabled); + }, + attachRenderer({ engine = null } = {}) { if (!initialized || renderer) throw new Error('Invalid air quality scene attachment'); sceneEngine = engine; renderer = rendererFactory({ engine }); - renderer.setVisible(_enabled); + this.show(_enabled); overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, _enabled); if (_readings.length) this._render(); }, diff --git a/src/layers/airQuality/ownership.test.mjs b/src/layers/airQuality/ownership.test.mjs index bbfdfb5f6..1e9a0ee58 100644 --- a/src/layers/airQuality/ownership.test.mjs +++ b/src/layers/airQuality/ownership.test.mjs @@ -280,7 +280,7 @@ test('station data stays queryable and attaches to a fresh scene without polling assert.equal(await h.layer.update(), true); assert.equal(h.events.length, 0); assert.equal(h.layer.getAnalystRecords()[0].value, 7); - h.layer.attachRenderer(null, { engine: h.engine }); + h.layer.attachRenderer({ engine: h.engine }); assert.equal(h.events.at(-1)[0], 'air-quality'); assert.equal(h.ground.set('air-quality').items.length, 1); assert.equal(polls, 1); diff --git a/src/layers/aircraft/sceneBinding.js b/src/layers/aircraft/sceneBinding.js index 07e967a41..bb933f513 100644 --- a/src/layers/aircraft/sceneBinding.js +++ b/src/layers/aircraft/sceneBinding.js @@ -29,16 +29,10 @@ export function createAircraftSceneBinding({ } const methods = { - attachRenderer(viewer, { engine = null } = {}) { - if (!flightState.initialized) - throw new Error('Initialize the flight layer before attaching'); - if (flightState._renderer) - throw new Error('Flight renderer already attached'); - const renderer = services.renderer.create({ viewer, engine }); - flightState._renderer = renderer; - try { - renderer.fleet.attach(); - parts.snapshots.presentRecords(); + cameraOwner: { + suspend() {}, + resume() { + if (!flightState._renderer) return; if (flightState._trackedIcao) { parts.tracking._trackFlight(flightState._trackedIcao, { origin: resumeOrigin, @@ -46,15 +40,32 @@ export function createAircraftSceneBinding({ follow: resumeFollow, }); } + if (flightState.enabled) parts.tracking._applyPendingTrackingRestore(); + }, + }, + show(enabled) { + flightState._renderer?.fleet.setVisible(enabled); + flightState._renderer?.models.setVisible(enabled); + }, + attachRenderer({ engine = null, viewRestored } = {}) { + if (!flightState.initialized) + throw new Error('Initialize the flight layer before attaching'); + if (flightState._renderer) + throw new Error('Flight renderer already attached'); + const renderer = services.renderer.create({ engine }); + flightState._renderer = renderer; + try { + renderer.fleet.attach(); + parts.snapshots.presentRecords(); renderer.fleet.setVisible(flightState.enabled); renderer.models.setVisible(flightState.enabled); renderer.fleet.restoreOrder(); + if (!viewRestored) methods.cameraOwner.resume(); if (flightState.enabled) { enable(); - parts.tracking._applyPendingTrackingRestore(); } } catch (error) { - methods.detachRenderer(viewer); + methods.detachRenderer(); throw error; } }, diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index e4ed9cac6..6b91c1e79 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -69,7 +69,7 @@ export function createAlprCamerasLayer({ }; let renderer = null; let sceneEngine = null; - let restoringView = false; + let sceneContext = null; function setAlprStatus(status, error = null) { if (state.status === status && state.error === error) return; @@ -114,7 +114,8 @@ export function createAlprCamerasLayer({ } function scheduleLoad() { - if (!state.enabled || !renderer || restoringView) return; + if (!state.enabled || !renderer || sceneContext?.viewReady === false) + return; clearUnavailableRetry({ resetBackoff: false }); clearTimeout(state.debounceTimer); // A view the accepted tiles already cover is a local filter: show it now. @@ -132,7 +133,8 @@ export function createAlprCamerasLayer({ } async function loadCameras() { - if (!state.enabled || !renderer?.ready || restoringView) return; + if (!state.enabled || !renderer?.ready || sceneContext?.viewReady === false) + return; clearTimeout(state.debounceTimer); state.debounceTimer = null; clearUnavailableRetry({ resetBackoff: false }); @@ -199,7 +201,7 @@ export function createAlprCamerasLayer({ state.lastQueryBox = null; state.lastQueryZoom = null; clearUnavailableRetry(); - if (!restoringView) renderer?.renderRecords(); + if (sceneContext?.viewReady !== false) renderer?.renderRecords(); setAlprStatus('zoom-in'); return; } @@ -232,7 +234,7 @@ 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. - if (!restoringView) renderer?.renderRecords(); + if (sceneContext?.viewReady !== false) renderer?.renderRecords(); setAlprStatus( (renderer?.count() ?? records.length) ? stale @@ -265,6 +267,10 @@ export function createAlprCamerasLayer({ } } + const picks = { + owner: LAYER_ID, + predicate: (id) => state.recordById.has(id), + }; const alprCamerasLayer = { id: LAYER_ID, name: 'ALPR Cameras', @@ -274,10 +280,14 @@ export function createAlprCamerasLayer({ updateInterval: 0, statsRefreshInterval: 1000, init(viewer, options) { - this.attachRenderer(viewer, options); + this.attachRenderer(options); }, - attachRenderer(viewer, options = {}) { - restoringView = Boolean(options.waitForCameraRestore); + show(enabled) { + renderer?.setVisible(enabled); + }, + picks, + attachRenderer(options = {}) { + sceneContext = options; if (renderer?.ready) throw new Error('ALPR layer is already initialized'); if (typeof rendererFactory !== 'function') throw new TypeError('An ALPR renderer factory is required'); @@ -290,26 +300,20 @@ export function createAlprCamerasLayer({ }); renderer.init({ onMove: scheduleLoad }); if (state.enabled) { - registerPickOwner( - LAYER_ID, - (id) => state.recordById.has(id), - sceneEngine, - ); + registerPickOwner(picks.owner, picks.predicate, sceneEngine); renderer.setVisible(true); - if (!restoringView) renderer.renderRecords(); + if (sceneContext?.viewReady !== false) renderer.renderRecords(); } }, detachRenderer() { unregisterPickOwner(LAYER_ID, sceneEngine); - restoringView = true; + sceneContext = null; clearTimeout(state.debounceTimer); renderer?.destroy({ retainContext: true }); renderer = null; sceneEngine = null; }, resumeViewport() { - if (!restoringView) return; - restoringView = false; if (state.enabled && renderer) { renderer.renderRecords(); if (!state.loading) scheduleLoad(); @@ -318,11 +322,7 @@ export function createAlprCamerasLayer({ enable() { if (state.enabled) return; state.enabled = true; - registerPickOwner( - LAYER_ID, - (id) => state.recordById.has(id), - sceneEngine, - ); + registerPickOwner(picks.owner, picks.predicate, sceneEngine); renderer.setVisible(true); // DataLayerManager calls update() right after enable(); it owns the first fetch. }, diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js index d182aaa72..d8026bf1f 100644 --- a/src/layers/awareness/lifecycle.js +++ b/src/layers/awareness/lifecycle.js @@ -278,10 +278,10 @@ export function createLifecycle({ parts.subject.clearAwarenessSubject(); } }; - if (viewer || engine) this.attachRenderer(viewer, { engine }); + if (viewer || engine) this.attachRenderer({ engine }); }, - attachRenderer(_scene, { engine = null } = {}) { + attachRenderer({ engine = null } = {}) { detachRuntimeListeners(); layerState.display?.destroy?.(); // The engine's display from the registry (render/display.js). diff --git a/src/layers/bikeshare/edgeCases.test.mjs b/src/layers/bikeshare/edgeCases.test.mjs index 015f9b997..0f8c9aa81 100644 --- a/src/layers/bikeshare/edgeCases.test.mjs +++ b/src/layers/bikeshare/edgeCases.test.mjs @@ -81,7 +81,7 @@ function fixture() { switchEngine() { layer.detachRenderer(); engine = makeEngine(); - layer.attachRenderer(null, { engine }); + layer.attachRenderer({ engine }); }, card: () => entries.get('bikeshare-selected')?.[0] ?? null, click(key) { diff --git a/src/layers/bikeshare/index.js b/src/layers/bikeshare/index.js index 81d55c812..16605bdc4 100644 --- a/src/layers/bikeshare/index.js +++ b/src/layers/bikeshare/index.js @@ -24,9 +24,13 @@ export function createBikeshareLayer({ services, source, rendererFactory }) { let initialized = false; const lifecycle = { init(viewer, options) { - return this.attachRenderer(viewer, options); + return this.attachRenderer(options); }, - attachRenderer(viewer, options = {}) { + picks: { + owner: 'bikeshare', + predicate: (id) => state._stationRenderMap.has(id), + }, + attachRenderer(options = {}) { if (initialized) throw new Error('Bikeshare layer is already initialized'); renderer ??= rendererFactory({ @@ -37,7 +41,7 @@ export function createBikeshareLayer({ services, source, rendererFactory }) { parts.display = renderer; initialized = true; try { - state._restoringViewport = Boolean(options.waitForCameraRestore); + state._sceneContext = options; renderer.init(); if (state._enabled) { const selected = state._selectedKey; @@ -57,13 +61,11 @@ export function createBikeshareLayer({ services, source, rendererFactory }) { } }, resumeViewport() { - if (!state._restoringViewport) return; - state._restoringViewport = false; if (state._enabled) parts.display?.onCameraChanged(); }, detachRenderer() { if (!renderer) return; - state._restoringViewport = true; + state._sceneContext = null; clearTimeout(state._cameraDebounceTimer); const selected = state._selectedKey; renderer.detach(); diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index 7cbbfa650..ac9b483ce 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -165,7 +165,8 @@ export function createViewport({ state: layerState, services, parts, camera }) { */ async function runProximityCheck() { - if (!layerState._enabled || layerState._restoringViewport) return; + if (!layerState._enabled || layerState._sceneContext?.viewReady === false) + return; const generation = ++layerState._proximityGeneration; const altitude = getCameraAltitude(); @@ -236,7 +237,8 @@ export function createViewport({ state: layerState, services, parts, camera }) { /** Camera change event handler — triggers a debounced proximity check. */ function onCameraChanged() { - if (!layerState._enabled || layerState._restoringViewport) return; + if (!layerState._enabled || layerState._sceneContext?.viewReady === false) + return; scheduleProximityCheck(); } return { diff --git a/src/layers/cctv/attachDisabled.test.mjs b/src/layers/cctv/attachDisabled.test.mjs index b0b004972..a52fdea4a 100644 --- a/src/layers/cctv/attachDisabled.test.mjs +++ b/src/layers/cctv/attachDisabled.test.mjs @@ -37,7 +37,7 @@ test('disabled CCTV markers stay hidden after an engine switch', () => { }); try { cctvLayer.detachRenderer(); - cctvLayer.attachRenderer(null, { engine: second }); + cctvLayer.attachRenderer({ engine: second }); second.fake.frames(2); const set = second.fake.moverSet('cctv'); assert.ok(set, 'marker set exists'); diff --git a/src/layers/cctv/lifecycle.js b/src/layers/cctv/lifecycle.js index d5687dd20..b1935164c 100644 --- a/src/layers/cctv/lifecycle.js +++ b/src/layers/cctv/lifecycle.js @@ -32,7 +32,8 @@ export function createLifecycle({ * set changed since they were last told, including by motion passes. */ function settleView() { - if (layerState._restoringViewport || !parts.renderer?.ready) return; + if (layerState._sceneContext?.viewReady === false || !parts.renderer?.ready) + return; const changed = parts.rendering.refreshHorizonCulling(); noteViewPose(); parts.geometryQueue.enqueueViewGeometry(); @@ -242,7 +243,24 @@ export function createLifecycle({ throw error; } } + const picks = { + owner: 'cctv', + predicate: (pickedId) => { + if (layerState._recordById.has(pickedId)) return true; + if (typeof pickedId === 'string' && pickedId.startsWith(GIZMO_ID_PREFIX)) + return true; + const coverage = + /^cctv-(.+)-(?:ray-tl|ray-tr|ray-br|ray-bl|cap|plane|plane-label)$/.exec( + pickedId, + ); + return Boolean(coverage && layerState._recordById.has(coverage[1])); + }, + }; const methods = { + cameraOwner: { + suspend: () => {}, + resume: () => methods.resumeViewport(), + }, /** * Initializes the CCTV layer: loads camera sources, builds the catalog, * restores calibration from localStorage, creates billboards, sets up click @@ -421,9 +439,13 @@ export function createLifecycle({ } }, - attachRenderer(viewer, options = {}) { - const renderer = createRenderer(viewer, options); - layerState._restoringViewport = Boolean(options.waitForCameraRestore); + show(enabled) { + parts.renderer?.setMarkersVisible(enabled); + }, + picks, + attachRenderer(options = {}) { + const renderer = createRenderer(null, options); + layerState._sceneContext = options; renderer.createMarkers(); const active = parts.selection.getActiveRecord(); for (const record of layerState._records) { @@ -440,15 +462,13 @@ export function createLifecycle({ record === active && record.activationDone === true; record.activationDone = false; } - renderer.setMarkersVisible(layerState._enabled); - bindScene(viewer, options); - if (!layerState._restoringViewport && layerState._enabled) + this.show(layerState._enabled); + bindScene(null, options); + if (layerState._sceneContext?.viewReady !== false && layerState._enabled) this.resumeDisplay(); }, resumeViewport() { - if (!layerState._restoringViewport) return; - layerState._restoringViewport = false; if (layerState._enabled && parts.renderer) this.resumeDisplay(); }, @@ -467,7 +487,7 @@ export function createLifecycle({ detachRenderer() { if (!parts.renderer) return; - layerState._restoringViewport = true; + layerState._sceneContext = null; parts.renderer.hideCredit(); unregisterPickOwner('cctv', parts.renderer?.engine); if (layerState._mapStackListener) { @@ -536,23 +556,7 @@ export function createLifecycle({ parts.renderer?.showCredit(); // Pick-ownership (H2): camera billboards use the camera id directly; // coverage polyline entities use `cctv--` entity ids. - registerPickOwner( - 'cctv', - (pickedId) => { - if (layerState._recordById.has(pickedId)) return true; - if ( - typeof pickedId === 'string' && - pickedId.startsWith(GIZMO_ID_PREFIX) - ) - return true; - const coverage = - /^cctv-(.+)-(?:ray-tl|ray-tr|ray-br|ray-bl|cap|plane|plane-label)$/.exec( - pickedId, - ); - return Boolean(coverage && layerState._recordById.has(coverage[1])); - }, - parts.renderer?.engine, - ); + registerPickOwner(picks.owner, picks.predicate, parts.renderer?.engine); const activeRecord = parts.selection.getActiveRecord(); if (activeRecord) { parts.projection.ensureProjectionRuntime(activeRecord); diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index e1c7a2c14..d8c70e784 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -249,12 +249,12 @@ export function createCyclonesLayer({ owner?.destroy(); } function registerOwnership() { - registerPickOwner( - 'weather-cyclones', - (id) => enabled && rendering?.ownsPickId?.(id) === true, - engine, - ); + registerPickOwner(picks.owner, picks.predicate, engine); } + const picks = { + owner: 'weather-cyclones', + predicate: (id) => enabled && rendering?.ownsPickId?.(id) === true, + }; const layer = { id: 'weather-cyclones', name: 'Cyclone advisories', @@ -262,9 +262,10 @@ export function createCyclonesLayer({ source: 'NOAA NHC / CPHC', updateInterval: 300_000, init(_viewer, options = {}) { - if (options.engine) return layer.attachRenderer(_viewer, options); + if (options.engine) return layer.attachRenderer(options); }, - attachRenderer(_viewer, options = {}) { + picks, + attachRenderer(options = {}) { engine = options.engine ?? null; rendering = engine && createRendering diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index d6b885069..3312da24d 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -935,7 +935,7 @@ test('advisories delivered between scenes keep a deliberate selection without an pending.resolve(snapshot([{ ...storm(), advisoryNumber: '11' }])); await refresh; assert.equal(layer.getDiagnostics().selectedId, 'ep152026'); - await layer.attachRenderer(null, { engine: second }); + await layer.attachRenderer({ engine: second }); assert.equal( isOwnedByOtherLayer('flights', 'cyclone:ep152026:center', second), true, diff --git a/src/layers/directions/edgeCases.test.mjs b/src/layers/directions/edgeCases.test.mjs index 99f6e9239..4703663a6 100644 --- a/src/layers/directions/edgeCases.test.mjs +++ b/src/layers/directions/edgeCases.test.mjs @@ -163,7 +163,7 @@ test('edge:directions: an engine switch mid-FLY continues the same flight on the const next = f.makeEngine(); f.layer.suspendCamera(); f.layer.detachRenderer(); - f.layer.attachRenderer(null, { engine: next }); + f.layer.attachRenderer({ engine: next }); f.layer.resumeCamera(); // The old engine's camera is not driven any more. @@ -203,7 +203,7 @@ test('edge:directions: a swap mid-FLY lands the old flight, and the answer arriv const next = f.makeEngine(); f.layer.suspendCamera(); f.layer.detachRenderer(); - f.layer.attachRenderer(null, { engine: next }); + f.layer.attachRenderer({ engine: next }); f.layer.resumeCamera(); f.requests[1].resolve(payload(B, A)); await settle(); diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index c35d64670..8ffd5adf9 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -1504,6 +1504,11 @@ export function createDirectionsLayer({ // Layer module // --------------------------------------------------------------------------- + const picks = { + owner: 'directions', + predicate: (pickedId) => + typeof pickedId === 'string' && pickedId.startsWith('directions:'), + }; const directionsLayer = { id: 'directions', name: 'Directions', @@ -1517,12 +1522,17 @@ export function createDirectionsLayer({ * @param {{engine?: object|null}} [options] */ init(_scene, options) { - return this.attachRenderer(_scene, options); + return this.attachRenderer(options); }, - attachRenderer(_scene, { engine = null } = {}) { + show(enabled) { + _display?.show(enabled); + _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, enabled); + }, + picks, + attachRenderer({ engine = null } = {}) { _engine = engine; _display = engine ? rendererFactory(engine) : null; - _display?.show(_enabled); + this.show(_enabled); _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, _enabled); if (!_enabled) return; const step = _selectedStep; @@ -1532,6 +1542,10 @@ export function createDirectionsLayer({ if (_route) drawRoute(_route); if (step !== null) _selectStep(step); }, + cameraOwner: { + suspend: () => directionsLayer.suspendCamera(), + resume: () => directionsLayer.resumeCamera(), + }, suspendCamera() { if (!_flightId || _suspendedFlight) return; _suspendedFlight = services.camera.suspendRouteFlight?.(_flightId); @@ -1556,12 +1570,7 @@ export function createDirectionsLayer({ _display?.show(true); _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, true); _installClickHandler(); - services.picking.registerPickOwner( - 'directions', - (pickedId) => - typeof pickedId === 'string' && pickedId.startsWith('directions:'), - _engine, - ); + services.picking.registerPickOwner(picks.owner, picks.predicate, _engine); syncRenderHold(); }, diff --git a/src/layers/directions/render/display.test.mjs b/src/layers/directions/render/display.test.mjs index 146031abb..7d9b029ce 100644 --- a/src/layers/directions/render/display.test.mjs +++ b/src/layers/directions/render/display.test.mjs @@ -315,7 +315,7 @@ test('an engine switch redraws the route, dots and markers on the new engine onl t.after(() => next.destroy()); f.layer.suspendCamera(); f.layer.detachRenderer(); - f.layer.attachRenderer(null, { engine: next.engine }); + f.layer.attachRenderer({ engine: next.engine }); f.layer.resumeCamera(); assert.equal(f.engine.movers.list().length, 0); assert.equal(f.sets(), 0); diff --git a/src/layers/droneAirspace/index.js b/src/layers/droneAirspace/index.js index 597942332..3aaf96733 100644 --- a/src/layers/droneAirspace/index.js +++ b/src/layers/droneAirspace/index.js @@ -163,7 +163,7 @@ export function createDroneAirspaceLayer({ throw new Error('Drone airspace layer is already initialized'); initialized = true; try { - if (engine) this.attachRenderer(viewer, { engine }); + if (engine) this.attachRenderer({ engine }); } catch (error) { this.destroy(); throw error; @@ -172,12 +172,16 @@ export function createDroneAirspaceLayer({ }, /** Draw on a globe: the rows already fetched, and the selection. */ - attachRenderer(viewer, { engine = null } = {}) { + show(enabled) { + renderer?.setVisible(enabled); + overlayHost?.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, enabled); + }, + attachRenderer({ engine = null } = {}) { if (!initialized || renderer) throw new Error('Invalid drone airspace scene attachment'); sceneEngine = engine; renderer = rendererFactory({ engine }); - renderer.setVisible(_enabled); + this.show(_enabled); if (_rows.length) renderer.replace(_rows); if (_enabled) { overlayHost?.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, true); diff --git a/src/layers/droneAirspace/ownership.test.mjs b/src/layers/droneAirspace/ownership.test.mjs index cd3247aeb..a358f2dc1 100644 --- a/src/layers/droneAirspace/ownership.test.mjs +++ b/src/layers/droneAirspace/ownership.test.mjs @@ -274,7 +274,7 @@ test('an engine switch keeps the zones and the selected card for the next globe' renderers[0].click({ position: { x: 1, y: 1 } }); layer.detachRenderer(); assert.equal(renderers[0].destroyed, true); - layer.attachRenderer(null, { engine: 'navara' }); + layer.attachRenderer({ engine: 'navara' }); const [, next] = renderers; assert.equal(next.engine, 'navara'); assert.equal(next.visible, true); diff --git a/src/layers/earthquakes/index.js b/src/layers/earthquakes/index.js index e9d28f7b2..8eb42dca3 100644 --- a/src/layers/earthquakes/index.js +++ b/src/layers/earthquakes/index.js @@ -65,7 +65,7 @@ export function createEarthquakesLayer({ throw new Error('Earthquake layer is already initialized'); initialized = true; try { - if (engine) this.attachRenderer(viewer, { engine }); + if (engine) this.attachRenderer({ engine }); } catch (error) { this.destroy(); throw error; @@ -73,13 +73,17 @@ export function createEarthquakesLayer({ console.log('[Data:Earthquakes] Initialized'); }, - attachRenderer(viewer, { engine = null } = {}) { + show(enabled) { + _renderer?.setVisible(enabled); + overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, enabled); + }, + attachRenderer({ engine = null } = {}) { if (!initialized || _renderer) throw new Error('Invalid earthquake scene attachment'); sceneEngine = engine; _renderer = rendererFactory({ engine }); if (!_renderer) throw new Error('Earthquake renderer unavailable'); - _renderer.setVisible(_enabled); + this.show(_enabled); if (discs.length) _renderer.replace(discs); overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, _enabled); if (_enabled) publishLabels(); diff --git a/src/layers/earthquakes/ownership.test.mjs b/src/layers/earthquakes/ownership.test.mjs index 938031997..515932eaa 100644 --- a/src/layers/earthquakes/ownership.test.mjs +++ b/src/layers/earthquakes/ownership.test.mjs @@ -101,7 +101,7 @@ test('an observation received between scenes is queryable and draws without a se assert.equal(record.id, row.usgsId); assert.equal(record.lat, row.lat); assert.equal(h.events.length, 0); - h.layer.attachRenderer(null, { engine: h.ground.engine }); + h.layer.attachRenderer({ engine: h.ground.engine }); assert.equal(h.ground.set('earthquakes').items.length, 1); assert.equal(h.events.length, 1); assert.equal(polls, 1); diff --git a/src/layers/earthquakes/render/display.test.mjs b/src/layers/earthquakes/render/display.test.mjs index 450aeb1a0..8ad1e0132 100644 --- a/src/layers/earthquakes/render/display.test.mjs +++ b/src/layers/earthquakes/render/display.test.mjs @@ -180,7 +180,7 @@ for (const makeHarness of GROUND_HARNESSES) { f.layer.detachRenderer(); assert.equal(f.sets(), 0); const next = makeHarness(); - f.layer.attachRenderer(null, { engine: next.engine }); + f.layer.attachRenderer({ engine: next.engine }); assert.equal(next.set('earthquakes').items.length, 3); assert.equal(next.set('earthquakes').shown, true); f.layer.destroy(); diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 2c237adbc..2ad84721e 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -26,27 +26,36 @@ export function createLifecycle({ layerState._handoffHeat = -1; } + const picks = { + owner: id, + predicate: (pickedId) => layerState._pickIndexById.has(pickedId), + }; const methods = { init(viewer, options) { - this.attachRenderer(viewer, options); + this.attachRenderer(options); }, /** Draws on `engine` (the scene's globe engine); without one, nothing. */ - attachRenderer( - _viewer, - { engine = null, waitForCameraRestore = false } = {}, - ) { + show(enabled) { + layerState._renderer?.setVisible(enabled); + layerState._heatImagery?.setShow(enabled && layerState._fires.length > 0); + overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, enabled); + }, + picks, + attachRenderer(context = {}) { + const { engine = null } = context; + layerState._sceneContext = context; if (layerState._destroyed) return; if (layerState._renderer || !engine) return; layerState._renderer = config.renderer.create({ engine, id, services }); if (!layerState._renderer) return; layerState._view = layerState._renderer.view; layerState._renderer.setVisible(false); - layerState._restoringViewport = waitForCameraRestore; - if (layerState._enabled && !waitForCameraRestore) this.showRenderer(); + if (layerState._enabled && context.viewReady !== false) + this.showRenderer(); // Enabled before a renderer: the load enable() could not start. if ( layerState._enabled && - !waitForCameraRestore && + context.viewReady !== false && !layerState._fires.length && !layerState._loading ) @@ -54,8 +63,6 @@ export function createLifecycle({ }, resumeViewport() { - if (!layerState._restoringViewport) return; - layerState._restoringViewport = false; if (layerState._enabled && layerState._renderer) { this.showRenderer(); if (!layerState._fires.length && !layerState._loading) @@ -98,8 +105,8 @@ export function createLifecycle({ components.viewport.installMoveEndWatcher(); components.selection.installClickHandler(); registerPickOwner( - id, - (pickedId) => layerState._pickIndexById.has(pickedId), + picks.owner, + picks.predicate, layerState._renderer?.engine, ); components.rendering.renderCurrentLod(true); diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 5c3fa51af..2b41f2efa 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -332,7 +332,7 @@ export function createQueries({ * renderer of `engine` (attached here when there is none yet). */ _bindInteractionForTest(engine, fires = []) { - if (!layerState._renderer) this.attachRenderer(null, { engine }); + if (!layerState._renderer) this.attachRenderer({ engine }); layerState._renderer.setVisible(true); layerState._enabled = true; layerState._allFires = fires; diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js index e84cd9522..876244a31 100644 --- a/src/layers/flights/lifecycle.js +++ b/src/layers/flights/lifecycle.js @@ -15,8 +15,15 @@ export function createLifecycle({ flightState, services, parts, layer }) { enable: () => methods.enable(), }); + const picks = { + owner: 'flights', + predicate: (pickedId) => flightState._billboards.has(pickedId), + }; const methods = { + picks, attachRenderer: binding.attachRenderer, + cameraOwner: binding.cameraOwner, + show: binding.show, detachRenderer: binding.detachRenderer, /** Configure the source before initialization; an active layer keeps its owner. */ setSource(source) { @@ -93,7 +100,7 @@ export function createLifecycle({ flightState, services, parts, layer }) { flightState.initialized = true; try { - if (viewer || engine) methods.attachRenderer(viewer, { engine }); + if (viewer || engine) methods.attachRenderer({ engine }); } catch (error) { methods.destroy(viewer); throw error; @@ -129,8 +136,8 @@ export function createLifecycle({ flightState, services, parts, layer }) { if (!renderer) return; parts.tracking._installClickHandler(); registerPickOwner( - 'flights', - (pickedId) => flightState._billboards.has(pickedId), + picks.owner, + picks.predicate, flightState._renderer?.engine, ); // Force a fresh rotation pass on the first tick after re-enable diff --git a/src/layers/flights/ownership.test.mjs b/src/layers/flights/ownership.test.mjs index 32af79f2f..bc5fc4e06 100644 --- a/src/layers/flights/ownership.test.mjs +++ b/src/layers/flights/ownership.test.mjs @@ -238,7 +238,7 @@ test('a live flight keeps observations, selection and freshness across renderer assert.equal(layer.hasContact('71BE26'), true); assert.equal(layer.getAnalystRecords().length, 1); assert.equal(layer.findByQuery('QA').icao24, '71be26'); - layer.attachRenderer(null, { engine: first }); + layer.attachRenderer({ engine: first }); assert.equal(layer.qaDrawn().length, 1); assert.equal(layer.trackById('71be26'), true); layer.detachRenderer(); @@ -246,7 +246,7 @@ test('a live flight keeps observations, selection and freshness across renderer assert.equal(layer.getTrackedInfo().icao24, '71be26'); await layer.update(null); assert.equal(layer.getStats().lastUpdate, epoch + 2000); - layer.attachRenderer(null, { engine: second }); + layer.attachRenderer({ engine: second }); assert.equal(second.camera.following()?.label.id, 'flights:71be26'); assert.equal(layer.qaDrawn().length, 1); assert.equal(polls, 2, 'attaching either renderer performs no source poll'); diff --git a/src/layers/flights/renderer.test.mjs b/src/layers/flights/renderer.test.mjs index 6ee9b98d0..254477187 100644 --- a/src/layers/flights/renderer.test.mjs +++ b/src/layers/flights/renderer.test.mjs @@ -70,7 +70,11 @@ test('the flights logic draws, selects and follows only through its renderer', a militaryRegistry, renderer: { create: ({ viewer, engine: given }) => { - assert.equal(viewer, null, 'no viewer on an engine without one'); + assert.equal( + viewer, + undefined, + 'the renderer receives only the engine', + ); renderer = recordingRenderer(given); return renderer; }, @@ -270,19 +274,19 @@ test('a failed engine switch, its rollback and the retry re-follow the tracked a // The switch: the working engine detaches; the candidate attaches the // layer, then fails and releases what it attached. layer.detachRenderer(working.viewer); - layer.attachRenderer(null, { engine: candidate }); + layer.attachRenderer({ engine: candidate }); assert.equal(candidate.camera.following()?.label?.id, 'flights:def456'); layer.detachRenderer(null); // The rollback: the working engine attaches again and follows the same // aircraft, announced as a restore. - layer.attachRenderer(working.viewer, { engine: working.engine }); + layer.attachRenderer({ engine: working.engine }); assert.equal(layer.getTrackedInfo()?.icao24, 'def456'); assert.equal(working.viewer.trackedEntity?.gevTrackedId, 'flights:def456'); assert.deepEqual(navigations, [], 'the rollback is not new navigation'); // The retry onto a new Cesium engine. layer.detachRenderer(working.viewer); retried = cesium(); - layer.attachRenderer(retried.viewer, { engine: retried.engine }); + layer.attachRenderer({ engine: retried.engine }); assert.equal(layer.getTrackedInfo()?.icao24, 'def456'); assert.equal(retried.viewer.trackedEntity?.gevTrackedId, 'flights:def456'); assert.equal( diff --git a/src/layers/ghostMaps/edgeCases.test.mjs b/src/layers/ghostMaps/edgeCases.test.mjs index d12f73a31..1a4191827 100644 --- a/src/layers/ghostMaps/edgeCases.test.mjs +++ b/src/layers/ghostMaps/edgeCases.test.mjs @@ -158,7 +158,7 @@ test('edge:s2-ghostmaps: an edition and engine change mid-parse shows only the s await new Promise(setImmediate); push(catalog('v3')); layer.detachRenderer(); - await layer.attachRenderer(null, { engine: { name: 'navara' } }); + await layer.attachRenderer({ engine: { name: 'navara' } }); v2.resolve(kmz('edition two')); v3.resolve(kmz('edition three')); // Let the queued syncs settle. @@ -180,7 +180,7 @@ test('edge:s2-ghostmaps: a bad archive keeps the working edition on the globe', push(catalog('v2')); // The engine changes while the broken file is still on its way. layer.detachRenderer(); - await layer.attachRenderer(null, { engine: { name: 'navara' } }); + await layer.attachRenderer({ engine: { name: 'navara' } }); broken.resolve(new Uint8Array([1, 2, 3, 4])); for (let i = 0; i < 20; i += 1) await new Promise(setImmediate); diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js index 491271c20..2079cf58c 100644 --- a/src/layers/ghostMaps/index.js +++ b/src/layers/ghostMaps/index.js @@ -524,12 +524,7 @@ export function createGhostMapsLayer({ function installClickHandler() { if (!canSelect() || _clickHandler || !_display) return; - picking.registerPickOwner?.( - GHOSTMAPS_LAYER_ID, - (pickedId) => - typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX), - sceneEngine, - ); + picking.registerPickOwner?.(picks.owner, picks.predicate, sceneEngine); _clickHandler = _display.onClick((click) => { if (pointer && !pointer.isPointerFree()) return; const cardHit = overlayHost.hitTest?.( @@ -576,6 +571,11 @@ export function createGhostMapsLayer({ return count; } + const picks = { + owner: GHOSTMAPS_LAYER_ID, + predicate: (pickedId) => + typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX), + }; const layer = { id: GHOSTMAPS_LAYER_ID, name: 'S2 GhostMaps', @@ -586,10 +586,11 @@ export function createGhostMapsLayer({ updateInterval: 300000, init(viewer, options = {}) { - if (options.engine) return this.attachRenderer(viewer, options); + if (options.engine) return this.attachRenderer(options); }, - attachRenderer(_viewer, { engine = null, signal = null } = {}) { + picks, + attachRenderer({ engine = null, signal = null } = {}) { if (_display) throw new Error('GhostMaps display is already attached'); if (typeof createRenderer !== 'function') throw new TypeError('GhostMaps needs a renderer factory'); diff --git a/src/layers/ghostMaps/lifecycle.test.mjs b/src/layers/ghostMaps/lifecycle.test.mjs index f6dcde5b8..1cbcbf3a5 100644 --- a/src/layers/ghostMaps/lifecycle.test.mjs +++ b/src/layers/ghostMaps/lifecycle.test.mjs @@ -242,7 +242,7 @@ test('a KMZ delivered between planets is reused by the new scene and releases it ['Test report'], ); assert.equal(h.mounted.size, 0); - await layer.attachRenderer(null, { engine: h.engine }); + await layer.attachRenderer({ engine: h.engine }); assert.equal(h.mounted.size, 1); assert.equal(h.created.length, 1); assert.equal(h.revoked.length, 0); diff --git a/src/layers/ghostMaps/pendingPaint.test.mjs b/src/layers/ghostMaps/pendingPaint.test.mjs index be1b6a00e..0343b6504 100644 --- a/src/layers/ghostMaps/pendingPaint.test.mjs +++ b/src/layers/ghostMaps/pendingPaint.test.mjs @@ -137,7 +137,7 @@ for (const action of ['expire', 'disable']) { // Engine switch: the new drawing is still decoding its icons. layer.detachRenderer(); - const attached = layer.attachRenderer(null, { engine: { name: 'navara' } }); + const attached = layer.attachRenderer({ engine: { name: 'navara' } }); await new Promise(setImmediate); assert.equal(gates.length, 1); // The event leaves its age window; the expiry pass runs meanwhile. diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index b47abdda3..074f934fe 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -161,14 +161,17 @@ export function createGnssIntegrityLayer({ throw new Error('GNSS integrity layer is already initialized'); initialized = true; try { - if (engine) this.attachRenderer(viewer, { engine }); + if (engine) this.attachRenderer({ engine }); } catch (error) { this.destroy(); throw error; } }, - attachRenderer(viewer, { engine = null } = {}) { + show(enabled) { + renderer?.setVisible(enabled); + }, + attachRenderer({ engine = null } = {}) { if (!initialized || renderer) throw new Error('Invalid GNSS scene attachment'); renderer = rendererFactory({ engine }); @@ -180,7 +183,7 @@ export function createGnssIntegrityLayer({ ); _signature = null; if (_enabled) expire(); - renderer.setVisible(_enabled); + this.show(_enabled); }, detachRenderer() { diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs index 551edd507..0a0831ddb 100644 --- a/src/layers/gnss/ownership.test.mjs +++ b/src/layers/gnss/ownership.test.mjs @@ -745,7 +745,7 @@ test('an in-flight regional response supplies the next renderer without a new re assert.equal(await pending, true); assert.equal(h.ground.sets(), 0); assert.equal(h.layer.getStats().levels.low, 1); - h.layer.attachRenderer(null, { engine: h.engine }); + h.layer.attachRenderer({ engine: h.engine }); assert.equal(h.drawn(), 1); assert.equal(calls, 1); assert.equal(h.camera.changed.numberOfListeners, 1); diff --git a/src/layers/infrastructure/index.js b/src/layers/infrastructure/index.js index e269afa32..011cea023 100644 --- a/src/layers/infrastructure/index.js +++ b/src/layers/infrastructure/index.js @@ -136,9 +136,9 @@ export function createInfrastructureSessionLayer( updateInterval: 0, statsRefreshInterval: 1000, init(_viewer, options = {}) { - if (options.engine) return this.attachRenderer(_viewer, options); + if (options.engine) return this.attachRenderer(options); }, - attachRenderer(_viewer, { engine = null, signal = null } = {}) { + attachRenderer({ engine = null, signal = null } = {}) { if (display) throw Error('Infrastructure display already attached'); display = engine ? createDisplay(engine, config, services) : null; // The dataset load belongs to the session and keeps running; only this diff --git a/src/layers/infrastructure/session.test.mjs b/src/layers/infrastructure/session.test.mjs index 82fd60c83..468d69053 100644 --- a/src/layers/infrastructure/session.test.mjs +++ b/src/layers/infrastructure/session.test.mjs @@ -57,9 +57,9 @@ test('a bundled file delivered between planets keeps multipart IDs and is read o await enabled; assert.equal(layer.getAnalystRecords().length, 2); assert.equal(captures.length, 0); - await layer.attachRenderer(null, { engine: {} }); + await layer.attachRenderer({ engine: {} }); layer.detachRenderer(); - await layer.attachRenderer(null, { engine: {} }); + await layer.attachRenderer({ engine: {} }); assert.equal(captures[0], captures[1]); assert.deepEqual( captures[0].records.map((r) => r.id), @@ -94,7 +94,7 @@ test('a released scene stops waiting for a stalled dataset load, which stays in await new Promise(setImmediate); layer.detachRenderer(); const scene = new AbortController(); - const attaching = layer.attachRenderer(null, { + const attaching = layer.attachRenderer({ engine: {}, signal: scene.signal, }); @@ -385,6 +385,6 @@ test('a scene switch restores the selected feature on the next display', async ( selectEntityContext(record.entity); env.layer.detachRenderer(); assert.equal(getContextStore().selectedEntityId, null); - await env.layer.attachRenderer(null, { engine: env.engine }); + await env.layer.attachRenderer({ engine: env.engine }); assert.equal(getContextStore().selectedEntityId, 'local-dams:dam'); }); diff --git a/src/layers/installations/index.js b/src/layers/installations/index.js index 236c5e017..9c77a7ebf 100644 --- a/src/layers/installations/index.js +++ b/src/layers/installations/index.js @@ -28,10 +28,13 @@ export function createInstallationsLayer({ let renderer = null; const lifecycle = { init(viewer, options = {}) { - if (viewer || options.engine) - return lifecycle.attachRenderer(viewer, options); + if (viewer || options.engine) return lifecycle.attachRenderer(options); }, - attachRenderer(viewer, options = {}) { + picks: { + owner: 'military-installations', + predicate: (id) => state.recordById.has(id), + }, + attachRenderer(options = {}) { if (renderer) throw new Error('Installations layer is already initialized'); const engine = options.engine ?? null; diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 71a8aab1d..24d072d6e 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -96,6 +96,23 @@ export function createLifecycle({ } const methods = { + picks: { + owner: 'rocket-launches', + predicate: (id) => getRenderer()?.ownsPick(id) ?? false, + }, + cameraOwner: { + suspend() { + layerState._replayCameraHeading = layerState._replayTracks.get( + layerState._replayCameraLaunchId, + )?.lastCameraHeading; + layerState._replayCameraToken++; + layerState._replayCameraRemover?.(); + layerState._missionZoomAnchorRemover?.(); + layerState._replayCameraRemover = layerState._missionZoomAnchorRemover = + null; + }, + resume: () => methods.resumeViewport(), + }, // The layer draws through the engine (options.engine) only. init(_scene, options) { layerState._count = 0; @@ -103,20 +120,19 @@ export function createLifecycle({ layerState._stale = false; layerState._lastError = null; layerState._orbitMatches = 0; - this.attachRenderer(null, options); + this.attachRenderer(options); }, - attachRenderer( - _scene, - { engine = null, waitForCameraRestore = false } = {}, - ) { + show(enabled) { + getRenderer()?.setVisible(enabled); + }, + attachRenderer(context = {}) { + const { engine = null } = context; + layerState._sceneContext = context; if (engine) attachDisplay(engine); - layerState._restoringViewport = true; - if (!waitForCameraRestore) this.resumeViewport(); + if (!context.viewRestored) this.resumeViewport(); }, resumeViewport() { - if (!layerState._restoringViewport) return; - layerState._restoringViewport = false; if (layerState._enabled && layerState._replayCameraLaunchId) { parts.replay.startMissionReplay(layerState._replayCameraLaunchId, { resuming: true, @@ -141,14 +157,7 @@ export function createLifecycle({ layerState._waitForRenderer ??= new Promise((resolve) => { layerState._resumeRenderer = resolve; }); - layerState._replayCameraHeading = layerState._replayTracks.get( - layerState._replayCameraLaunchId, - )?.lastCameraHeading; - layerState._replayCameraToken++; - layerState._replayCameraRemover?.(); - layerState._missionZoomAnchorRemover?.(); - layerState._replayCameraRemover = layerState._missionZoomAnchorRemover = - null; + methods.cameraOwner.suspend(); layerState._clickHandler?.destroy(); layerState._moveHandler?.destroy(); layerState._declutterHandler?.(); diff --git a/src/layers/launches/render/display.js b/src/layers/launches/render/display.js index a91ae4c31..5a2969add 100644 --- a/src/layers/launches/render/display.js +++ b/src/layers/launches/render/display.js @@ -376,6 +376,7 @@ export function createLaunchesDisplay(engine, { layerId = LAYER_ID } = {}) { return { engine, + ownsPick: owns, occluder, /** The engine frames seen so far (preUpdate). */ frameNumber: () => frameNumber, diff --git a/src/layers/launches/render/display.test.mjs b/src/layers/launches/render/display.test.mjs index b278fed16..e568cd8df 100644 --- a/src/layers/launches/render/display.test.mjs +++ b/src/layers/launches/render/display.test.mjs @@ -269,7 +269,14 @@ test('a click on a mission names its element; a click elsewhere or while off nam const engine = createFakeEngine(); const display = createLaunchesDisplay(engine); display.setVisible(true); - display.addPoint({ id: 'rocket-launch:a', position: PAD, sizePx: 5 }); + const point = display.addPoint({ + id: 'rocket-launch:a', + position: PAD, + sizePx: 5, + }); + assert.equal(display.ownsPick('rocket-launch:a'), true); + assert.equal(display.ownsPick('rocket-launch:missing'), false); + assert.equal(display.ownsPick('other'), false); let enabled = true; const clicks = []; const handler = display.onClick((id) => clicks.push(id), { @@ -292,6 +299,8 @@ test('a click on a mission names its element; a click elsewhere or while off nam enabled = true; engine.fake.input('click', { position: { x: 1, y: 2 } }); assert.equal(clicks.length, 3); + point.remove(); + assert.equal(display.ownsPick('rocket-launch:a'), false); display.destroy(); engine.destroy(); }); diff --git a/src/layers/localAdsb/edgeCases.test.mjs b/src/layers/localAdsb/edgeCases.test.mjs index 681489e92..005429c1b 100644 --- a/src/layers/localAdsb/edgeCases.test.mjs +++ b/src/layers/localAdsb/edgeCases.test.mjs @@ -167,7 +167,7 @@ test('edge:local-adsb: an unplugged receiver ends one session and a scene switch layer.detachRenderer(); clock.now += 1_000; await layer.update(); - layer.attachRenderer(null, { engine: createFakeEngine() }); + layer.attachRenderer({ engine: createFakeEngine() }); await layer.update(); assert.deepEqual(receiver.modeCalls, ['adsb'], 'the tuner is asked once'); diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index df7a20617..38494cd4d 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -603,6 +603,7 @@ export function createLocalAdsbLayer({ return rows; } + const picks = { owner: LAYER_ID, predicate: (id) => markers.has(id) }; return { id: LAYER_ID, name: LAYER_NAME, @@ -632,11 +633,15 @@ export function createLocalAdsbLayer({ }); unsubscribe = receiver.subscribe?.(scheduleSync) || null; unsubscribeFeeds = feeds?.subscribe?.(requestSync) || null; - return this.attachRenderer(nextViewer, options); + return this.attachRenderer(options); }, - attachRenderer(nextViewer, { engine = null } = {}) { - renderer = services.renderer.create({ viewer: nextViewer, engine }); + show(enabled) { + renderer?.setVisible(enabled); + }, + picks, + attachRenderer({ engine = null } = {}) { + renderer = services.renderer.create({ engine }); renderer.attach(); if (renderer.models) { models = createLocalAdsbModels({ @@ -650,7 +655,7 @@ export function createLocalAdsbLayer({ const restore = selectedId; selectedId = null; renderer.setVisible(true); - registerPickOwner(LAYER_ID, (id) => markers.has(id), renderer?.engine); + registerPickOwner(picks.owner, picks.predicate, renderer?.engine); installInteraction(); installFrame(); sync(); @@ -687,11 +692,7 @@ export function createLocalAdsbLayer({ async enable() { enabled = true; renderer?.setVisible(true); - registerPickOwner( - LAYER_ID, - (pickedValue) => markers.has(pickedValue), - renderer?.engine, - ); + registerPickOwner(picks.owner, picks.predicate, renderer?.engine); installInteraction(); installFrame(); clearInterval(tickTimer); diff --git a/src/layers/localAdsb/index.test.mjs b/src/layers/localAdsb/index.test.mjs index 728c8d012..2a386885e 100644 --- a/src/layers/localAdsb/index.test.mjs +++ b/src/layers/localAdsb/index.test.mjs @@ -1152,7 +1152,7 @@ test('globe handoff retains the receiver, feed, trail fixes and selection', asyn }); await layer.update(); assert.equal(layer.getAnalystRecords()[0].icao24, 'abc123'); - layer.attachRenderer(null, { engine: fakeEngine() }); + layer.attachRenderer({ engine: fakeEngine() }); assert.deepEqual(receiver.modeCalls, ['adsb']); assert.deepEqual(feeds.calls, ['start']); assert.equal(closes, 0); diff --git a/src/layers/military/lifecycle.js b/src/layers/military/lifecycle.js index 67ba8b28b..c8a3f68e2 100644 --- a/src/layers/military/lifecycle.js +++ b/src/layers/military/lifecycle.js @@ -14,8 +14,15 @@ export function createLifecycle({ flightState, services, parts, layer }) { parts, enable: () => methods.enable(), }); + const picks = { + owner: 'military', + predicate: (pickedId) => flightState._billboards.has(pickedId), + }; const methods = { + picks, attachRenderer: binding.attachRenderer, + cameraOwner: binding.cameraOwner, + show: binding.show, detachRenderer: binding.detachRenderer, /** Configure the source before initialization; an active layer keeps its owner. */ setSource(source) { @@ -76,7 +83,7 @@ export function createLifecycle({ flightState, services, parts, layer }) { flightState.initialized = true; try { - if (viewer || engine) binding.attachRenderer(viewer, { engine }); + if (viewer || engine) binding.attachRenderer({ engine }); } catch (error) { methods.destroy(viewer); throw error; @@ -113,8 +120,8 @@ export function createLifecycle({ flightState, services, parts, layer }) { if (!renderer) return; parts.tracking._installClickHandler(); registerPickOwner( - 'military', - (pickedId) => flightState._billboards.has(pickedId), + picks.owner, + picks.predicate, flightState._renderer?.engine, ); // Force a fresh rotation pass on the first tick after re-enable diff --git a/src/layers/military/renderer.test.mjs b/src/layers/military/renderer.test.mjs index 28652384f..f7a17758f 100644 --- a/src/layers/military/renderer.test.mjs +++ b/src/layers/military/renderer.test.mjs @@ -73,7 +73,11 @@ test('the military logic draws, selects and follows only through its renderer', militaryRegistry, renderer: { create: ({ viewer, engine: given }) => { - assert.equal(viewer, null, 'no viewer on an engine without one'); + assert.equal( + viewer, + undefined, + 'the renderer receives only the engine', + ); renderer = recordingAircraftRenderer(given); return renderer; }, @@ -311,14 +315,14 @@ test('military classification and the selected aircraft survive an update betwee await layer.update(null); assert.equal(layer.hasContact('AE0001'), true); assert.equal(layer.getAnalystRecords().length, 1); - layer.attachRenderer(null, { engine: first }); + layer.attachRenderer({ engine: first }); assert.equal(layer.trackById('ae0001'), true); layer.detachRenderer(); assert.equal(binding.follow.current(), undefined); assert.equal(militaryRegistry.isMilitaryLayerActive(), true); assert.equal(layer.getTrackedInfo().icao24, 'ae0001'); await layer.update(null); - layer.attachRenderer(null, { engine: second }); + layer.attachRenderer({ engine: second }); assert.ok(binding.follow.current()); assert.equal(layer.getStats().lastUpdate, 1700000002000); assert.equal(polls, 2); diff --git a/src/layers/osmPlaces/index.js b/src/layers/osmPlaces/index.js index 7a7624fdf..ec87b5fe5 100644 --- a/src/layers/osmPlaces/index.js +++ b/src/layers/osmPlaces/index.js @@ -97,16 +97,20 @@ export function createOsmPlacesLayer({ }) .catch(() => {}); overlayHost.setVisible(OVERLAY_SOURCE_ID, false); - if (engine) this.attachRenderer(viewer, { engine }); + if (engine) this.attachRenderer({ engine }); }, /** Draw on a globe; the results stay for the next one. */ - attachRenderer(viewer, { engine = null } = {}) { + show(enabled) { + renderer?.setVisible(enabled); + overlayHost.setVisible(OVERLAY_SOURCE_ID, enabled); + }, + attachRenderer({ engine = null } = {}) { if (!initialized || renderer) throw new Error('Invalid OSM Places scene attachment'); sceneEngine = engine; - renderer = createRenderer({ viewer, engine }); - renderer.setVisible(enabled); + renderer = createRenderer({ engine }); + this.show(enabled); if (enabled) overlayHost.setVisible(OVERLAY_SOURCE_ID, true); render(); }, diff --git a/src/layers/osmPlaces/render/display.test.mjs b/src/layers/osmPlaces/render/display.test.mjs index 2ac40ecdc..976aac358 100644 --- a/src/layers/osmPlaces/render/display.test.mjs +++ b/src/layers/osmPlaces/render/display.test.mjs @@ -132,7 +132,7 @@ for (const makeHarness of HARNESSES) { layer.detachRenderer(); assert.equal(count(h.engine), undefined, 'the points go with the globe'); const next = makeHarness(); - layer.attachRenderer(null, { engine: next.engine }); + layer.attachRenderer({ engine: next.engine }); assert.equal(count(next.engine), 2); assert.equal(layer.getAnalystRecords().length, 2, 'the results stayed'); layer.disable(); diff --git a/src/layers/perimeters/index.js b/src/layers/perimeters/index.js index 20c568447..3591b1e2d 100644 --- a/src/layers/perimeters/index.js +++ b/src/layers/perimeters/index.js @@ -277,19 +277,23 @@ export function createFirePerimetersLayer({ throw new Error('Fire perimeter layer is already initialized'); initialized = true; try { - if (engine) this.attachRenderer(viewer, { engine }); + if (engine) this.attachRenderer({ engine }); } catch (error) { this.destroy(); throw error; } }, - attachRenderer(viewer, { engine = null } = {}) { + show(enabled) { + renderer?.setVisible(enabled); + overlayHost?.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, enabled); + }, + attachRenderer({ engine = null } = {}) { if (!initialized || renderer) throw new Error('Invalid fire perimeter scene attachment'); sceneEngine = engine; renderer = createRenderer({ engine }); - renderer.setVisible(_enabled); + this.show(_enabled); if (geometry.size) renderer.replace([...geometry.values()]); if (_enabled) { overlayHost?.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, true); diff --git a/src/layers/perimeters/ownership.test.mjs b/src/layers/perimeters/ownership.test.mjs index 90f098d8c..cf04404e3 100644 --- a/src/layers/perimeters/ownership.test.mjs +++ b/src/layers/perimeters/ownership.test.mjs @@ -583,7 +583,7 @@ test('a selected fire retains its card and changed polygon through a scene hando assert.equal(await h.layer.update(), true); const record = h.layer.getAnalystRecords()[0]; assert.equal(record.id, 'incident:one'); - h.layer.attachRenderer(null, { engine: h.engine }); + h.layer.attachRenderer({ engine: h.engine }); assert.equal(h.zones().items.length, 2, 'the fill and its fire line'); assert.equal(h.zones().items[0].rings[0][0][0], ring[0][0] + 0.1); assert.equal( diff --git a/src/layers/radio/lifecycle.js b/src/layers/radio/lifecycle.js index e4a89bc15..ac7b1dffe 100644 --- a/src/layers/radio/lifecycle.js +++ b/src/layers/radio/lifecycle.js @@ -15,9 +15,13 @@ export function createLifecycle({ const { clearSource, setVisible } = services.overlays; const methods = { + picks: { + owner: 'radio', + predicate: (id) => parts.renderer?.ownsPick(id) ?? false, + }, /** Initialize scene resources and the single audio element. */ init(viewer, options = {}) { - this.attachRenderer(viewer, options); + this.attachRenderer(options); layerState._sessionGeneration += 1; parts.clustering.resetRadioClusterOverlayIdentities(); parts.playback.installAudio(); @@ -25,7 +29,7 @@ export function createLifecycle({ setVisible(RADIO_OVERLAY_SOURCE_ID, false); }, - attachRenderer(viewer, options = {}) { + attachRenderer(options = {}) { if (parts.renderer?.ready) throw new Error('Radio layer is already initialized'); if (typeof rendererFactory !== 'function') diff --git a/src/layers/radio/render/display.js b/src/layers/radio/render/display.js index 046d35457..e16bfb519 100644 --- a/src/layers/radio/render/display.js +++ b/src/layers/radio/render/display.js @@ -305,9 +305,10 @@ export function createRadioDisplay(engine, { state, services, parts }) { drillPick(position, limit) { return engine.picker.drillPick(position, limit); }, + ownsPick: (id) => String(id).startsWith(RADIO_PREFIX), installClick(callback) { removeClick = selectionDispatcherFor(engine).register('radio', { - owns: (id) => String(id).startsWith(RADIO_PREFIX), + owns: renderer.ownsPick, onClick: ({ position }) => callback(position), }); return removeClick; diff --git a/src/layers/radio/render/display.test.mjs b/src/layers/radio/render/display.test.mjs index 33dd19a35..66be6c2d7 100644 --- a/src/layers/radio/render/display.test.mjs +++ b/src/layers/radio/render/display.test.mjs @@ -188,6 +188,10 @@ for (const makeHarness of HARNESSES) { ); near(station.pixelSize, 13, 'station size'); const cluster = f.instance('radio-clusters', 0); + assert.equal(f.layer.picks.owner, 'radio'); + assert.equal(f.layer.picks.predicate(station.id), true); + assert.equal(f.layer.picks.predicate(cluster.id), true); + assert.equal(f.layer.picks.predicate('other'), false); near( cluster.pixelSize, Math.min(26, 12 + Math.log2(3) * 1.6), @@ -235,6 +239,7 @@ for (const makeHarness of HARNESSES) { const bracket = f.instance('radio-bracket', 0); near(bracket.pixelSize, 40, 'bracket size'); assert.equal(bracket.id, `radio:selected:${clusterId}`); + assert.equal(f.layer.picks.predicate(bracket.id), true); { const { options } = f.engine.fake.moverSet('radio-selected'); assert.equal(options.point.sizePx, 14); diff --git a/src/layers/recentImagery/index.js b/src/layers/recentImagery/index.js index 0aa5fa562..4c4524bb3 100644 --- a/src/layers/recentImagery/index.js +++ b/src/layers/recentImagery/index.js @@ -775,10 +775,10 @@ export function createRecentImageryLayer({ updateInterval: 0, init(_viewer, options) { - return this.attachRenderer(null, options); + return this.attachRenderer(options); }, - attachRenderer(_viewer, { engine } = {}) { + attachRenderer({ engine } = {}) { _engine = engine || null; if (rendererFactory && !renderer) renderer = rendererFactory({ engine }); if (!renderer) throw new TypeError('Recent imagery requires a renderer'); diff --git a/src/layers/satellites/cockpitSubject.test.mjs b/src/layers/satellites/cockpitSubject.test.mjs index 415482e4d..9cbcdbbed 100644 --- a/src/layers/satellites/cockpitSubject.test.mjs +++ b/src/layers/satellites/cockpitSubject.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as geo from '../../geo/index.js'; import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; +import { createLayerSceneContext } from '../../data/layerSceneBinding.js'; import { createSatellitesDisplay } from './render/display.js'; import { constantRgba } from '../shared/color.js'; import { createState } from './state.js'; @@ -545,10 +546,20 @@ test('an engine switch retains the held subject and dims the replacement rings', const engine = createFakeEngine(); t.after(() => engine.destroy()); - f.parts.lifecycle.methods.attachRenderer(null, { + let restoreView; + const context = createLayerSceneContext({ engine, - waitForCameraRestore: true, + viewRestored: new Promise((resolve) => { + restoreView = resolve; + }), }); + f.parts.lifecycle.methods.attachRenderer(context); + assert.equal(f.methods.trackById(GPS, { origin: 'share-restore' }), true); + assert.equal( + engine.camera.following(), + null, + 'retained tracking waits for the view', + ); const issPath = f.state._orbitPaths.get(ISS_NORAD); assert.equal(f.ring(ISS_NORAD).show, false, 'the hidden class stays hidden'); assert.equal(f.ring(GPS).show, false, 'the held ring stays hidden'); @@ -557,7 +568,10 @@ test('an engine switch retains the held subject and dims the replacement rings', ringColors({ ...f, engine }, ISS_NORAD).color[3], issPath.baseColor.alpha * COCKPIT_ORBIT_DIM_FACTOR, ); + restoreView(); + await context.viewRestored; f.parts.lifecycle.methods.resumeViewport(); + assert.equal(engine.camera.following().native, f.state._trackedEntity); engine.fake.frame(); assert.equal(dotShown(f), false); f.methods.setCockpitSubject(null); diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js index 448be44a5..dec61342d 100644 --- a/src/layers/satellites/controls.js +++ b/src/layers/satellites/controls.js @@ -456,7 +456,10 @@ export function createControls({ state: layerState, services, parts, source }) { !layerState._points.has(id) ) return false; - if (layerState._restoringViewport && layerState._trackedNorad === id) + if ( + layerState._sceneContext?.viewReady === false && + layerState._trackedNorad === id + ) return true; parts.tracking._cancelPendingTrackingRestore(); parts.tracking._trackSatellite(id, { origin }); diff --git a/src/layers/satellites/detachedToggle.test.mjs b/src/layers/satellites/detachedToggle.test.mjs index f7a7a824d..acfc58e29 100644 --- a/src/layers/satellites/detachedToggle.test.mjs +++ b/src/layers/satellites/detachedToggle.test.mjs @@ -92,9 +92,9 @@ test('restored orbit rings receive their current rotation before the first frame }, }, }); - lifecycle.methods.attachRenderer(null, { + lifecycle.methods.attachRenderer({ engine: {}, - waitForCameraRestore: true, + viewRestored: new Promise(() => {}), }); assert.equal(rotated, 1); assert.equal(path.primitive.show, true); diff --git a/src/layers/satellites/edgeCases.test.mjs b/src/layers/satellites/edgeCases.test.mjs index 7b37a50ed..0792b24d7 100644 --- a/src/layers/satellites/edgeCases.test.mjs +++ b/src/layers/satellites/edgeCases.test.mjs @@ -86,7 +86,7 @@ test('edge:satellites: a scene switch at a stopped epoch keeps passes and coordi layer.detachRenderer(); // Wall time passes during the switch; the stopped world clock does not. t.mock.timers.tick(15 * 60_000); - layer.attachRenderer(null, { engine: second }); + layer.attachRenderer({ engine: second }); const after = observe(layer, second); assert.equal(reads, catalogReads, 'the switch does not refetch elements'); diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js index f52fad5e9..f15a05519 100644 --- a/src/layers/satellites/lifecycle.js +++ b/src/layers/satellites/lifecycle.js @@ -30,7 +30,18 @@ export function createLifecycle({ const { holdContinuousRender, releaseContinuousRender } = services.render; const { registerPickOwner, unregisterPickOwner } = services.picking; + const picks = { + owner: 'satellites', + predicate: (pickedId) => { + const norad = Number(pickedId); + return Number.isFinite(norad) && layerState._points.has(norad); + }, + }; const methods = { + cameraOwner: { + suspend: () => methods.suspendCamera(), + resume: () => methods.resumeViewport(), + }, // Catalog data refresh; propagation remains preRender-owned. // The display draws on `engine` (none without one: nothing is drawn). @@ -160,10 +171,23 @@ export function createLifecycle({ layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); }, - attachRenderer( - _viewer, - { engine = null, waitForCameraRestore = false } = {}, - ) { + show(enabled) { + if (layerState._pointCollection) + layerState._pointCollection.show = + enabled && layerState._params.showPoints; + for (const [id, path] of layerState._orbitPaths) { + if (path.primitive) + path.primitive.show = + enabled && + (id === ISS_NORAD + ? parts.controls._issOrbitVisible() + : layerState._params.showOrbits); + } + }, + picks, + attachRenderer(context = {}) { + const { engine = null } = context; + layerState._sceneContext = context; layerState._engine = engine; const renderer = engine ? (services.renderer.create({ engine }) ?? null) @@ -196,13 +220,11 @@ export function createLifecycle({ layerState._resumeRenderer?.(); layerState._waitForRenderer = layerState._resumeRenderer = null; // The restored camera runs before a retained follow gets its new native target. - layerState._restoringViewport = true; - if (!waitForCameraRestore) this.resumeViewport(); + if (!context.viewRestored) this.resumeViewport(); }, resumeViewport() { - if (!layerState._restoringViewport || !layerState._renderer) return; - layerState._restoringViewport = false; + if (!layerState._renderer) return; if (layerState._enabled) { if (layerState._trackedNorad != null && !layerState._trackedEntity) parts.tracking._trackSatellite(layerState._trackedNorad, { @@ -239,14 +261,7 @@ export function createLifecycle({ if (layerState._renderer) parts.interaction._installClickHandler(); // Pick-ownership (H2): satellite dot ids are numeric NORAD catalog numbers; // the registry hands predicates String()-coerced ids, so match via Number(). - registerPickOwner( - 'satellites', - (pickedId) => { - const norad = Number(pickedId); - return Number.isFinite(norad) && layerState._points.has(norad); - }, - layerState._engine, - ); + registerPickOwner(picks.owner, picks.predicate, layerState._engine); if (!layerState._preRenderListener && layerState._renderer) { layerState._preRenderListener = getRenderer().onFrame( parts.rendering._preRenderTick, diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 2753fbb33..6b95cfa2c 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -78,7 +78,7 @@ export function createStreetLevelLayer({ isAvailable: (entry) => providerAvailable(entry), }); - let waitingForView = false; + let sceneContext = null; let notifyQueued = false; function notify() { if (notifyQueued) return; @@ -113,7 +113,7 @@ export function createStreetLevelLayer({ getFilter: () => resolveFilter(state.filter, streetLevelTime()), /** The layer and this provider's switch are both on. */ isActive: () => state.enabled && entry.on, - waitingForView: () => waitingForView, + viewReady: () => Boolean(state.map) && sceneContext?.viewReady !== false, notify, actions: { /** @@ -510,21 +510,23 @@ export function createStreetLevelLayer({ }, init(scene, options) { - this.attachRenderer(scene, options); + this.attachRenderer(options); for (const entry of state.providers.values()) checkStatus(entry); }, - /** Draws on `engine` through its display; the first argument is not read. */ - attachRenderer( - _scene, - { engine = null, waitForCameraRestore = false } = {}, - ) { + /** Draws on `engine` through its display. */ + show(enabled) { + parts.marker.setVisible(enabled); + }, + picks: { owner: STREET_LEVEL_LAYER_ID, predicate: parts.router.ownsPick }, + attachRenderer(context = {}) { + const { engine = null } = context; + sceneContext = context; const createDisplay = services.createDisplay ?? createStreetLevelDisplay; const map = engine ? (createDisplay(engine) ?? null) : null; state.map = map; - waitingForView = waitForCameraRestore; if (!map) return; parts.marker.ensure(map); - parts.marker.setVisible(state.enabled); + this.show(state.enabled); for (const entry of state.providers.values()) { entry.instance.init(map); if (state.enabled && entry.on) { @@ -544,18 +546,23 @@ export function createStreetLevelLayer({ remeasureGround(); } }, + cameraOwner: { + suspend: () => parts.framing.cancel(), + resume() { + if (state.enabled && state.map) { + for (const entry of activeEntries()) + entry.instance.refreshCoverage?.(); + remeasureGround(); + } + }, + }, resumeViewport() { - if (!waitingForView) return; - waitingForView = false; - if (state.enabled && state.map) { - for (const entry of activeEntries()) entry.instance.refreshCoverage?.(); - remeasureGround(); - } + this.cameraOwner.resume(); }, detachRenderer() { const map = state.map; if (!map) return; - waitingForView = true; + sceneContext = null; removeCameraListener?.(); removeCameraListener = null; cancelGroundRetry(); diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index 5eacb0bff..0d6db5c72 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -373,7 +373,7 @@ export function createLineCoverage({ const map = state.map; if ( !map || - state.context.waitingForView?.() || + state.context.viewReady?.() === false || !state.context.isActive() || state.coverage.blocked ) diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs index 02a4b0c81..6afa7096f 100644 --- a/src/layers/streetLevel/lineCoverage.test.mjs +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -128,6 +128,18 @@ function setup({ zoomFor, harness = null } = {}) { return { viewer, source, state, coverage, bytes, tileKey, onGlobe, centre }; } +test('coverage waits for the shared view readiness before requesting tiles', () => { + const { state, source, coverage } = setup(); + let ready = false; + state.context.viewReady = () => ready; + coverage.refresh(); + assert.equal(source.calls.length, 0); + ready = true; + coverage.refresh(); + assert.ok(source.calls.length > 0); + coverage.clear(); +}); + test('a superseded tile request cannot strand lines on the globe', async () => { const { viewer, source, state, coverage, bytes, tileKey, onGlobe } = setup(); const forTile = () => source.calls.filter((call) => call.key === tileKey); diff --git a/src/layers/streetLevel/sequenceCones.js b/src/layers/streetLevel/sequenceCones.js index 4bd0f0ec4..c7bdcef2a 100644 --- a/src/layers/streetLevel/sequenceCones.js +++ b/src/layers/streetLevel/sequenceCones.js @@ -120,7 +120,7 @@ export function createSequenceCones({ * reloads a selected one (a provider that loads part of a sequence moved on). */ async function select(sequenceId, { force = false } = {}) { - if (!sequenceId || (!state.map && !state.context.waitingForView?.())) + if (!sequenceId || (!state.map && state.context.viewReady?.() !== false)) return; // Already on screen, or already on its way. if ( diff --git a/src/layers/streetLevel/sequenceCones.test.mjs b/src/layers/streetLevel/sequenceCones.test.mjs index 5d29d2718..36a7fdad2 100644 --- a/src/layers/streetLevel/sequenceCones.test.mjs +++ b/src/layers/streetLevel/sequenceCones.test.mjs @@ -276,7 +276,7 @@ test('without a display the cones draw nothing and do not throw', async () => { }); state.map = null; sequences.ensureCollections(null); - state.context.waitingForView = () => true; + state.context.viewReady = () => false; sequences.select('A'); source.last('A').resolve(records('A')); await settle(); diff --git a/src/layers/submarineCables/session.js b/src/layers/submarineCables/session.js index 9cedaa82c..ebfaf5147 100644 --- a/src/layers/submarineCables/session.js +++ b/src/layers/submarineCables/session.js @@ -76,9 +76,9 @@ export function createCableSessionLayer({ source, createDisplay }) { updateInterval: 0, statsRefreshInterval: 500, init(_viewer, ctx) { - return this.attachRenderer(_viewer, ctx); + return this.attachRenderer(ctx); }, - attachRenderer(_viewer, { engine = null, signal = null } = {}) { + attachRenderer({ engine = null, signal = null } = {}) { display = engine ? createDisplay(engine) : null; // The cable data load belongs to the session and keeps running; only // this scene's wait for it ends when the scene is released. show() diff --git a/src/layers/submarineCables/session.test.mjs b/src/layers/submarineCables/session.test.mjs index 95eba1af9..afcf3e599 100644 --- a/src/layers/submarineCables/session.test.mjs +++ b/src/layers/submarineCables/session.test.mjs @@ -25,7 +25,7 @@ test('cables arriving during handoff belong to the incoming display', async () = layer.init(null, { engine: 'outgoing' }); const pending = layer.enable(); layer.detachRenderer(); - const handoff = layer.attachRenderer(null, { engine: 'incoming' }); + const handoff = layer.attachRenderer({ engine: 'incoming' }); const dataset = { cables: { features: [] }, landingPoints: { features: [] } }; resolve(dataset); await Promise.all([pending, handoff]); @@ -55,7 +55,7 @@ test('a released scene stops waiting for a stalled cable load, which stays in th const pending = layer.enable(); layer.detachRenderer(); const scene = new AbortController(); - const attaching = layer.attachRenderer(null, { + const attaching = layer.attachRenderer({ engine: 'released', signal: scene.signal, }); @@ -66,7 +66,7 @@ test('a released scene stops waiting for a stalled cable load, which stays in th resolve(dataset); await pending; assert.deepEqual(shown, []); - await layer.attachRenderer(null, { engine: 'next' }); + await layer.attachRenderer({ engine: 'next' }); assert.deepEqual(shown, [['next', dataset]]); layer.destroy(); }); diff --git a/src/layers/traffic/handoff.test.mjs b/src/layers/traffic/handoff.test.mjs index bb7049748..daaaa547f 100644 --- a/src/layers/traffic/handoff.test.mjs +++ b/src/layers/traffic/handoff.test.mjs @@ -172,7 +172,6 @@ test('resuming the viewport republishes the retained roads as a pass that can st const surfaceInput = { roads: [road(0.004, GROUND)], bounds }; Object.assign(state, { _enabled: true, - _restoringViewport: true, _fetching: false, _surfaceInput: surfaceInput, _lastBounds: bounds, diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js index 458a042d3..c1ac0a05a 100644 --- a/src/layers/traffic/lifecycle.js +++ b/src/layers/traffic/lifecycle.js @@ -84,7 +84,7 @@ export function createLifecycle({ * @param {{ engine?: object, waitForCameraRestore?: boolean }} [options] */ init(_viewer, options) { - this.attachRenderer(_viewer, options); + this.attachRenderer(options); layerState._dots = []; layerState._roads = []; layerState._count = 0; @@ -128,15 +128,16 @@ export function createLifecycle({ console.log('[Data:Traffic] Initialized'); }, - attachRenderer( - _viewer, - { engine = null, waitForCameraRestore = false } = {}, - ) { - layerState._restoringViewport = waitForCameraRestore; + show(enabled) { + layerState._display?.show(enabled && layerState._showDots); + }, + attachRenderer(context = {}) { + const { engine = null } = context; + layerState._sceneContext = context; layerState._engine = engine; layerState._display = engine ? services.createRenderer(engine) : null; - layerState._display?.show(layerState._enabled && layerState._showDots); - if (!waitForCameraRestore) readyForSurfaceWork(); + this.show(layerState._enabled); + if (context.viewReady !== false) readyForSurfaceWork(); if (layerState._enabled) { bindScene(); parts.animation.drawDots(); @@ -157,8 +158,6 @@ export function createLifecycle({ } }, resumeViewport() { - if (!layerState._restoringViewport) return; - layerState._restoringViewport = false; readyForSurfaceWork(); if (!layerState._enabled) return; if (layerState._populationPending) parts.retention.resume(); diff --git a/src/layers/traffic/viewport.js b/src/layers/traffic/viewport.js index 9c4296b87..3af9ecd6d 100644 --- a/src/layers/traffic/viewport.js +++ b/src/layers/traffic/viewport.js @@ -175,7 +175,7 @@ export function createViewport({ state: layerState, services, parts, source }) { if ( !layerState._enabled || layerState._waitForScene || - layerState._restoringViewport + layerState._sceneContext?.viewReady === false ) return; clearTimeout(layerState._fetchTimeout); @@ -203,7 +203,7 @@ export function createViewport({ state: layerState, services, parts, source }) { if ( !layerState._enabled || layerState._waitForScene || - layerState._restoringViewport + layerState._sceneContext?.viewReady === false ) return; const alt = getCameraAltitude(); diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index 943f98733..6557a1e6f 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -104,6 +104,10 @@ export function createLifecycle({ hidden = false; } + const picks = { + owner: 'transit', + predicate: (pickedId) => state._vehicles.has(pickedId), + }; const methods = { id: 'transit', name: 'Transit', @@ -114,10 +118,19 @@ export function createLifecycle({ * Create the marker sets. Called once at bootstrap. */ init(viewer, options) { - this.attachRenderer(viewer, options); + this.attachRenderer(options); }, - attachRenderer(viewer, options = {}) { - state._restoringViewport = Boolean(options.waitForCameraRestore); + show(enabled) { + if (state._markers) state._markers.show = enabled; + if (state._animatedMarkers) state._animatedMarkers.show = enabled; + state._overlayHost.setVisible( + TRANSIT_SELECTED_OVERLAY_SOURCE_ID, + enabled, + ); + }, + picks, + attachRenderer(options = {}) { + state._sceneContext = options; if (parts.renderer) throw new Error('Transit layer is already initialized'); if (typeof rendererFactory !== 'function') @@ -151,7 +164,7 @@ export function createLifecycle({ entry.pathRevision = -1; } this.bindScene(); - if (!state._restoringViewport) this.repaint(); + if (state._sceneContext?.viewReady !== false) this.repaint(); } bindStyleEvents(); console.log( @@ -179,11 +192,7 @@ export function createLifecycle({ ); state._overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, true); parts.selection.installClickHandler(); - registerPickOwner( - 'transit', - (pickedId) => state._vehicles.has(pickedId), - parts.renderer?.engine, - ); + registerPickOwner(picks.owner, picks.predicate, parts.renderer?.engine); if (!state._cameraChangedAttached) { state._cameraChangedRemove = parts.renderer.onCameraChanged( parts.viewport.onCameraChanged, @@ -213,13 +222,11 @@ export function createLifecycle({ ); }, resumeViewport() { - if (!state._restoringViewport) return; - state._restoringViewport = false; if (state._enabled && parts.renderer) this.repaint(); }, detachRenderer() { if (!parts.renderer) return; - state._restoringViewport = true; + state._sceneContext = null; unbindVisibility(); clearInterval(state._maintenanceTimer); clearTimeout(state._cameraDebounceTimer); diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index f15de07d5..19c982fab 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -76,7 +76,11 @@ export function createViewport({ state, services, parts }) { globalThis.document?.visibilityState === 'hidden' ) return; - if (!state._enabled || !parts.renderer?.ready || state._restoringViewport) + if ( + !state._enabled || + !parts.renderer?.ready || + state._sceneContext?.viewReady === false + ) return; refreshViewBounds(); const altitude = getCameraAltitude(); @@ -125,7 +129,7 @@ export function createViewport({ state, services, parts }) { } function onCameraChanged() { - if (state._restoringViewport) return; + if (state._sceneContext?.viewReady === false) return; // The bounds update immediately; the feed proximity check stays debounced. // A person dragging the globe should not wait a third of a second for the // vehicles under the cursor to start moving again. diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index a122f3ccd..132af5a2b 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -10,7 +10,7 @@ export function createLifecycle({ options, }) { const { state } = vesselState; - const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites; + const { restoreSpriteOrderOnEnable } = services.sprites; const { releaseContinuousRender } = services.render; const { ensureGeoidReady } = services.geoid; const { registerPickOwner, unregisterPickOwner } = services.picking; @@ -138,6 +138,10 @@ export function createLifecycle({ state.trailMmsi = null; state.trailBackfillToken = 0; } + const picks = { + owner: 'ais-live-vessels', + predicate: (pickedId) => state.records.byMmsi.has(pickedId), + }; const methods = { /** Configure the source before initialization; an active layer keeps its owner. */ setSource(source) { @@ -160,23 +164,26 @@ export function createLifecycle({ init(viewer, { engine = null } = {}) { if (typeof vesselState._source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required'); - if (viewer || engine) this.attachRenderer(viewer, { engine }); + if (viewer || engine) this.attachRenderer({ engine }); }, - attachRenderer(viewer, { engine = null } = {}) { - state.viewer = viewer; - state.renderer ??= createRenderer(viewer, engine); - const scene = viewer || state.renderer?.view || null; + show(enabled) { + components.rendering.setVisible(enabled); + }, + picks, + attachRenderer({ engine = null } = {}) { + state.viewer = null; + state.renderer ??= createRenderer(null, engine); + const scene = state.renderer?.view || null; components.rendering.ensureCollections(scene); components.selection.installInteraction(scene); components.rendering.installRuntime(scene); - restoreSpriteOrder(viewer); - components.rendering.setVisible(state.feed.enabled); + this.show(state.feed.enabled); const horizon = components.rendering.makeOccluder(); for (const record of state.records.all) components.rendering.addRecordPrimitives(record, horizon); if (!state.feed.enabled) return; - bindVisibleScene(viewer, scene); + bindVisibleScene(null, scene); components.tracking.attachSelectedVesselTrail(); components.rendering.updateVisibility(true); if (state.selectedRecord) @@ -288,11 +295,7 @@ export function createLifecycle({ function bindVisibleScene(activeViewer, scene) { // Only MMSI picks present in the live record map belong to this layer. - registerPickOwner( - 'ais-live-vessels', - (pickedId) => state.records.byMmsi.has(pickedId), - state.renderer?.engine, - ); + registerPickOwner(picks.owner, picks.predicate, state.renderer?.engine); restoreSpriteOrderOnEnable('ais', activeViewer); // Vessels are asked for by view, so ask again when the view moves away. removeViewListener(); diff --git a/src/layers/vessels/renderer.test.mjs b/src/layers/vessels/renderer.test.mjs index ae1c3b374..59d2f9da3 100644 --- a/src/layers/vessels/renderer.test.mjs +++ b/src/layers/vessels/renderer.test.mjs @@ -243,7 +243,7 @@ test('a pending AIS poll and selected trail survive replacement of the planet', assert.equal(layer.getSelectedInfo()?.mmsi, '111'); assert.ok(layer.findByQuery('222').position); assert.equal(layer.testing._getVesselStateForTest().trailPositionCount, 3); - layer.attachRenderer(null, { engine: engines[1] }); + layer.attachRenderer({ engine: engines[1] }); assert.equal(recorders[1].sprites.size, 2); const selected = [...recorders[1].sprites].find(({ id }) => id === record); assert.equal(selected.image, vesselChevronIcon('#ffffff', true)); diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 3612baf96..30c725055 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -302,9 +302,9 @@ export function createWeatherLayer({ motion = matchMedia?.('(prefers-reduced-motion: reduce)'); motion?.addEventListener?.('change', onVisibility); documentRef?.addEventListener?.('visibilitychange', onVisibility); - if (options.engine) layer.attachRenderer(nextViewer, options); + if (options.engine) layer.attachRenderer(options); }, - attachRenderer(_viewer, { engine = null } = {}) { + attachRenderer({ engine = null } = {}) { sceneEngine = engine; rendering = engine ? (createNativeRendering(engine, { onChange: notify }) ?? null) diff --git a/src/layers/wildlifeSightings/lifecycle.js b/src/layers/wildlifeSightings/lifecycle.js index 5a19e3244..5849b9034 100644 --- a/src/layers/wildlifeSightings/lifecycle.js +++ b/src/layers/wildlifeSightings/lifecycle.js @@ -522,11 +522,8 @@ export function createLifecycle({ state, services, parts }) { services.overlayHost?.setVisible?.(id, true); parts.selection.install(); services.picking?.registerPickOwner?.( - 'wildlife-sightings', - (id) => - typeof id === 'string' && - id.startsWith('wildlife:') && - state.rowById.has(id.slice(9)), + picks.owner, + picks.predicate, state.engine, ); state.removeCamera = @@ -572,16 +569,24 @@ export function createLifecycle({ state, services, parts }) { return true; } + const picks = { + owner: 'wildlife-sightings', + predicate: (id) => + typeof id === 'string' && + id.startsWith('wildlife:') && + state.rowById.has(id.slice(9)), + }; const methods = { /** Attach this layer to the scene's globe engine. */ init(_viewer, options = {}) { if (state.engine) throw new Error('Wildlife sightings layer is already initialized'); - return methods.attachRenderer(_viewer, options); + return methods.attachRenderer(options); }, /** Draw on `engine` (a scene switch brings another); observations, * filters and selection carry over. */ - attachRenderer(_viewer, { engine = null, signal = null } = {}) { + picks, + attachRenderer({ engine = null, signal = null } = {}) { if (state.destroyed || signal?.aborted) return false; if (state.engine) throw new Error('Wildlife sightings display is already attached'); diff --git a/src/layers/wildlifeSightings/ownership.test.mjs b/src/layers/wildlifeSightings/ownership.test.mjs index e82536cae..f38c93137 100644 --- a/src/layers/wildlifeSightings/ownership.test.mjs +++ b/src/layers/wildlifeSightings/ownership.test.mjs @@ -2252,7 +2252,7 @@ test('an engine switch keeps the data and selection and redraws the same badges const other = ENGINE_KINDS.find((kind) => kind !== h.engine.kind); const next = h.globe(other); const nextCells = watchCells(next); - assert.equal(h.layer.attachRenderer(null, { engine: next }), true); + assert.equal(h.layer.attachRenderer({ engine: next }), true); h.flushIdle(); assert.deepEqual(drawn(next), before, 'the same badges, drawn the same'); assert.deepEqual(style(next), beforeStyle); diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index 928573057..f597be17d 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -119,10 +119,11 @@ export function createWindLayer({ source: 'GFS / ECMWF IFS · FORECAST', updateInterval: 3600_000, init(nextViewer, options) { - layer.attachRenderer(nextViewer, options); + layer.attachRenderer(options); }, - attachRenderer(nextViewer, { engine: nextEngine = null } = {}) { - viewer = nextViewer; + attachRenderer({ engine: nextEngine = null } = {}) { + // The wind backend owns its native viewer. + viewer = nextEngine?.extensions?.cesium?.viewer ?? null; engine = nextEngine; if (!engine) return; rendering = createNativeRendering(engine, { onStatusChange: notify }); diff --git a/src/layers/wind/index.test.mjs b/src/layers/wind/index.test.mjs index 30ef80423..4099941d7 100644 --- a/src/layers/wind/index.test.mjs +++ b/src/layers/wind/index.test.mjs @@ -366,7 +366,9 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis createNativeRendering: () => rendering, }); // A Cesium page: the viewer samples, the engine projects the marker. - layer.init(viewer, { engine: createFakeEngine({ container }) }); + const engine = createFakeEngine({ container }); + engine.extensions = { cesium: { viewer } }; + layer.init(viewer, { engine }); layer.enable(); await layer.update(); layer.setParams({ inspect: true }); diff --git a/src/main.js b/src/main.js index c64f3b465..98c10d2c4 100644 --- a/src/main.js +++ b/src/main.js @@ -89,7 +89,6 @@ window.addEventListener('popstate', () => { function createApplication(kind, engineSwitch, transition) { session.count('applications'); - layerSession.restoringView = engineSwitch; application = createStandaloneApplication({ googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY, cesiumToken: import.meta.env.CESIUM_ION_TOKEN, diff --git a/src/standalone/engineSwitch.js b/src/standalone/engineSwitch.js index 3cd40073a..023390acb 100644 --- a/src/standalone/engineSwitch.js +++ b/src/standalone/engineSwitch.js @@ -67,39 +67,17 @@ export function createEngineAddress({ location, history, flush = () => {} }) { * was where the incoming address agrees (always after a pick) and follows a * visited entry where it differs (handoffTargetEnabled). * @param {import('../app/layerSession.js').LayerSession} session - * @param {object} [options] - * @param {Iterable | null} [options.addressLayerIds] Layers listed by the outgoing address. - * @param {boolean} [options.visit=false] Restore a history entry's layer options; a picker change keeps the live options. */ -export function handOffSession( - session, - { addressLayerIds = null, visit = false } = {}, -) { - const manager = session.manager; - const listed = addressLayerIds ? new Set(addressLayerIds) : null; - session.handoffLayers = new Map( - [...manager.layers].map(([id, entry]) => [ - id, - { - entry: entry.module.attachRenderer ? entry : null, - enabled: manager.isEffectivelyEnabled(id), - durable: listed ? listed.has(id) : undefined, - visit, - }, - ]), - ); - manager.layers.get('directions')?.module.suspendCamera?.(); - manager.layers.get('satellites')?.module.suspendCamera?.(); - session.director?.suspendScene(); +export function handOffSession(session) { + for (const id of session.handoff.cameraOwners) + session.manager.layers.get(id)?.module.cameraOwner.suspend(); + session.handoff.director?.suspendScene(); } -/** Resume what handOffSession parked, once the incoming view is restored. */ -export function resumeSession(session) { - for (const { module } of session.manager.layers.values()) - module.resumeViewport?.(); - if (session.handoffLayers) - session.manager.layers.get('directions')?.module.resumeCamera?.(); - session.director?.resumeScene(); +/** Resume the Director once the incoming view and layer cameras are restored. */ +export async function resumeSession(session) { + await session.manager.resumeScene?.(); + (session.handoff?.director ?? session.director)?.resumeScene(); } /** diff --git a/src/standalone/engineSwitch.test.mjs b/src/standalone/engineSwitch.test.mjs index b26b983bf..5fe929fee 100644 --- a/src/standalone/engineSwitch.test.mjs +++ b/src/standalone/engineSwitch.test.mjs @@ -1,3 +1,4 @@ +import { createSessionHandoff } from '../app/sessionHandoff.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import { @@ -75,8 +76,10 @@ function fakeSession(log) { 'directions', { module: module('directions', { - suspendCamera: () => log.push('suspend:directions'), - resumeCamera: () => log.push('resume:directions'), + cameraOwner: { + suspend: () => log.push('suspend:directions'), + resume: () => log.push('resume:directions'), + }, }), }, ], @@ -84,15 +87,19 @@ function fakeSession(log) { 'satellites', { module: module('satellites', { - suspendCamera: () => log.push('suspend:satellites'), + cameraOwner: { suspend: () => log.push('suspend:satellites'), resume() {} }, }), }, ], ]); return { - handoffLayers: null, + handoff: null, manager: { layers, + resumeScene() { + for (const { module } of layers.values()) module.resumeViewport(); + for (const { module } of layers.values()) module.cameraOwner?.resume(); + }, isEffectivelyEnabled: (id) => id === 'flights', }, director: { @@ -127,7 +134,8 @@ function harness({ signal: lifetime.signal, async leave(from, to) { log.push(`leave:${from}->${to}`); - handOffSession(session, { addressLayerIds: session.addressLayerIds }); + session.handoff = createSessionHandoff(session, { addressLayerIds: session.addressLayerIds }); + handOffSession(session); parked.add(from); running = null; }, @@ -138,7 +146,7 @@ function harness({ ); await outcome?.(next, { ...attempt, warm }); running = next; - resumeSession(session); + await resumeSession(session); }, address: createEngineAddress({ location: browser.location, @@ -146,7 +154,7 @@ function harness({ flush: () => log.push('flush'), }), settle: () => { - if (running) session.handoffLayers = null; + if (running) session.handoff = null; }, remember: (next) => remembered.push(next), onFailure: (error, outcome) => @@ -193,7 +201,7 @@ test('Back and Forward hand the session off exactly like the picker', async () = h.session.addressLayerIds = ['flights']; let captured; h.session.director.suspendScene = () => { - captured = h.session.handoffLayers; + captured = h.session.handoff.layers; h.log.push('suspend:director'); }; await h.machine.select('navara'); @@ -225,10 +233,7 @@ test('Back and Forward hand the session off exactly like the picker', async () = // The visit records the same running state and outgoing address; the // layer-state coordinator compares that with the visited entry. assert.deepEqual(record(captured), afterPick); - assert.equal( - captured.get('flights').entry, - h.session.manager.layers.get('flights'), - ); + assert.equal('entry' in captured.get('flights'), false); h.browser.go(1); await h.machine.reconcile(); assert.equal(h.machine.state().kind, 'navara'); diff --git a/src/standalone/sceneSwitcher.js b/src/standalone/sceneSwitcher.js index 27dad0bbd..bbc1d72d4 100644 --- a/src/standalone/sceneSwitcher.js +++ b/src/standalone/sceneSwitcher.js @@ -1,3 +1,4 @@ +import { createSessionHandoff } from '../app/sessionHandoff.js'; import { createEngineSwitch, handOffSession, @@ -41,24 +42,25 @@ export function createSceneSwitcher({ cover ??= keepFrame?.(components.scene.engine); const style = components.controls.styleManager; cockpit = style.getCockpitState(); - handOffSession(layerSession, { + layerSession.handoff = createSessionHandoff(layerSession, { addressLayerIds: style.shareLinkManager?.currentLayerState?.()?.enabledLayerIds, visit: change?.visit === true, }); + handOffSession(layerSession); renderers.retain(from); await getApplication().detachScene({ handoff: true }, transaction); } count('detaches'); } - async function resume(style) { + async function resume(style, restoreView) { signal.throwIfAborted(); await style.initialRestorePromise; signal.throwIfAborted(); - layerSession.restoringView = false; + restoreView(); count('attachments'); - resumeSession(layerSession); + await resumeSession(layerSession); // A visited entry or a stale track may replace the outgoing subject. const target = style.getAircraftTrackingTarget(); if ( @@ -73,13 +75,16 @@ export function createSceneSwitcher({ } async function enter(kind, { recovery = false } = {}, transaction) { - layerSession.restoringView = true; + let restoreView; + layerSession.viewRestored = new Promise((resolve) => { + restoreView = resolve; + }); let app = getApplication(); if (!app || app.getState().status !== 'ready') { app = startApplication(kind, transaction); try { const { controls } = await app.start(); - await resume(controls.styleManager); + await resume(controls.styleManager, restoreView); } catch (error) { if (!signal.aborted) await app @@ -95,7 +100,8 @@ export function createSceneSwitcher({ } else { await app.attachScene(kind, { transaction, - resume: () => resume(app.getComponents().controls.styleManager), + resume: () => + resume(app.getComponents().controls.styleManager, restoreView), }); } if (recovery) count('rollbacks'); @@ -110,7 +116,7 @@ export function createSceneSwitcher({ cover = null; } if (!components) return; - layerSession.handoffLayers = null; + layerSession.handoff = null; cockpit = null; onSettled?.(); components.controls.styleManager.shareLinkManager?.resumeNavigation?.(); diff --git a/src/standalone/sceneSwitcher.test.mjs b/src/standalone/sceneSwitcher.test.mjs index faf335f67..746d76ca2 100644 --- a/src/standalone/sceneSwitcher.test.mjs +++ b/src/standalone/sceneSwitcher.test.mjs @@ -39,6 +39,7 @@ function fixture({ failResume = false, changeTarget = false } = {}) { session.manager = { layers: new Map([['flights', entry]]), isEffectivelyEnabled: () => true, + resumeScene: () => entry.module.resumeViewport(), }; session.director = { suspendScene: () => log.push('suspend-director'), @@ -96,8 +97,8 @@ test('a scene switch parks once, resumes the session and cockpit, then returns n const { switcher, session, log, current } = fixture(); await switcher.select('navara'); assert.equal(current(), 'navara'); - assert.equal(session.handoffLayers, null); - assert.equal(session.restoringView, false); + assert.equal(session.handoff, null); + await session.viewRestored; assert.deepEqual(log, [ 'suspend-director', ['park', 'first'], @@ -125,7 +126,7 @@ test('resume failure has one candidate release and one rollback on the retained ['attach', 'first'], ['failure', 'resume failed', true], ]); - assert.equal(session.handoffLayers, null); + assert.equal(session.handoff, null); assert.equal(switcher.state().busy, false); }); @@ -135,3 +136,101 @@ test('a changed tracking target does not resume the outgoing cockpit', async () assert.equal(log.includes('cockpit'), false); assert.equal(log.includes('ir'), false); }); + +test('the restored camera precedes retained follow and replay', async () => { + const { LayerLifecycle } = await import('../data/lifecycle.js'); + const session = createLayerSession(); + const log = []; + let restoreCamera; + const restored = new Promise((resolve) => { + restoreCamera = resolve; + }); + const layers = ['follow', 'replay'].map((id) => ({ + id, + name: id, + icon: '', + source: 'test', + init() {}, + enable() {}, + disable() {}, + update() {}, + destroy() {}, + attachRenderer() { + log.push(`attach:${id}`); + }, + detachRenderer() {}, + cameraOwner: { + suspend() { + log.push(`suspend:${id}`); + }, + resume() { + log.push(`resume:${id}`); + }, + }, + })); + const catalog = { + layers, + metadata: layers.map(({ id }) => ({ + id, + token: id, + disposition: 'enabled-only', + })), + }; + const manager = new LayerLifecycle(null); + session.manager = manager; + await manager.bindCatalog({ kind: 'first' }, catalog); + for (const layer of layers) await manager.setEnabled(layer.id, true); + let current = 'first'; + let attached; + const attachment = new Promise((resolve) => { + attached = resolve; + }); + const style = { + initialRestorePromise: restored, + getCockpitState: () => null, + getAircraftTrackingTarget: () => null, + }; + const app = { + getState: () => ({ status: 'ready' }), + getComponents: () => ({ + scene: { engine: { kind: current } }, + controls: { styleManager: style }, + }), + async detachScene() { + await manager.detachScene(); + }, + async attachScene(kind, { resume }) { + current = kind; + await manager.bindCatalog({ kind }, catalog, { + viewRestored: session.viewRestored, + }); + attached(); + await resume(); + }, + }; + const switcher = createSceneSwitcher({ + kind: 'first', + requested: () => current, + layerSession: session, + getApplication: () => app, + renderers: { retain() {} }, + address: { select() {}, commit() {}, restore() {} }, + }); + const switching = switcher.select('navara'); + await attachment; + assert.deepEqual(log, [ + 'suspend:follow', + 'suspend:replay', + 'attach:follow', + 'attach:replay', + ]); + log.push('camera-restored'); + restoreCamera(); + await switching; + assert.deepEqual(log.slice(-3), [ + 'camera-restored', + 'resume:follow', + 'resume:replay', + ]); + await manager.destroyAll(); +}); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index f657e5040..b85114616 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -405,7 +405,7 @@ export class StyleManager extends ShellScene { this._shareRestoration.start({ engineSwitch, - retainScene: Boolean(this._layerSession?.handoffLayers), + retainScene: Boolean(this._layerSession?.handoff), }); // Keep the parameter panel from overlapping toggle controls. @@ -771,7 +771,7 @@ export class StyleManager extends ShellScene { setClearBusy: (busy) => this._publishClearBusy(busy), }, }); - if (this._layerSession?.handoffLayers) + if (this._layerSession?.handoff) this._contextControls.resumeScene(this._layerSession.contextScene); // The Contacts layer publishes its panel; the view calls its navigation. contextStore.connectAwareness(militaryAwarenessLayer); diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index d41c1b3b2..291c9ab03 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -121,7 +121,7 @@ export class ShareRestoration { constructor({ engine, canvas = engine?.host.canvas, - handoffLayers = null, + handoff = null, navigation, syncShareState, syncModels3d, @@ -133,7 +133,7 @@ export class ShareRestoration { Object.assign(this, { engine, canvas, - handoffLayers, + handoff, navigation, syncShareState, syncModels3d, @@ -396,13 +396,13 @@ export class ShareRestoration { ); this._layerStateRestorePromise = this._layerStateCoordinator.start({ shareLayerState: this._initialShareState?.layerState || null, - handoffLayers: this.handoffLayers, + handoff: this.handoff, shareCreatedAtMs: this._initialShareState?.sharedAtMs ?? null, // Any valid camera/style share isolates recipient-local preferences, // including legacy and malformed-v2 layer payloads. allowLocalState: !this._initialShareState, }); - this.handoffLayers = null; + this.handoff = null; if (this._initialShareSelectionSuperseded) { this._layerStateCoordinator.cancelPendingShareTracking( 'superseded-before-layer-coordinator-start', diff --git a/src/ui/shareRestorationEngine.test.mjs b/src/ui/shareRestorationEngine.test.mjs index e595896bc..671556df4 100644 --- a/src/ui/shareRestorationEngine.test.mjs +++ b/src/ui/shareRestorationEngine.test.mjs @@ -1,6 +1,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { layersNotOnThisEngine } from './shareRestoration.js'; +import { layersNotOnThisEngine, ShareRestoration } from './shareRestoration.js'; +import { createSessionHandoff } from '../app/sessionHandoff.js'; +import { LayerLifecycle } from '../data/lifecycle.js'; +import { + LAYER_STATE_REGISTRY, + REGISTERED_LAYER_IDS, +} from '../data/layerState.js'; test('the notice names the link layers this engine leaves out', () => { assert.deepEqual( @@ -14,3 +20,81 @@ test('the notice names the link layers this engine leaves out', () => { ); assert.deepEqual(layersNotOnThisEngine([]), []); }); + +const viewportLayers = ['bikeshare', 'transit', 'traffic']; +for (const scenario of [ + { + name: 'engine choice retains operational layers', + enabled: true, + outgoing: [], + incoming: [], + visit: false, + expected: true, + }, + { + name: 'Back restores the visited disabled layers', + enabled: true, + outgoing: viewportLayers, + incoming: [], + visit: true, + expected: false, + }, + { + name: 'Forward restores the visited enabled layers', + enabled: false, + outgoing: [], + incoming: viewportLayers, + visit: true, + expected: true, + }, +]) { + test(`share restoration: ${scenario.name}`, async (t) => { + const manager = new LayerLifecycle(null); + for (const id of REGISTERED_LAYER_IDS) + manager.register({ + id, + init() {}, + enable() {}, + update() {}, + disable() {}, + attachRenderer() {}, + setParams() { + return true; + }, + }); + manager.finalizeRegistrations(LAYER_STATE_REGISTRY); + t.after(() => manager.destroyAll()); + for (const id of viewportLayers) + await manager.setEnabled(id, scenario.enabled, { origin: 'user' }); + const models = viewportLayers.map((id) => manager.layers.get(id).module); + const handoff = createSessionHandoff( + { manager, director: null }, + { addressLayerIds: scenario.outgoing, visit: scenario.visit }, + ); + const restoration = new ShareRestoration({ + handoff, + syncModels3d() {}, + showStatus() {}, + }); + t.after(() => restoration.destroy()); + restoration.attachLinks({ + parseInitialHash: () => ({ + layerState: { enabledLayerIds: scenario.incoming }, + }), + }); + restoration.connect(manager); + const results = await restoration._layerStateRestorePromise; + assert.equal( + restoration.handoff, + null, + 'the coordinator consumes the handoff once', + ); + for (const [index, id] of viewportLayers.entries()) { + assert.equal(manager.isEffectivelyEnabled(id), scenario.expected, id); + assert.equal(manager.layers.get(id).module, models[index]); + const result = results.find((result) => result.layerId === id); + assert.equal(result.succeeded, true, id); + if (!scenario.visit) assert.equal(result.phase, 'retained', id); + } + }); +} diff --git a/src/ui/shellScene.js b/src/ui/shellScene.js index 9dbc15a42..0899ba312 100644 --- a/src/ui/shellScene.js +++ b/src/ui/shellScene.js @@ -38,7 +38,7 @@ export class ShellScene extends ShellFacade { this._shareRestoration = new ShareRestoration({ engine, canvas: engine?.host.canvas ?? viewer?.canvas, - handoffLayers: layerSession?.handoffLayers, + handoff: layerSession?.handoff, navigation: this._navigation, syncShareState: () => this._syncShareState(), syncModels3d: (state) => this._syncModels3dFromLayerState(state), @@ -365,7 +365,7 @@ export class ShellScene extends ShellFacade { this._publishActiveStyle(); this._shareRestoration.start({ engineSwitch, - retainScene: Boolean(this._layerSession?.handoffLayers), + retainScene: Boolean(this._layerSession?.handoff), }); this._layerBindings.observeCamera(); this._layoutRightPanels(); diff --git a/src/voice/voiceWorkflowHarness.mjs b/src/voice/voiceWorkflowHarness.mjs index 0b5ea9725..ec88b4f00 100644 --- a/src/voice/voiceWorkflowHarness.mjs +++ b/src/voice/voiceWorkflowHarness.mjs @@ -172,7 +172,7 @@ export function harness({ engine.fake.script('viewRectangle', () => viewer.camera.computeViewRectangle(), ); - imagery.layer.attachRenderer(null, { engine }); + imagery.layer.attachRenderer({ engine }); const referents = createReferentRegistry(); let snapshot = pointer; const osmCalls = [];