diff --git a/scripts/qa-engine-switch.mjs b/scripts/qa-engine-switch.mjs index b39b92f13..cc967add1 100644 --- a/scripts/qa-engine-switch.mjs +++ b/scripts/qa-engine-switch.mjs @@ -67,6 +67,8 @@ try { await page.waitForSelector('#globe-engine', { visible: true }); await page.evaluate(() => { window.__switchDocument = document; + window.__quakeEntry = window.__godsEyeView.dataManager.layers.get('earthquakes'); + window.__quakeTimer = window.__quakeEntry.intervalId; window.__renderers ??= new Map(); window.__renderers.set( window.__godsEyeView.engine.kind, @@ -93,6 +95,8 @@ try { const previous = window.__renderers.get(engine.kind); return ( !window.__switchSplash && + window.__quakeEntry === window.__godsEyeView.dataManager.layers.get('earthquakes') && + window.__quakeTimer === window.__quakeEntry.intervalId && (!previous || previous === engine) && !window.__outgoingEngine.isDestroyed() && !window.__outgoingEngine.host.canvas.isConnected && diff --git a/scripts/qa-layer-handoff.mjs b/scripts/qa-layer-handoff.mjs new file mode 100644 index 000000000..e451ae0d5 --- /dev/null +++ b/scripts/qa-layer-handoff.mjs @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer'; +import { blockPaidQaMaps } from './qa-map-policy.mjs'; + +const url = new URL(process.argv[2]); +if ( + !['localhost', '127.0.0.1'].includes(url.hostname) || + ['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( + url.port, + ) +) + throw new Error('Supply an unreserved local keyless preview URL'); +url.search = '?engine=navara&welcome=0'; +url.hash = + 'v=2&lat=40.2539&lon=-125.5779&alt=21130&heading=360&pitch=-35&map=sentinel2&l=f&lo=f.e.1_f.t.71be26'; +const browser = await puppeteer.launch({ + headless: true, + args: ['--use-angle=metal', '--enable-gpu'], +}); +try { + const page = await browser.newPage(); + await blockPaidQaMaps(page); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.setViewport({ width: 1280, height: 720 }); + await page.evaluateOnNewDocument(() => { + const fetch = window.fetch.bind(window); + window.__flightPolls = 0; + window.fetch = (input, options) => { + const path = new URL(input.url ?? input, document.baseURI).pathname; + const now = Math.floor(Date.now() / 1000); + if (path === '/api/flights') { + window.__flightPolls++; + return Promise.resolve( + Response.json({ + time: now, + states: [ + [ + '71be26', + 'GEV123', + 'QA', + now, + now, + -125.5779, + 40.2539, + 9700, + false, + 200, + 45, + 0, + null, + 9700, + null, + false, + 0, + ], + ], + }), + ); + } + if (path === '/api/military') + return Promise.resolve(Response.json({ now: Date.now(), ac: [] })); + if (path === '/api/flights/track') + return Promise.resolve(Response.json({ path: [] })); + if (path.startsWith('/api/flights/') || path.startsWith('/api/openai/')) + return Promise.resolve(Response.json({})); + return fetch(input, options); + }; + }); + await page.goto(url.href, { waitUntil: 'domcontentloaded' }); + const ready = (kind) => + page.waitForFunction( + (engine) => + window.__godsEyeView?.engine.kind === engine && + !document.querySelector('#globe-engine')?.disabled && + document.querySelector('#loading-screen.hidden') && + !document.querySelector('.engine-switch-frame'), + { timeout: 90000 }, + kind, + ); + await ready('navara'); + await page.evaluate( + () => window.__godsEyeView.styleManager.initialRestorePromise, + ); + await page.waitForSelector('#cockpit-entry', { visible: true }); + await page.click('#cockpit-entry'); + await page.waitForFunction( + () => window.__godsEyeView.styleManager.getCockpitState().active, + ); + await page.evaluate(() => { + const { dataManager, styleManager, tileset } = window.__godsEyeView; + if (window.__GOOGLE_MAPS_API_KEY__ || tileset) + throw Error('Keyless preview required'); + styleManager.cockpitView.setVisionMode('thermal'); + const entry = dataManager.layers.get('flights'); + window.__handoff = { + dataManager, + entry, + timer: entry.intervalId, + model: entry.module, + pollCount: window.__flightPolls, + timestamp: entry.module.getStats().lastUpdate, + started: Date.now(), + }; + }); + for (const kind of ['cesium', 'navara']) { + await page.select('#globe-engine', kind); + await ready(kind); + const result = await page.evaluate(() => { + const { dataManager, engine, styleManager } = window.__godsEyeView; + const before = window.__handoff; + const entry = dataManager.layers.get('flights'); + const cockpit = styleManager.getCockpitState(); + return { + sameOwner: + before.dataManager === dataManager && + before.entry === entry && + before.model === entry.module, + sameTimer: + before.timer === entry.intervalId && entry.intervalId != null, + sourcePolls: window.__flightPolls - before.pollCount, + elapsed: Date.now() - before.started, + stamp: entry.module.getStats().lastUpdate, + initialStamp: before.timestamp, + id: cockpit.subject?.id, + active: cockpit.active, + mode: cockpit.visionMode, + hud: !document.querySelector('#cockpit-hud').hidden, + drawn: entry.module.qaDrawn().some((row) => row.id === '71be26'), + height: engine.camera.height(), + }; + }); + assert.ok( + result.sameOwner && + result.sameTimer && + result.active && + result.hud && + result.drawn, + JSON.stringify(result), + ); + assert.equal(result.id, '71be26'); + assert.equal(result.mode, 'thermal'); + if (result.elapsed < 30000) { + assert.equal(result.sourcePolls, 0); + assert.equal(result.stamp, result.initialStamp); + } + await page.waitForFunction( + () => Math.abs(window.__godsEyeView.engine.camera.height() - 9700) < 1000, + ); + console.log( + `PASS ${kind} cockpit, aircraft, HUD, manager and timer (${result.sourcePolls} polls since entry)`, + ); + } + await page.evaluate(() => + window.__godsEyeView.styleManager.controlCockpit('exit'), + ); + await page.waitForFunction( + () => !window.__godsEyeView.styleManager.getCockpitState().active, + ); + assert.deepEqual(errors, []); +} finally { + await browser.close(); +} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 56e028b98..4664ad992 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -66,6 +66,7 @@ export function createApplicationCatalog({ metadata = APPLICATION_LAYER_METADATA, vesselOptions, resolveAsset, + layerSession, nepalBoundaryResolver, streetLevelProviders, engineKind = 'cesium', @@ -79,30 +80,48 @@ export function createApplicationCatalog({ const sourceComposition = resolveCatalogSources(sources); sources = sourceComposition.sources; - const militaryRegistry = createMilitaryRegistry(); + const militaryRegistry = + layerSession?.militaryRegistry ?? createMilitaryRegistry(); const weatherClock = createWeatherClock(); const dispose = () => { signal.removeEventListener('abort', dispose); - militaryRegistry.dispose(); + if (!layerSession) militaryRegistry.dispose(); weatherClock.destroy(); }; signal.addEventListener('abort', dispose, { once: true }); try { - if (sourceComposition.isConfigured('military')) - militaryRegistry.configureSource(sources.military, { signal }); + if (!layerSession?.militaryRegistry) { + const lifetime = layerSession?.lifetime.signal ?? signal; + if (sourceComposition.isConfigured('military')) + militaryRegistry.configureSource(sources.military, { signal: lifetime }); + if (layerSession) { + layerSession.militaryRegistry = militaryRegistry; + lifetime.addEventListener('abort', militaryRegistry.dispose, { + once: true, + }); + } + } // Shared instances (awareness and launches read others) are made once, // when the first layer that needs them is built. const once = (create) => { let value; return () => (value ??= create()); }; + const sessionModel = (id, create) => { + if (!layerSession) return create(); + const models = (layerSession.models ??= new Map()); + if (!models.has(id)) models.set(id, create()); + return models.get(id); + }; const flights = once(() => - createApplicationFlights({ - surface, - source: sources.flights, - militaryRegistry, - resolveAsset, - }), + sessionModel('flights', () => + createApplicationFlights({ + surface, + source: sources.flights, + militaryRegistry, + resolveAsset, + }), + ), ); const military = once(() => createApplicationMilitary({ @@ -158,7 +177,10 @@ export function createApplicationCatalog({ ], [ 'earthquakes', - () => createApplicationEarthquakes({ source: sources.earthquakes }), + () => + sessionModel('earthquakes', () => + createApplicationEarthquakes({ source: sources.earthquakes }), + ), ], [ 'air-quality', diff --git a/src/app/data.js b/src/app/data.js index ce6f1451b..bc6b0a394 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -8,12 +8,13 @@ export function createApplicationData({ controls: { styleManager }, catalog, allowQaRegistration, + layerSession, onData, defer, }) { // Initialize data layer manager // The modules get { signal, engine } on every call (viewer null on Navara). - const dataManager = new LayerLifecycle(viewer, { + const dataManager = layerSession?.manager ?? new LayerLifecycle(viewer, { engine, allowQaRegistration, // A panel's host may report it hidden while it is on screen; its layers @@ -21,7 +22,12 @@ export function createApplicationData({ visibilityTarget: panelAwareVisibility(document), getSourceAvailability: catalog?.getSourceAvailability, }); + if (layerSession) layerSession.manager = dataManager; defer(async () => { + if (layerSession && !layerSession.lifetime.signal.aborted) { + await dataManager.detachScene(); + return; + } await dataManager.destroyAll(); if (dataManager.layers.size) throw new Error( @@ -35,12 +41,11 @@ export function createApplicationData({ onData?.(dataManager); if (!catalog?.layers || !catalog?.metadata) throw new TypeError('An application layer catalog is required'); - for (const layer of catalog.layers) dataManager.register(layer); + dataManager.bindCatalog(viewer, engine, catalog); for (const layer of catalog.layers) layer.attachDataManager?.(dataManager); for (const layer of catalog.layers) layer.attachMapStackController?.(mapStackController); - // Restoration starts only after the caller's complete registry is sealed. - dataManager.finalizeRegistrations(catalog.metadata); + if (allowQaRegistration) { window.__gevQaRegisterLayer = (targetManager, layerModule) => { if (targetManager !== dataManager) diff --git a/src/app/scene.js b/src/app/scene.js index be99f90c1..46ea0a770 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -29,12 +29,14 @@ export async function createApplicationScene({ signal, defer, rendererCache, + layerSession, ...options }) { - const operations = createApplicationOperations({ + const operations = layerSession?.operations ?? createApplicationOperations({ requests: requestServices, - signal, + signal: layerSession?.lifetime.signal ?? signal, }); + if (layerSession) layerSession.operations = operations; defer(initLogoGaze()); const previousKey = window.__GOOGLE_MAPS_API_KEY__; if (googleApiKey) { @@ -55,6 +57,14 @@ export async function createApplicationScene({ const { viewer, engine, tileset, mapStackController } = rendererCache ? await rendererCache.acquire(options.engineKind, build, defer) : await build({ defer }); + if (layerSession) { + // DEM cells belong to the session. Mesh samples came from the outgoing + // scene, including when the incoming canvas was warm in the cache. + operations.surface.groundFloor.clearMeshFloorCells(); + operations.surface.groundFloor.setMeshFloorPreferred( + mapStackController.getActiveId() === 'photoreal', + ); + } return { viewer, engine, tileset, mapStackController, operations }; } diff --git a/src/contextModePolicy.js b/src/contextModePolicy.js index e40b9eb52..9058ff8a7 100644 --- a/src/contextModePolicy.js +++ b/src/contextModePolicy.js @@ -412,7 +412,7 @@ export function contextRestoreLayerIds(snapshot = {}) { } /** - * Contacts mode requires both observed-flight feeds. Navara also supports + * Contacts mode requires both observed-flight feeds. Both globes support * first-person flight directly from a single selected aircraft layer. * * @param {object} input Current context and dependency visibility. diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index 40208831a..0749f0019 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -470,6 +470,36 @@ export class LayerStateCoordinator { return null; } + // On an in-page engine switch, an attached session model can already own + // this exact contact. Ordinary shared links must still verify the source. + retainedTrackingTarget() { + const selected = this._selectedShareTrackingTarget(); + if (!selected || !this.dataManager.isEnabled(selected.layerId)) return null; + const model = this.dataManager.layers.get(selected.layerId)?.module; + if ( + model?.attachRenderer && + model.getParams?.()[selected.optionKey] === selected.targetId && + model.hasContact?.(selected.targetId) === true + ) return selected; + return null; + } + + resumeRetainedTracking(previous) { + const selected = this.retainedTrackingTarget(); + if ( + !selected || selected.layerId !== previous.layerId || + selected.targetId !== previous.targetId + ) return null; + const model = this.dataManager.layers.get(selected.layerId).module; + const followed = model.trackById(selected.targetId, { + origin: LAYER_RESTORE_ORIGINS.share, + }); + if (!followed) return null; + const result = { ...selected, status: 'found', classification: 'followed', cleared: false }; + this._publishTrackingRestoreStatus(result); + return result; + } + _passivelyClearTrackingSelection(selected) { const current = this._durableState.options?.[selected.optionOwner]?.[selected.optionKey]; diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 7b7615b3b..cc8c504be 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -121,6 +121,50 @@ export class LayerLifecycle { this._registerLayer(layerModule); } + /** + * Release the scene only. Models with an explicit renderer lifetime keep + * their entry, polling interval, visibility intents and in-flight source. + * Legacy modules use their ordinary teardown until they gain that contract. + */ + async detachScene() { + for (const [id, entry] of [...this.layers]) { + await entry.toggleChain.catch(() => {}); + if (entry.module.attachRenderer && entry.module.detachRenderer) { + if (entry.initialized) entry.module.detachRenderer(this.viewer); + } else if (!(await this.destroyLayer(id))) { + throw new Error(`Cannot detach layer ${id}`); + } + } + this.viewer = null; + this.engine = null; + this._registrationsFinalized = false; + this._registrationDispositions = null; + } + + /** Bind the next catalog only after the preceding scene released its entries. */ + bindCatalog(viewer, engine, catalog) { + if (this._registrationsFinalized) throw new Error('Scene already attached'); + this.viewer = viewer; + this.engine = engine ?? engineFromViewer(viewer) ?? null; + this._catalogSourceAvailability = catalog.getSourceAvailability; + for (const layer of catalog.layers) { + const entry = this.layers.get(layer.id); + if (!entry) this.register(layer); + else { + if (entry.module !== layer) throw new Error(`Layer owner changed: ${layer.id}`); + if (entry.initialized) layer.attachRenderer(viewer, this._moduleContext()); + } + } + // Retained entries may appear anywhere in the directory. Preserve both + // catalog order (panel/serialization) and each live entry's identity. + for (const { id } of catalog.layers) { + const entry = this.layers.get(id); + this.layers.delete(id); + this.layers.set(id, entry); + } + this.finalizeRegistrations(catalog.metadata); + } + /** Register a synthetic layer after sealing in an explicitly dev-enabled manager. */ registerForQa(layerModule) { if (!this._allowQaRegistration || !this._registrationsFinalized) { diff --git a/src/layers/earthquakes/index.js b/src/layers/earthquakes/index.js index e18ee3e2d..6d3301208 100644 --- a/src/layers/earthquakes/index.js +++ b/src/layers/earthquakes/index.js @@ -16,9 +16,9 @@ export { createUsgsEarthquakeSource } from './source.js'; /** * Own one earthquake display and its refresh lifecycle. * renderer.create({viewer, engine}) owns drawing and returns: - * setVisible(boolean), replace(discs), records(limit), destroy(). + * setVisible(boolean), replace(discs), destroy(). * Discs are plain ECEF anchors, metre radii and Rgba colours; renderers keep - * source order and return plain raw analyst rows (model.mapAnalystRecord). + * source order. The layer retains observations for queries while detached. */ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) { if (typeof source?.getSnapshot !== 'function') @@ -26,7 +26,10 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) { if (!overlayHost) throw new TypeError('Earthquakes require an overlay host'); if (typeof renderer?.create !== 'function') throw new TypeError('Earthquakes require a renderer factory'); - let _viewer = null; + let initialized = false; + let observations = []; + let discs = []; + let labels = []; let _request = null; let _renderer = null; let _count = 0; @@ -34,6 +37,14 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) { let _lastError = null; let _enabled = false; + function publishLabels() { + overlayHost.setEntries(EARTHQUAKE_OVERLAY_SOURCE_ID, labels, { + cohortLimit: EARTHQUAKE_OVERLAY_COHORT_LIMIT, + collisionCapacity: EARTHQUAKE_OVERLAY_COLLISION_CAPACITY, + moving: false, + }); + } + const layer = { id: 'earthquakes', name: 'Earthquakes (24h)', @@ -42,17 +53,32 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) { updateInterval: 60000, init(viewer, { engine = null } = {}) { - if (_renderer) throw new Error('Earthquake layer is already initialized'); - _viewer = viewer; + if (initialized) throw new Error('Earthquake layer is already initialized'); + initialized = true; + try { + if (viewer || engine) this.attachRenderer(viewer, { engine }); + } catch (error) { + this.destroy(); + throw error; + } + console.log('[Data:Earthquakes] Initialized'); + }, + + attachRenderer(viewer, { engine = null } = {}) { + if (!initialized || _renderer) throw new Error('Invalid earthquake scene attachment'); _renderer = renderer.create({ viewer, engine }); if (!_renderer) throw new Error('Earthquake renderer unavailable'); - _renderer.setVisible(false); - _count = 0; - _lastUpdate = null; - _lastError = null; - _enabled = false; + _renderer.setVisible(_enabled); + if (discs.length) _renderer.replace(discs); + overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, _enabled); + if (_enabled) publishLabels(); + }, + + detachRenderer() { + overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID); overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); - console.log('[Data:Earthquakes] Initialized'); + _renderer?.destroy(); + _renderer = null; }, enable(viewer) { @@ -60,7 +86,10 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) { // No continuous-render hold: the discs are static geometry now, so the // layer has no per-frame animator to keep the render loop alive for. _renderer?.setVisible(true); - overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, true); + if (_renderer) { + overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, true); + if (labels.length) publishLabels(); + } }, disable(viewer) { @@ -73,7 +102,7 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) { }, async update(viewer) { - if (!_enabled || !_renderer) return false; + if (!_enabled) return false; _request?.abort(); const request = new AbortController(); _request = request; @@ -128,17 +157,12 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) { ); } - _renderer.replace(nextDiscs); - if (_enabled) { - overlayHost.setEntries( - EARTHQUAKE_OVERLAY_SOURCE_ID, - selectEarthquakeOverlayCohort(overlayEntries), - { - cohortLimit: EARTHQUAKE_OVERLAY_COHORT_LIMIT, - collisionCapacity: EARTHQUAKE_OVERLAY_COLLISION_CAPACITY, - moving: false, - }, - ); + observations = rows; + discs = nextDiscs; + labels = selectEarthquakeOverlayCohort(overlayEntries); + if (_renderer) { + _renderer.replace(discs); + publishLabels(); } _count = count; @@ -157,15 +181,15 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) { } }, - destroy(viewer = _viewer) { + destroy() { _request?.abort(); _request = null; - _viewer = null; + initialized = false; _enabled = false; - overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID); - overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); - _renderer?.destroy(); - _renderer = null; + this.detachRenderer(); + observations = []; + discs = []; + labels = []; _count = 0; _lastUpdate = null; _lastError = null; @@ -180,11 +204,13 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) { * @returns {Array} See mapAnalystRecord for the record shape. */ getAnalystRecords(maxCount = 2000) { - if (!_enabled || !_renderer) return []; + if (!_enabled) return []; const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; - return _renderer.records(limit).map(mapAnalystRecord); + return observations.slice(0, limit).map((row, index) => + mapAnalystRecord({ ...row, id: row.usgsId, depth: row.depthKm }, index), + ); }, getStats() { diff --git a/src/layers/earthquakes/ownership.test.mjs b/src/layers/earthquakes/ownership.test.mjs index 7d9469157..ad3f39d45 100644 --- a/src/layers/earthquakes/ownership.test.mjs +++ b/src/layers/earthquakes/ownership.test.mjs @@ -92,3 +92,28 @@ test('USGS body completion honors cancellation even with an uncooperative transp name: 'AbortError', }); }); + +test('an observation received between scenes is queryable and draws without a second fetch', async () => { + let resolve, polls = 0; + const h = harness({ getSnapshot() { + polls++; + return new Promise(done => { resolve = done; }); + } }); + try { + const pending = h.layer.update(); + h.layer.detachRenderer(); + assert.equal(h.sources.length, 0); + resolve([row]); + assert.equal(await pending, true); + const [record] = h.layer.getAnalystRecords(); + assert.equal(record.id, row.usgsId); + assert.equal(record.lat, row.lat); + assert.equal(h.events.length, 0); + h.layer.attachRenderer(h.viewer); + assert.equal(h.sources[0].entities.values.length, 1); + assert.equal(h.events.length, 1); + assert.equal(polls, 1); + } finally { + h.layer.destroy(); + } +}); diff --git a/src/main.js b/src/main.js index 7bfbccb5a..8f0e01e03 100644 --- a/src/main.js +++ b/src/main.js @@ -15,11 +15,14 @@ let kind = pageEngineKind(); let application; let busy = false; const renderers = createRendererCache(); +const layerSession = { lifetime: new AbortController() }; window.addEventListener('pagehide', (event) => { - if (!event.persisted) renderers.clear(); + if (event.persisted) return; + layerSession.lifetime.abort(); + void application?.destroy().catch(report).finally(() => renderers.clear()); }); -async function start(next, engineSwitch = false) { +async function start(next, engineSwitch = false, cockpit = null) { kind = next; application = createStandaloneApplication({ googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY, @@ -27,11 +30,25 @@ async function start(next, engineSwitch = false) { allowQaRegistration: import.meta.env.DEV, engineKind: next, rendererCache: renderers, + layerSession, engineSwitch, }); const { scene, controls } = await application.start(); if (engineSwitch) { - await controls.styleManager.initialRestorePromise; + const style = controls.styleManager; + await style.initialRestorePromise; + // Only resume the aircraft that actually survived the handoff. A history + // entry may select a different subject, and a stale track may disappear. + const target = style.getAircraftTrackingTarget(); + if ( + cockpit?.active && + target && + target?.id === cockpit.subject?.id && + target?.layerId === cockpit.subject?.layerId && + style.controlCockpit('enter').ok + ) { + style.cockpitView.setVisionMode(cockpit.visionMode); + } await waitForGlobeFrame(scene.engine); } } @@ -106,12 +123,16 @@ async function reconcile(remember = false) { present(); const previous = kind; let cover; + let cockpit; try { while (pageEngineKind() !== kind) { cover ??= keepGlobeFrame(application.getComponents().scene.engine); + cockpit = application + .getComponents() + .controls.styleManager.getCockpitState(); renderers.retain(kind); await application.destroy(); - await start(pageEngineKind(), true); + await start(pageEngineKind(), true, cockpit); } if (remember) { try { @@ -127,7 +148,7 @@ async function reconcile(remember = false) { history.replaceState(history.state, '', url); try { await application.destroy(); - await start(previous, true); + await start(previous, true, cockpit); application .getComponents() .controls.styleManager._showToast(describeError(error)); diff --git a/src/services/groundFloor.js b/src/services/groundFloor.js index 27b7dc5e4..922103de1 100644 --- a/src/services/groundFloor.js +++ b/src/services/groundFloor.js @@ -500,7 +500,7 @@ export function createGroundFloor({ terrain, signal }) { } /** Test hook: clears mesh cells (never called at runtime — cells are valid all session). */ - function _clearMeshFloorCellsForTest() { + function clearMeshFloorCells() { _meshCells.clear(); } @@ -663,7 +663,8 @@ export function createGroundFloor({ terrain, signal }) { meshFloorSampleWithinPrior, reportValidatedMeshFloorCell, cachedMeshFloor, - _clearMeshFloorCellsForTest, + clearMeshFloorCells, + _clearMeshFloorCellsForTest: clearMeshFloorCells, cachedGroundFloor, FLOOR_RESOLVE_DEADLINE_MS, resolveGroundFloorCellsBounded, diff --git a/src/standalone/application.js b/src/standalone/application.js index 224c41be8..f89d61842 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -42,6 +42,7 @@ export function createStandaloneApplication({ allowQaRegistration = false, engineKind = pageEngineKind(), rendererCache, + layerSession, engineSwitch = false, }) { if (owner) @@ -89,6 +90,7 @@ export function createStandaloneApplication({ loaderStatus, engineKind, rendererCache, + layerSession, }); // The catalog of this engine: on Navara only the layers with a Navara // renderer (src/app/layers/renderers.js; none yet), the others' state @@ -108,6 +110,7 @@ export function createStandaloneApplication({ : {}; catalog = createStandaloneCatalog({ engineKind, + layerSession, sources: { ...sources, ...qaSources }, streetLevelProviders, nepalBoundaryResolver: (signal) => @@ -132,7 +135,7 @@ export function createStandaloneApplication({ catalog, }), createData: (context) => - createStandaloneData({ ...context, allowQaRegistration, catalog }), + createStandaloneData({ ...context, allowQaRegistration, catalog, layerSession }), createTools: async (context) => { const tools = await createStandaloneTools({ ...context, diff --git a/src/standalone/catalog.js b/src/standalone/catalog.js index d58134fd4..05a412141 100644 --- a/src/standalone/catalog.js +++ b/src/standalone/catalog.js @@ -8,6 +8,7 @@ export { createStandaloneReferenceSources } from './layerSources.js'; export function createStandaloneCatalog({ nepalBoundaryResolver, engineKind = 'cesium', + layerSession, sources = {}, streetLevelProviders, signal = new AbortController().signal, @@ -18,6 +19,7 @@ export function createStandaloneCatalog({ } = {}) { return createApplicationCatalog({ engineKind, + layerSession, nepalBoundaryResolver, surface, sources: createStandaloneLayerSources(sources), diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index f82241ab2..3c8db9045 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -37,7 +37,7 @@ export class CockpitCoordinator { operations, ); this._cockpitDisplayPortal = null; - this.cockpitView = viewer || engine?.kind === 'navara' + this.cockpitView = viewer || (engine?.frames?.preUpdate && engine?.camera?.followChanged) ? this._createCockpitView(viewer, services) : null; } @@ -88,7 +88,7 @@ export class CockpitCoordinator { this._stampNavigation({ cancelPendingSelection: false }), isEntryAllowed: () => cockpitEntryAllowed({ - standaloneAircraft: this.engine?.kind === 'navara', + standaloneAircraft: true, contextMode: this._contextMode, contextModeChanging: this._contextModeChanging, flightsEnabled: !!this._dataManager?.isEnabled('flights'), @@ -279,7 +279,7 @@ export class CockpitCoordinator { action: 'control_cockpit', error: this._contextModeChanging ? 'Contacts is still starting up — try Cockpit again in a moment' - : this.engine?.kind === 'navara' + : !this._contextMode ? 'Enable an aircraft layer and track a flight to enter Cockpit' : 'Contacts must be active to enter Cockpit — say "open contacts" first', state: this.getCockpitState(), diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index eb3a4b25e..7c64b0b20 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -254,7 +254,7 @@ export class NavigationController { }); } - _reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) { + _reassertNavigationHandoff(generation, { trackingOrigin = 'tool', keepTracking = false } = {}) { return reassertNavigationHandoff({ generation, currentGeneration: this._navigationGeneration, @@ -266,7 +266,7 @@ export class NavigationController { // was refused by the cockpit leaves the old readout standing. release: () => { this.clearLocation(); - return this._releaseFollowCamera({ trackingOrigin }); + if (!keepTracking) return this._releaseFollowCamera({ trackingOrigin }); }, }); } diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index b191a4e4c..bada15585 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -174,12 +174,17 @@ export class ShareRestoration { this._initialShareRestoreTimeout = null; if (this._disposed) return; const generation = this._initialShareNavigationGeneration; + const retained = engineSwitch && this._layerStateCoordinator?.retainedTrackingTarget(); const applyCamera = Number.isInteger(generation) && - this.navigation._reassertNavigationHandoff(generation); + this.navigation._reassertNavigationHandoff(generation, { + trackingOrigin: engineSwitch ? 'share-restore' : 'tool', + keepTracking: Boolean(retained), + }); void this._runShareRestore(savedState, { applyCamera, instant: engineSwitch, + retained, navigationToken: generation, layersPromise: this._layerStateRestorePromise, settle: (result) => this._settleInitialShareRestore(result), @@ -238,7 +243,7 @@ export class ShareRestoration { */ async _runShareRestore( state, - { applyCamera, instant = false, navigationToken, layersPromise, settle }, + { applyCamera, instant = false, retained, navigationToken, layersPromise, settle }, ) { const generation = this._shareRestoreGeneration; const current = () => @@ -253,7 +258,8 @@ export class ShareRestoration { if (!current()) return; const tracking = share.camera === 'applied' - ? await this._layerStateCoordinator?.restoreShareTrackingSelection?.() + ? (retained && this._layerStateCoordinator.resumeRetainedTracking(retained)) || + await this._layerStateCoordinator?.restoreShareTrackingSelection?.() : { status: 'superseded', cleared: