diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 376f163ea..5c9ff552c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1921,6 +1921,7 @@ "src/layers/awareness/policy.js", "src/layers/awareness/presentation.js", "src/layers/awareness/queries.js", + "src/layers/awareness/render/cesium.js", "src/layers/awareness/rendering.js", "src/layers/awareness/state.js", "src/layers/awareness/subject.js", @@ -2806,6 +2807,7 @@ "src/layers/awareness/policy.js", "src/layers/awareness/presentation.js", "src/layers/awareness/queries.js", + "src/layers/awareness/render/cesium.js", "src/layers/awareness/rendering.js", "src/layers/awareness/state.js", "src/layers/awareness/subject.js", @@ -3668,6 +3670,7 @@ "src/layers/earthquakes/render/navaraGeometry.js", "src/layers/earthquakes/render/navaraGrid.js", "src/layers/installations/render/navara.js", + "src/layers/awareness/render/navara.js", "src/layers/bikeshare/render/navara.js", "src/layers/installations/render/navaraGeometry.js", "src/layers/installations/render/navaraOutlineBatch.js", @@ -3866,6 +3869,7 @@ "src/layers/awareness/policy.js", "src/layers/awareness/presentation.js", "src/layers/awareness/queries.js", + "src/layers/awareness/render/cesium.js", "src/layers/awareness/rendering.js", "src/layers/awareness/state.js", "src/layers/awareness/subject.js", diff --git a/scripts/qa-contact-switch.mjs b/scripts/qa-contact-switch.mjs new file mode 100644 index 000000000..2226ee420 --- /dev/null +++ b/scripts/qa-contact-switch.mjs @@ -0,0 +1,148 @@ +import puppeteer from 'puppeteer'; +import assert from 'node:assert/strict'; +import { blockPaidQaMaps } from './qa-map-policy.mjs'; + +const base = new URL(process.argv[2]); +if ( + !['localhost', '127.0.0.1'].includes(base.hostname) || + ['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( + base.port, + ) +) + throw new Error('Supply an unreserved local keyless preview URL'); +base.search = '?engine=navara&welcome=0&look=cinematic'; +base.hash = + 'v=2&lat=43.6500&lon=-79.3800&alt=46000&heading=0&pitch=-90&roll=0&map=sentinel2&l=4'; +const b = await puppeteer.launch({ + headless: true, + args: ['--use-angle=metal', '--enable-gpu'], +}); +try { + const p = await b.newPage(); + await blockPaidQaMaps(p); + await p.setViewport({ width: 1024, height: 768 }); + const errors = []; + p.on('pageerror', (e) => { + errors.push(e.message); + console.log('error', e.message.split('?')[0]); + }); + await p.evaluateOnNewDocument(() => { + const fetch = window.fetch.bind(window); + window.fetch = (input, options) => { + const { pathname } = new URL(input.url ?? input, document.baseURI); + if (pathname === '/api/vessels') + return Promise.resolve( + Response.json({ + status: 'live', + rows: ['123456789', '223456789'].map((mmsi, i) => ({ + mmsi, + name: 'Toronto ' + i, + lat: 43.65, + lon: -79.38 + i * 0.05, + last_position_epoch: Date.now() / 1000, + })), + }), + ); + if (pathname === '/api/vessels/track') + return Promise.resolve(Response.json({ samples: [] })); + if (pathname === '/api/flights') + return Promise.resolve( + Response.json({ time: Date.now() / 1000, states: [] }), + ); + if (pathname === '/api/military') + return Promise.resolve(Response.json({ now: Date.now(), ac: [] })); + if (pathname === '/api/military-installations') + return Promise.resolve(Response.json({ elements: [], status: 'ok' })); + return fetch(input, options); + }; + }); + await p.goto(base.href); + const ready = async (kind) => { + await p.waitForFunction( + (k) => + window.__godsEyeView?.engine.kind === k && + !document.querySelector('#globe-engine')?.disabled && + !document.querySelector('.engine-switch-frame') && + document.querySelector('#loading-screen.hidden'), + { timeout: 90000 }, + kind, + ); + await p.evaluate( + () => window.__godsEyeView.styleManager.initialRestorePromise, + ); + }; + await ready('navara'); + await p.evaluate(async () => { + const { dataManager, styleManager, tileset } = window.__godsEyeView; + if (tileset || window.__GOOGLE_MAPS_API_KEY__) + throw Error('Require keyless preview'); + await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' }); + window.qaBefore = [...dataManager.getEnabledLayerIds()].sort(); + const outcome = await styleManager.setContextMode('contacts'); + if (!outcome.ok) throw Error(JSON.stringify(outcome)); + window.qaContext = dataManager.layers.get('military-awareness').module; + window.qaSites = dataManager.layers.get('military-installations').module; + const vessels = dataManager.layers.get('ais-live-vessels').module; + if (!vessels.selectById('123456789')) throw Error('Missing first vessel'); + if (!window.qaContext.navigateNext({ targetLayer: 'ais-live-vessels' })) + throw Error('Cannot navigate to the second vessel'); + }); + const sample = () => + p.evaluate(() => { + const { dataManager, styleManager, engine } = window.__godsEyeView; + return { + context: + window.qaContext === + dataManager.layers.get('military-awareness').module, + sites: + window.qaSites === + dataManager.layers.get('military-installations').module, + mode: styleManager.getContextModeState().mode, + subject: window.qaContext.getContextSnapshot()?.subject?.id, + history: + window.qaContext._getAwarenessNavigationStateForTest().historyKeys, + enabled: [...dataManager.getEnabledLayerIds()].sort(), + ring: + engine.kind === 'navara' + ? engine.lines + .list() + .filter((line) => line.id === 'gev-awareness-rim').length + : null, + }; + }); + const baseline = await sample(); + console.log('baseline', JSON.stringify(baseline)); + assert.equal(baseline.mode, 'flights'); + assert.equal(baseline.subject, '223456789'); + assert.ok(baseline.history.includes('ais-live-vessels:123456789')); + assert.ok(baseline.ring >= 1); + for (const kind of ['cesium', 'navara']) { + await p.select('#globe-engine', kind); + await ready(kind); + const current = await sample(); + console.log('handoff', kind, JSON.stringify(current)); + assert.deepEqual(current, { + ...baseline, + ring: kind === 'navara' ? baseline.ring : null, + }); + } + const restored = await p.evaluate(async () => { + const { dataManager, styleManager } = window.__godsEyeView; + const result = await styleManager.setContextMode('off'); + return ( + result.ok && + JSON.stringify([...dataManager.getEnabledLayerIds()].sort()) === + JSON.stringify(window.qaBefore) + ); + }); + assert.ok( + restored, + 'Restore layers enabled by the user before entering Contacts', + ); + assert.deepEqual(errors, []); + console.log( + 'PASS Contacts history, dependency ownership and exit restore across both globes', + ); +} finally { + await b.close(); +} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 22364b155..26de4c459 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -140,10 +140,12 @@ export function createApplicationCatalog({ ), ); const installations = once(() => - createApplicationInstallations({ - surface, - source: sources.installations, - }), + sessionModel('military-installations', () => + createApplicationInstallations({ + surface, + source: sources.installations, + }), + ), ); const satellites = once(() => createApplicationSatellites({ @@ -261,13 +263,14 @@ export function createApplicationCatalog({ ['military-installations', installations], [ 'military-awareness', - () => + () => sessionModel('military-awareness', () => createApplicationAwareness({ flights: flights(), military: military(), vessels: vessels(), installations: installations(), }), + ), ], [ 'wind', diff --git a/src/app/controls.js b/src/app/controls.js index c81246e51..647a0727b 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -21,6 +21,7 @@ export function createApplicationControls({ initialShareCode = null, localePicker = true, engineSwitch = false, + layerSession, }) { // Initialize the style manager (post-processing, HUD, locations, share links) const styleManager = new Controls(viewer, { @@ -42,6 +43,7 @@ export function createApplicationControls({ // A share code to open at start-up instead of the page's address. initialShareCode, engineSwitch, + layerSession, localePicker, }); defer(() => styleManager.orbitController.stop()); diff --git a/src/app/data.js b/src/app/data.js index c293cd487..b780282c0 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -45,7 +45,7 @@ export function createApplicationData({ onData?.(dataManager); if (!catalog?.layers || !catalog?.metadata) throw new TypeError('An application layer catalog is required'); - dataManager.bindCatalog(viewer, engine, catalog); + const attached = dataManager.bindCatalog(viewer, engine, catalog); for (const layer of catalog.layers) layer.attachDataManager?.(dataManager); for (const layer of catalog.layers) layer.attachMapStackController?.(mapStackController); @@ -75,5 +75,6 @@ export function createApplicationData({ // The sonar scene draws on the viewer; the Navara engine has none. if (viewer) defer(createCyberSonarScene(viewer, dataManager)); - return { dataManager, catalog, presentation }; + const data = { dataManager, catalog, presentation }; + return attached ? attached.then(() => data) : data; } diff --git a/src/app/layers/militaryAwareness.js b/src/app/layers/militaryAwareness.js index ff52ec3af..c23d7bf1b 100644 --- a/src/app/layers/militaryAwareness.js +++ b/src/app/layers/militaryAwareness.js @@ -1,3 +1,4 @@ +import { rendererFor } from './renderers.js'; import { createAwarenessLayer } from '../../layers/awareness/index.js'; import * as navigation from '../../navigationPolicy.js'; import * as geometry from '../../celestialRing.js'; @@ -17,6 +18,7 @@ export function createApplicationAwareness({ installations, navigation, geometry, + renderer: (engine) => rendererFor('military-awareness', engine), render, }, }); diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index a1160e648..6721172f3 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -60,6 +60,7 @@ test('the Navara mode registers migrated renderers once', async () => { 'recent-imagery', 'ais-live-vessels', 'military-installations', + 'military-awareness', 'local-firms', ], ); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index b75bb1b9f..a6adfe6ba 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -106,6 +106,8 @@ export async function registerNavaraLayerRenderers() { bikeshare: async () => (await import('../layers/bikeshare/render/navara.js')) .createBikeshareNavaraRenderer, + 'military-awareness': async () => + (await import('../layers/awareness/render/navara.js')).createAwarenessNavaraRenderer, 'military-installations': async () => { const { createInstallationsNavaraRenderer } = await import('../layers/installations/render/navara.js'); diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index 0749f0019..ad31b3169 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -102,6 +102,7 @@ export class LayerStateCoordinator { start({ shareLayerState = null, allowLocalState = true, + handoffLayers = null, shareCreatedAtMs = null, } = {}) { if (this._destroyed) @@ -139,11 +140,12 @@ export class LayerStateCoordinator { ); this.shareLinkManager?.onLayerStateChange?.(); this._notifyDurableState(); - if (!selected) return this.restorePromise; + if (!selected && !handoffLayers) return this.restorePromise; this.restorePromise = this._restoreSelectedState( this._source === 'share' ? LAYER_RESTORE_ORIGINS.share : LAYER_RESTORE_ORIGINS.local, + handoffLayers, ); return this.restorePromise; } @@ -365,7 +367,7 @@ export class LayerStateCoordinator { : this.restoreGate); } - async _restoreSelectedState(origin) { + async _restoreSelectedState(origin, handoffLayers = null) { // One controller set per run: a superseded run must not clear the newer // run's controllers when it settles. const controllers = new Map(); @@ -379,7 +381,18 @@ export class LayerStateCoordinator { const settled = await Promise.allSettled( LAYER_STATE_REGISTRY.map(async (entry) => { const controller = controllers.get(entry.id); - const targetEnabled = enabled.has(entry.id); + const live = handoffLayers?.get(entry.id); + const targetEnabled = live?.enabled ?? enabled.has(entry.id); + // A same-document engine choice retains operational layer intents. + // The share still owns durable preferences and later history visits. + if (live && this.dataManager.layers.get(entry.id) === live.entry && + !live.entry.lifecycleUncertain && live.entry.enabled === targetEnabled) { + return { + layerId: entry.id, targetEnabled, origin, phase: 'retained', + ...currentLayerOutcome(this.dataManager, entry.id), + appliedOptions: {}, persistenceWrite: false, succeeded: true, + }; + } const options = layerOptionsForRestore(this._durableState, entry.id); if (origin === LAYER_RESTORE_ORIGINS.share && options) { for (const trackingKey of Object.values( diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index cc8c504be..729971ebb 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -147,12 +147,16 @@ export class LayerLifecycle { this.viewer = viewer; this.engine = engine ?? engineFromViewer(viewer) ?? null; this._catalogSourceAvailability = catalog.getSourceAvailability; + const attachments = []; 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()); + if (entry.initialized) { + const ready = layer.attachRenderer(viewer, this._moduleContext()); + if (ready?.then) attachments.push(ready); + } } } // Retained entries may appear anywhere in the directory. Preserve both @@ -163,6 +167,7 @@ export class LayerLifecycle { this.layers.set(id, entry); } this.finalizeRegistrations(catalog.metadata); + if (attachments.length) return Promise.all(attachments); } /** Register a synthetic layer after sealing in an explicitly dev-enabled manager. */ diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 6f8cfaaa3..efe6d0b0e 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -1984,18 +1984,6 @@ test('cockpit blocks only non-aircraft Context camera flights', () => { ); }); -test('vessel entry and selection framing both use the 3 km focus radius', () => { - assert.match( - militaryAwarenessSource, - /const\s*VESSEL_FOCUS_RADIUS_M\s*=\s*3000;/, - ); - const focusRadiusUses = - militaryAwarenessSource.match( - /new\s*Cesium\.BoundingSphere\(\s*vessel\.position,\s*VESSEL_FOCUS_RADIUS_M,?\s*\)/g, - ) || []; - assert.equal(focusRadiusUses.length, 2); -}); - test('awareness rescans only for invalidation, source change, or meaningful movement', () => { assert.equal( awarenessRefreshRequired({ @@ -3081,6 +3069,7 @@ test('every Context camera flight without a tracked entity takes navigation auth assert.deepEqual(taken, ['context-vessel-focus']); // The announcement precedes the flight, never trails it. assert.equal(runtime.cameraFlights.length, 1); + assert.equal(runtime.cameraFlights[0].sphere.radius, 3000); militaryAwarenessLayer.focusTarget('military-installations', 'inst-1'); assert.deepEqual(taken, [ @@ -3119,6 +3108,7 @@ test('Contacts vessel autofocus takes navigation authority before it frames', as // Activation enables dependencies before it auto-focuses. await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(runtime.cameraFlights.length, 1, 'autofocus framed a vessel'); + assert.equal(runtime.cameraFlights[0].sphere.radius, 3000); assert.deepEqual(taken, [ { reason: 'context-vessel-autofocus', diff --git a/src/layers/awareness/directionOverlay.test.mjs b/src/layers/awareness/directionOverlay.test.mjs index 283c5e286..364c27b16 100644 --- a/src/layers/awareness/directionOverlay.test.mjs +++ b/src/layers/awareness/directionOverlay.test.mjs @@ -18,9 +18,9 @@ function overlay(screenAngle) { const container = document.body.appendChild(document.createElement('div')); const subject = Cesium.Cartesian3.fromDegrees(0, 0, 1000); Object.assign(state, { - viewer: { + display: { container, - scene: { canvas: { clientWidth: 800, clientHeight: 600 } }, + canvas: { clientWidth: 800, clientHeight: 600 }, camera: { heading: 0, rightWC: new Cesium.Cartesian3(0, 1, 0), @@ -115,7 +115,7 @@ test('an unstable frame keeps the last stable bearing, until the overlay is remo rendering.updateDirectionOverlay(); assert.equal(state.directionAngles[0], 1); // The heading turns, so the next frame recomputes the contact. - state.viewer.camera.heading = 0.1; + state.display.camera.heading = 0.1; rendering.updateDirectionOverlay(); assert.equal(previous[1], 1, 'the unstable frame is given the last angle'); assert.equal(state.directionAngles[0], 1, 'and does not replace it'); diff --git a/src/layers/awareness/focus.js b/src/layers/awareness/focus.js index 50e62b788..0dc189f6f 100644 --- a/src/layers/awareness/focus.js +++ b/src/layers/awareness/focus.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; import { VESSEL_FOCUS_RADIUS_M } from './policy.js'; @@ -42,14 +41,11 @@ export function createFocus({ state: layerState, services, parts, source }) { if ( !parts.model.contextTargetFlyToAllowed(layerId) || !vessel?.position || - !layerState.viewer + !layerState.display ) return true; announceNavigationAuthority('context-vessel-focus'); - layerState.viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M), - { duration: 1.4 }, - ); + layerState.display.frameContact(vessel.position, VESSEL_FOCUS_RADIUS_M, 1.4); return true; } @@ -113,7 +109,7 @@ export function createFocus({ state: layerState, services, parts, source }) { function closestToCurrentView(candidates) { if (!Array.isArray(candidates) || !candidates.length) return null; - const cameraPosition = layerState.viewer?.camera?.positionWC; + const cameraPosition = layerState.display?.camera?.positionWC; if (!cameraPosition) return candidates.find((candidate) => candidate?.position) || null; @@ -146,7 +142,7 @@ export function createFocus({ state: layerState, services, parts, source }) { if ( !layerState.enabled || layerState.subject || - !layerState.viewer || + !layerState.display || layerState.autoFocusAttempted ) return false; @@ -181,10 +177,7 @@ export function createFocus({ state: layerState, services, parts, source }) { announceNavigationAuthority('context-vessel-autofocus', { cancelPendingSelection: false, }); - layerState.viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M), - { duration: 1.6 }, - ); + layerState.display.frameContact(vessel.position, VESSEL_FOCUS_RADIUS_M, 1.6); return true; } return { diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js index 925a3a9ca..6cf31e89a 100644 --- a/src/layers/awareness/lifecycle.js +++ b/src/layers/awareness/lifecycle.js @@ -1,3 +1,4 @@ +import { createAwarenessCesiumRenderer } from './render/cesium.js'; import { cameraPoseSignature } from '../../data/iconOrientation.js'; export function createLifecycle({ @@ -36,7 +37,7 @@ export function createLifecycle({ } function attachRuntimeListeners() { - if (layerState.runtimeListenersAttached || !layerState.viewer) return; + if (layerState.runtimeListenersAttached || !layerState.display) return; window.addEventListener( 'gev:awareness-subject-selected', layerState.subjectListener, @@ -51,14 +52,14 @@ export function createLifecycle({ layerState.subjectClearListener, ); layerState.preRenderRemover = - layerState.viewer.scene.preRender.addEventListener(() => { + layerState.display.onFrame(() => { if (!layerState.enabled) return; const now = Date.now(); // "Did the camera move" uses the SAME quantized pose signature the fleet // rotation pass gates on (iconOrientation.cameraPoseSignature) rather than // camera.changed, whose granularity is globally degraded by other layers // mutating camera.percentageChanged. - const poseSig = cameraMotionSignature(layerState.viewer.camera); + const poseSig = cameraMotionSignature(layerState.display.camera); if (poseSig !== layerState.lastCameraPoseSig) { layerState.lastCameraPoseSig = poseSig; layerState.lastCameraPoseChangeMs = now; @@ -121,9 +122,7 @@ export function createLifecycle({ layerState.runtimeListenersAttached = false; } const methods = { - init(viewer) { - detachRuntimeListeners(); - layerState.viewer = viewer; + init(viewer, { engine = null } = {}) { layerState.subjectListener = (event) => { if (!layerState.enabled) return; parts.subject.selectSubject(event.detail); @@ -177,6 +176,28 @@ export function createLifecycle({ parts.subject.clearAwarenessSubject(); } }; + if (viewer || engine) this.attachRenderer(viewer, { engine }); + }, + + attachRenderer(viewer, { engine = null } = {}) { + detachRuntimeListeners(); + layerState.display?.destroy?.(); + layerState.display = viewer + ? createAwarenessCesiumRenderer(viewer) + : services.renderer?.(engine) ?? null; + if (layerState.enabled) { + attachRuntimeListeners(); + if (layerState.subject) parts.rendering.renderVisual(layerState.subject); + parts.rendering.scheduleDirectionOverlayUpdate(true); + } + }, + + detachRenderer() { + detachRuntimeListeners(); + parts.rendering.clearVisual(); + parts.rendering.removeDirectionOverlay(); + layerState.display?.destroy?.(); + layerState.display = null; }, enable() { @@ -235,7 +256,8 @@ export function createLifecycle({ layerState.contextListener = null; layerState.clearListener = null; layerState.subjectClearListener = null; - layerState.viewer = null; + layerState.display?.destroy?.(); + layerState.display = null; layerState.dataManager = null; }, }; diff --git a/src/layers/awareness/render/cesium.js b/src/layers/awareness/render/cesium.js new file mode 100644 index 000000000..7dbb33a3e --- /dev/null +++ b/src/layers/awareness/render/cesium.js @@ -0,0 +1,40 @@ +import { BoundingSphere, Color } from 'cesium'; +import { AWARENESS_RADIUS_M } from '../../../data/militaryAwarenessEngine.js'; +import { CONTEXT_RIM_HEIGHT_M } from '../policy.js'; + +export function createAwarenessCesiumRenderer(viewer) { + return { + container: viewer.container, + get canvas() { + return viewer.scene?.canvas; + }, + get camera() { + return viewer.camera; + }, + get trackedEntity() { + return viewer.trackedEntity; + }, + onFrame: (run) => viewer.scene.preRender.addEventListener(run), + frameContact: (center, radius, duration) => + viewer.camera.flyToBoundingSphere(new BoundingSphere(center, radius), { + duration, + }), + createRim(center) { + const entity = viewer.entities.add({ + position: center, + ellipse: { + semiMajorAxis: AWARENESS_RADIUS_M, + semiMinorAxis: AWARENESS_RADIUS_M, + fill: false, + outline: true, + outlineColor: Color.fromCssColorString('#62b5ff').withAlpha(0.72), + height: CONTEXT_RIM_HEIGHT_M, + }, + }); + return { + move: (next) => entity.position.setValue(next), + destroy: () => viewer.entities.remove(entity), + }; + }, + }; +} diff --git a/src/layers/awareness/render/navara.js b/src/layers/awareness/render/navara.js new file mode 100644 index 000000000..253fc5576 --- /dev/null +++ b/src/layers/awareness/render/navara.js @@ -0,0 +1,63 @@ +import * as geo from '../../../geo/index.js'; +import { createEngineLayerView } from '../../shared/render/engineView.js'; +import { AWARENESS_RADIUS_M } from '../../../data/militaryAwarenessEngine.js'; +import { CONTEXT_RIM_HEIGHT_M } from '../policy.js'; + +// Sample in the subject's east/north plane, then lift each vertex onto +// the ellipsoid. This also works when a ring crosses the date line or a pole. +function rimPositions(center) { + const frame = geo.transforms.eastNorthUpToFixedFrame(center); + const positions = []; + for (let i = 0; i < 180; i++) { + const angle = (i * Math.PI) / 90; + const local = geo.cartesian3.create( + Math.cos(angle) * AWARENESS_RADIUS_M, + Math.sin(angle) * AWARENESS_RADIUS_M, + 0, + ); + const world = geo.matrix4.multiplyByPoint( + frame, + local, + geo.cartesian3.create(), + ); + const point = geo.cartographic.fromCartesian(world); + positions.push( + geo.cartesian3.fromRadians( + point.longitude, + point.latitude, + CONTEXT_RIM_HEIGHT_M, + ), + ); + } + positions.push(positions[0]); + return positions; +} + +export function createAwarenessNavaraRenderer(engine) { + const binding = createEngineLayerView(engine, { + layerId: 'military-awareness', + }); + return { + container: engine.host.container, + canvas: engine.host.canvas, + camera: binding.view.camera, + get trackedEntity() { + return engine.camera.following()?.native; + }, + onFrame: (run) => binding.view.on('preRender', run), + frameContact: (center, radius, duration) => + engine.camera.flyToSphere({ center, radius }, { duration }), + createRim(center) { + const line = engine.lines.createLine({ + id: 'gev-awareness-rim', + color: [98 / 255, 181 / 255, 1, 0.72], + widthPx: 1, + arc: 'geodesic', + }); + const move = (position) => line.setPositions(rimPositions(position)); + move(center); + return { move, destroy: () => line.destroy() }; + }, + destroy: binding.destroy, + }; +} diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index c6e0eb97f..a9db73678 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; import { t } from '../../i18n/index.js'; import { @@ -8,10 +7,9 @@ import { import { formatAwarenessLabel, formatAwarenessDistance, - AWARENESS_RADIUS_M, } from '../../data/militaryAwarenessEngine.js'; import { bearingBetweenCoordinates } from '../../cockpitMath.js'; -import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js'; +import { AWARENESS_PAGE_SIZE } from './policy.js'; import { formatAwarenessDirection } from './presentation.js'; export function createRendering({ @@ -36,10 +34,10 @@ export function createRendering({ * viewer's container. Each part keeps its last placement while hidden. */ function ensureDirectionOverlay() { - if (layerState.directionView || !layerState.viewer) + if (layerState.directionView || !layerState.display) return layerState.directionView; presentDirection({ - container: layerState.viewer.container, + container: layerState.display.container, hidden: true, ring: null, heading: { text: '', left: null, top: null }, @@ -129,7 +127,7 @@ export function createRendering({ return; } - const canvas = layerState.viewer.scene.canvas; + const canvas = layerState.display.canvas; const width = canvas.clientWidth || canvas.width; const height = canvas.clientHeight || canvas.height; const sonar = isCyberSonarActive() @@ -147,7 +145,7 @@ export function createRendering({ 120, Math.min(geometry.radius - 48, geometry.radius * 0.66), ); - const cameraHeading = layerState.viewer.camera.heading || 0; + const cameraHeading = layerState.display.camera.heading || 0; const headingDeg = (Math.round(geo.math.toDegrees(cameraHeading)) + 360) % 360; const ring = { @@ -205,8 +203,8 @@ export function createRendering({ } geo.cartesian3.normalize(direction, direction); const projection = celestialScreenAngle( - geo.cartesian3.dot(direction, layerState.viewer.camera.rightWC), - geo.cartesian3.dot(direction, layerState.viewer.camera.upWC), + geo.cartesian3.dot(direction, layerState.display.camera.rightWC), + geo.cartesian3.dot(direction, layerState.display.camera.upWC), layerState.directionAngles[index], ); if (projection.stable) @@ -234,12 +232,12 @@ export function createRendering({ transform: `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`, }; const label = formatAwarenessLabel(item); - const subjectCartographic = Cesium.Cartographic.fromCartesian( + const subjectCartographic = geo.cartographic.fromCartesian( layerState.subject.position, undefined, layerState.SUBJECT_CARTOGRAPHIC_SCRATCH, ); - const targetCartographic = Cesium.Cartographic.fromCartesian( + const targetCartographic = geo.cartographic.fromCartesian( item.position, undefined, layerState.TARGET_CARTOGRAPHIC_SCRATCH[index], @@ -273,9 +271,8 @@ export function createRendering({ } function clearVisual() { - if (layerState.visual?.entities && layerState.viewer) { - for (const entity of layerState.visual.entities) - layerState.viewer.entities.remove(entity); + if (layerState.visual) { + layerState.visual.rim.destroy(); // Idle mode renders only on request; a removed ring must not linger. governorRequestRender('awareness-visual'); } @@ -283,8 +280,8 @@ export function createRendering({ } function renderVisual(subject) { - if (!layerState.viewer || !subject?.position) return; - const cartographic = Cesium.Cartographic.fromCartesian(subject.position); + if (!layerState.display || !subject?.position) return; + const cartographic = geo.cartographic.fromCartesian(subject.position); if (!cartographic) return; const groundCenter = geo.cartesian3.fromRadians( cartographic.longitude, @@ -299,8 +296,7 @@ export function createRendering({ if ( geo.cartesian3.distance(layerState.visual.center, groundCenter) >= 250 ) { - for (const entity of layerState.visual.entities) - entity.position.setValue(groundCenter); + layerState.visual.rim.move(groundCenter); geo.cartesian3.clone(groundCenter, layerState.visual.center); // Content actually changed — buy exactly one frame instead of holding. governorRequestRender('awareness-visual'); @@ -309,26 +305,9 @@ export function createRendering({ } clearVisual(); - const entities = []; - - entities.push( - layerState.viewer.entities.add({ - position: groundCenter, - ellipse: { - semiMajorAxis: AWARENESS_RADIUS_M, - semiMinorAxis: AWARENESS_RADIUS_M, - fill: false, - outline: true, - outlineColor: - Cesium.Color.fromCssColorString('#62b5ff').withAlpha(0.72), - height: CONTEXT_RIM_HEIGHT_M, - }, - }), - ); - layerState.visual = { key, - entities, + rim: layerState.display.createRim(groundCenter), center: geo.cartesian3.clone(groundCenter), }; governorRequestRender('awareness-visual'); diff --git a/src/layers/awareness/state.js b/src/layers/awareness/state.js index 600a94993..694190f94 100644 --- a/src/layers/awareness/state.js +++ b/src/layers/awareness/state.js @@ -14,7 +14,7 @@ export function createState({ services }) { ); Object.assign(state, { - viewer: null, + display: null, dataManager: null, enabled: false, // Set once a refresh observes the subject gone from a still-reporting source. diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index cd7a6de9d..0d266e476 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -193,7 +193,7 @@ export function createSubject({ state: layerState, services, parts, source }) { function currentTrackedFlightSubject() { const trackedKey = normalizeContextId( - layerState.viewer?.trackedEntity?.gevTrackedId, + layerState.display?.trackedEntity?.gevTrackedId, ); if (!trackedKey) return null; const subjects = [ @@ -262,14 +262,14 @@ export function createSubject({ state: layerState, services, parts, source }) { if (!subject?.position) return null; if (subject.layerId === 'flights' || subject.layerId === 'military') { const trackedPosition = - layerState.viewer?.trackedEntity?.gevDisplayPosition?.(); + layerState.display?.trackedEntity?.gevDisplayPosition?.(); if (trackedPosition) { return { position: Cesium.Cartesian3.clone(trackedPosition), // Only the follow camera's own contact proves presence this way; a // different tracked entity says nothing about this subject. presence: - String(layerState.viewer?.trackedEntity?.gevTrackedId || '') === + String(layerState.display?.trackedEntity?.gevTrackedId || '') === subjectKey(subject) ? SUBJECT_PRESENCE.LIVE : SUBJECT_PRESENCE.UNCHECKED, diff --git a/src/layers/installations/controls.js b/src/layers/installations/controls.js index d917b35e4..76590957a 100644 --- a/src/layers/installations/controls.js +++ b/src/layers/installations/controls.js @@ -49,7 +49,7 @@ export function createControls({ state: layerState, services, parts, source }) { statsRefreshInterval: 1000, visitNamedMarkers(visitor) { - parts.namedMarkers.visit(visitor); + parts.namedMarkers?.visit(visitor); }, /** Request a one-shot Google Maps Places search around the current map view. */ @@ -153,7 +153,7 @@ export function createControls({ state: layerState, services, parts, source }) { position: geo.cartesian3.fromDegrees( record.longitude, record.latitude, - parts.rendering.installationSurfaceHeightM(record), + parts.model.installationSurfaceHeightM(record), ), distanceM, }); @@ -184,7 +184,7 @@ export function createControls({ state: layerState, services, parts, source }) { center: geo.cartesian3.fromDegrees( record.longitude, record.latitude, - parts.rendering.installationSurfaceHeightM(record), + parts.model.installationSurfaceHeightM(record), ), radius: 18000, }; diff --git a/src/layers/installations/controls.test.mjs b/src/layers/installations/controls.test.mjs index 4bfa6de34..443c1b87b 100644 --- a/src/layers/installations/controls.test.mjs +++ b/src/layers/installations/controls.test.mjs @@ -21,7 +21,7 @@ test('focus frames a selected installation through the globe engine camera', () services: {}, parts: { selection: { selectRecord: (id) => id === record.id }, - rendering: { installationSurfaceHeightM: () => 126 }, + model: { installationSurfaceHeightM: () => 126 }, }, source: {}, }).methods; diff --git a/src/layers/installations/index.js b/src/layers/installations/index.js index 65b9e922b..5f7c8ba24 100644 --- a/src/layers/installations/index.js +++ b/src/layers/installations/index.js @@ -28,6 +28,9 @@ export function createInstallationsLayer({ let renderer = null; const lifecycle = { init(viewer, options = {}) { + if (viewer || options.engine) return lifecycle.attachRenderer(viewer, options); + }, + attachRenderer(viewer, options = {}) { if (renderer) throw new Error('Installations layer is already initialized'); const engine = options.engine ?? null; @@ -35,27 +38,47 @@ export function createInstallationsLayer({ if (!renderer?.lifecycle?.methods) throw new Error('Installation renderer is unavailable'); Object.assign(parts, renderer); - return renderer.lifecycle.methods.init(viewer, options); + const ready = renderer.lifecycle.methods.init(viewer, options); + if (state.enabled) { + renderer.lifecycle.methods.enable(); + renderer.rendering.renderRecords({ claimSelection: Boolean(state.selectedId) }); + } + return ready; }, enable(...args) { + state.enabled = true; return renderer?.lifecycle?.methods.enable(...args); }, disable(...args) { - return renderer?.lifecycle?.methods.disable(...args); + if (renderer) return renderer.lifecycle.methods.disable(...args); + state.enabled = false; + state.abort?.abort(); + state.abort = null; + state.loading = false; + state.selectedId = null; + state.contextAnchor = null; + state.contextPosition = null; + state.cameraLoadKey = null; + state.cameraLoadOwner = null; + state.coverage = { kind: 'viewport' }; + clearTimeout(state.timer); + parts.viewport.clearUnavailableRetry(); + services.context.clearSelectedEntityContextForLayer('military-installations'); + parts.ingestion.setInstallationStatus('idle'); + }, + detachRenderer(viewer) { + renderer?.lifecycle.methods.detach(viewer); + renderer = null; + for (const key of ['rendering', 'namedMarkers', 'selection', 'lifecycle']) + delete parts[key]; }, destroy(...args) { - try { - return renderer?.lifecycle?.methods.destroy(...args); - } finally { - renderer = null; - for (const key of [ - 'rendering', - 'namedMarkers', - 'selection', - 'lifecycle', - ]) - delete parts[key]; + if (renderer) renderer.lifecycle.methods.destroy(...args); + else { + lifecycle.disable(); + source.destroy?.(); } + renderer = null; }, }; return Object.assign( diff --git a/src/layers/installations/ingestion.js b/src/layers/installations/ingestion.js index facc3bcd8..52858579b 100644 --- a/src/layers/installations/ingestion.js +++ b/src/layers/installations/ingestion.js @@ -73,8 +73,7 @@ export function createIngestion({ } async function loadInstallations() { - if (!layerState.enabled || (!layerState.viewer && !layerState.engine)) - return; + if (!layerState.enabled) return; const { box, coverage } = parts.viewport.loadArea(layerState.viewer); // Guidance, not a fault: the layer chose not to query because the view is // unbounded (a global view, or Cockpit looking at the horizon). Keep it out @@ -261,8 +260,8 @@ export function createIngestion({ ? msg('Too many mapped sites in view to list them all') : placesError, ); - parts.rendering.renderRecords(); - parts.rendering.warmInstallationFloors( + parts.rendering?.renderRecords(); + parts.rendering?.warmInstallationFloors( layerState.records.filter((r) => !r.pointOnly), ); }; diff --git a/src/layers/installations/model.js b/src/layers/installations/model.js index 59f81bc23..e26690d06 100644 --- a/src/layers/installations/model.js +++ b/src/layers/installations/model.js @@ -44,11 +44,11 @@ export function createModel({ state: layerState, services, parts, source }) { } function installationSurfaceHeightM(record) { - const floor = services.ground?.cachedGroundFloor?.( + const floor = services?.ground?.cachedGroundFloor?.( record?.latitude, record?.longitude, ); - return services.ground?.floorAltitudeM?.(null, floor) ?? 0; + return services?.ground?.floorAltitudeM?.(null, floor) ?? 0; } /** diff --git a/src/layers/installations/namedMarkers.test.mjs b/src/layers/installations/namedMarkers.test.mjs index 8b56335aa..3eaa1e6e1 100644 --- a/src/layers/installations/namedMarkers.test.mjs +++ b/src/layers/installations/namedMarkers.test.mjs @@ -55,10 +55,11 @@ test('Contacts camera motion reprojects and declutters existing labels without s h.setX(600); h.lifecycle.enable(); assert.equal(h.labels.length, 2); assert.equal(h.publications(), 5); + const oldScene = h.state.viewer.scene; h.lifecycle.destroy(h.state.viewer); assert.equal(h.camera.changed.numberOfListeners, 0); assert.equal(h.camera.moveEnd.numberOfListeners, 0); - assert.equal(h.state.viewer.scene.preRender.numberOfListeners, 0); + assert.equal(oldScene.preRender.numberOfListeners, 0); }); test('wide named markers retain primitives until close polygons publish with the same identity', async (t) => { diff --git a/src/layers/installations/render/cesium/lifecycle.js b/src/layers/installations/render/cesium/lifecycle.js index fb7e85e3f..9f9672ced 100644 --- a/src/layers/installations/render/cesium/lifecycle.js +++ b/src/layers/installations/render/cesium/lifecycle.js @@ -92,11 +92,19 @@ export function createLifecycle({ destroy(viewer) { source.destroy?.(); this.disable(); - layerState.contextAnchor = null; - layerState.contextPosition = null; - layerState.coverage = { kind: 'viewport' }; + this.detach(viewer); + }, + + detach(viewer) { + services.credits?.hideOsmCredit?.(viewer, LAYER_ID); + services.credits?.hideGooglePlacesCredit?.(viewer, LAYER_ID); + unregisterPickOwner(LAYER_ID); + releaseCameraSensitivity(viewer?.camera, LAYER_ID); + layerState.surfaceChangedRemove?.(); + layerState.surfaceChangedRemove = null; layerState.moveEndRemove?.(); layerState.changedRemove?.(); + layerState.moveEndRemove = layerState.changedRemove = null; parts.selection.destroy?.(); layerState.clickHandler?.destroy(); layerState.clickHandler = null; @@ -105,6 +113,7 @@ export function createLifecycle({ if (layerState.dataSource && viewer) viewer.dataSources.remove(layerState.dataSource, true); layerState.dataSource = null; + layerState.viewer = null; layerState.engine = null; }, }; diff --git a/src/layers/installations/render/cesium/rendering.js b/src/layers/installations/render/cesium/rendering.js index 7511bd6f8..7f391a563 100644 --- a/src/layers/installations/render/cesium/rendering.js +++ b/src/layers/installations/render/cesium/rendering.js @@ -353,8 +353,9 @@ export function createRendering({ ) .map((record) => ({ lat: record.latitude, lon: record.longitude })); if (!cold.length) return; + const owner = layerState.dataSource; warmFireAnchorFloors(cold).then(() => { - if (!layerState.enabled || !layerState.dataSource) return; + if (!layerState.enabled || !owner || layerState.dataSource !== owner) return; if ( !cold.some((point) => cachedGroundFloor(point.lat, point.lon) != null) ) diff --git a/src/layers/installations/render/navara.js b/src/layers/installations/render/navara.js index 92d922bc9..a6888c357 100644 --- a/src/layers/installations/render/navara.js +++ b/src/layers/installations/render/navara.js @@ -608,7 +608,7 @@ export function createInstallationsNavaraRenderer( services.picking.registerPickOwner(LAYER_ID, (id) => layerState.recordById.has(id), ); - renderRecords(); + renderRecords({ claimSelection: Boolean(layerState.selectedId) }); }, disable() { visible = false; @@ -640,6 +640,12 @@ export function createInstallationsNavaraRenderer( destroy() { source.destroy?.(); this.disable(); + this.detach(); + }, + detach() { + services.credits?.hideOsmCredit?.(engine, LAYER_ID); + syncPlacesCredit([]); + services.picking.unregisterPickOwner(LAYER_ID); layerState.moveEndRemove?.(); layerState.changedRemove?.(); layerState.moveEndRemove = layerState.changedRemove = null; diff --git a/src/layers/installations/viewport.js b/src/layers/installations/viewport.js index 709627d1d..15277c3f2 100644 --- a/src/layers/installations/viewport.js +++ b/src/layers/installations/viewport.js @@ -19,7 +19,9 @@ export function createViewport({ state: layerState, services, parts, source }) { return { box: bounds, coverage: { kind: 'subject', radiusM } }; } } - return { box: viewportBox(viewer), coverage: { kind: 'viewport' } }; + if (viewer || layerState.engine) + layerState.lastViewBox = viewportBox(viewer); + return { box: layerState.lastViewBox ?? null, coverage: { kind: 'viewport' } }; } function viewportBox(viewer) { diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index 96d5ef1b0..4300c589b 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -172,11 +172,11 @@ export function createLifecycle({ components.rendering.installRuntime(scene); restoreSpriteOrder(viewer); components.rendering.setVisible(state.feed.enabled); - if (!state.feed.enabled) return; - bindVisibleScene(viewer, scene); 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); components.tracking.attachSelectedVesselTrail(); components.rendering.updateVisibility(true); if (state.selectedRecord) diff --git a/src/main.js b/src/main.js index 8f0e01e03..6483de1ac 100644 --- a/src/main.js +++ b/src/main.js @@ -124,12 +124,19 @@ async function reconcile(remember = false) { const previous = kind; let cover; let cockpit; + const handoffHash = remember ? location.hash : null; try { while (pageEngineKind() !== kind) { cover ??= keepGlobeFrame(application.getComponents().scene.engine); cockpit = application .getComponents() .controls.styleManager.getCockpitState(); + const manager = application.getComponents().data.dataManager; + layerSession.handoffLayers = location.hash === handoffHash + ? new Map([...manager.layers].map(([id, entry]) => [ + id, { entry: entry.module.attachRenderer ? entry : null, enabled: manager.isEffectivelyEnabled(id) }, + ])) + : null; renderers.retain(kind); await application.destroy(); await start(pageEngineKind(), true, cockpit); @@ -156,6 +163,7 @@ async function reconcile(remember = false) { report(recoveryError); } } finally { + layerSession.handoffLayers = null; cover?.remove(); busy = false; present(); diff --git a/src/standalone/application.js b/src/standalone/application.js index f89d61842..a1f8b21e4 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -130,6 +130,7 @@ export function createStandaloneApplication({ createStandaloneControls({ ...context, engineSwitch, + layerSession, loaderStatus, placeSearch, catalog, diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index da867a029..0272de33e 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -105,11 +105,13 @@ export class StyleManager extends ShellFacade { requestServices, initialShareCode = null, engineSwitch = false, + layerSession = null, localePicker = true, } = {}, ) { super(); this._localePickerEnabled = localePicker; + this._layerSession = layerSession; const { IntelHUD, ShareLinkManager, @@ -197,6 +199,7 @@ export class StyleManager extends ShellFacade { this._shareRestoration = new ShareRestoration({ viewer, canvas: engine?.host.canvas ?? viewer?.canvas, + handoffLayers: layerSession?.handoffLayers, navigation: this._navigation, syncShareState: () => this._syncShareState(), syncModels3d: (state) => this._syncModels3dFromLayerState(state), @@ -972,6 +975,8 @@ export class StyleManager extends ShellFacade { setClearBusy: (busy) => this._publishClearBusy(busy), }, }); + if (this._layerSession?.handoffLayers) + this._contextControls.resumeScene(this._layerSession.contacts); // The Contacts layer publishes its panel; the view calls its navigation. contextStore.connectAwareness(militaryAwarenessLayer); // A panel left by an earlier shell must not outlive its layer. @@ -1855,7 +1860,13 @@ export class StyleManager extends ShellFacade { // Close camera-entry seams synchronously. Context restoration may await // layer work, so leaving these listeners attached until afterward lets a // focus event release tracking or start a flight during teardown. - await this._contextControls.restoreForDisposal(); + if (this._layerSession && !this._layerSession.lifetime.signal.aborted && + this._contextControls.canRetainContacts()) { + this._layerSession.contacts = this._contextControls.captureScene(); + } else { + if (this._layerSession) this._layerSession.contacts = null; + await this._contextControls.restoreForDisposal(); + } // IR boost teardown BEFORE detaching the data manager: restore fog and // un-boost both aircraft layers so a surviving viewer or replacement // manager doesn't inherit sensor state (review P2, 2026-08-16). diff --git a/src/ui/contextControls.js b/src/ui/contextControls.js index 81da5f20e..b6c4009c0 100644 --- a/src/ui/contextControls.js +++ b/src/ui/contextControls.js @@ -85,6 +85,19 @@ export class ContextControls { this[field] = null; } } + canRetainContacts() { + return this._contextMode === 'flights' && !this._contextModeChanging && + !this._contextRestoreState && !this._clearSelectedLayersPromise && + !this._contextLayerReactionPromises.size; + } + captureScene() { + return { mode: this._contextMode, snapshot: this._contextSessionSnapshot }; + } + resumeScene(saved) { + if (!saved) return; + this._contextMode = saved.mode; + this._contextSessionSnapshot = saved.snapshot; + } stop() { if (this.destroyed) return; this.destroyed = true; diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index bada15585..0bd2e5299 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -110,6 +110,7 @@ export class ShareRestoration { constructor({ viewer, canvas = viewer?.canvas, + handoffLayers = null, navigation, syncShareState, syncModels3d, @@ -121,6 +122,7 @@ export class ShareRestoration { Object.assign(this, { viewer, canvas, + handoffLayers, navigation, syncShareState, syncModels3d, @@ -368,11 +370,13 @@ export class ShareRestoration { ); this._layerStateRestorePromise = this._layerStateCoordinator.start({ shareLayerState: this._initialShareState?.layerState || null, + handoffLayers: this.handoffLayers, 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; if (this._initialShareSelectionSuperseded) { this._layerStateCoordinator.cancelPendingShareTracking( 'superseded-before-layer-coordinator-start', @@ -425,6 +429,7 @@ export class ShareRestoration { try { while (live()) { const vessels = this._dataManager?.layers?.get?.(layerId)?.module; + if (vessels?.getSelectedInfo?.()?.mmsi === id) return true; if (vessels?.hasContact?.(id) && vessels.selectById(id)) return true; if (Date.now() >= deadline) break; await new Promise((resolve) =>