diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index b05cf9980..700d949c4 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -1,13 +1,13 @@ { "metrics": { - "attachRendererWithViewer": 33, - "restoreFlagCopies": 9, + "attachRendererWithViewer": 0, + "restoreFlagCopies": 0, "surfaceCallsOutsideGlobe": 10, - "engineSwitchLayerIds": 3, + "engineSwitchLayerIds": 0, "cesiumLayerRendererLines": 0, "navaraLayerRendererLines": 0, "cesiumImportFiles": 2, - "viewerLines": 666, + "viewerLines": 613, "navaraKindChecks": 1, "engineSniffCalls": 0, "engineExtensionReads": 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 78d3b001a..2cfc5485c 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -999,6 +999,7 @@ export function createBhoteKoshiEventLayer({ panelStore = useBhoteKoshiPanelStore(applicationPinia()), } = {}) { let _viewer = null; + let _attachmentContext = null; overlayHost = bindOverlayHost(overlayHost, () => _engine); let _engine = null; let _native = null; @@ -2681,6 +2682,7 @@ export function createBhoteKoshiEventLayer({ function applyCinematicCamera() { if ( + _attachmentContext?.viewReady === false || !_cinematicActive || !cameraForScene() || _cinematicKeyframes.length === 0 @@ -3528,16 +3530,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()) { @@ -3612,7 +3616,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'); @@ -4010,6 +4014,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 88ff6efe9..c34d82fff 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 2e24f27a0..f98e4a758 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 3d7b659fd..675d2e90f 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -61,7 +61,6 @@ export function createWindLayer({ } = {}) { if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Wind requires a snapshot source'); - let viewer = null; let engine = null; let request = null; let enabled = false; @@ -112,10 +111,9 @@ 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 } = {}) { engine = nextEngine; if (!engine) return; rendering = createNativeRendering(engine, { onStatusChange: notify }); @@ -135,7 +133,7 @@ export function createWindLayer({ inspectionMarker?.destroy(); inspectionMarker = null; rendering?.destroy(); - rendering = viewer = engine = null; + rendering = engine = null; }, attachShellServices() { rendering?.rehome?.(); 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 f67867c8e..2cabc3c53 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 = [];