Keep Contacts, active layer intents and mapped sites across globe switches

This commit is contained in:
German Khokhlov committed 2026-11-03 03:05:47 +04:00
1 parent 81bcae907f
commit 87bbe14748
35 files changed
+460 -113

No files matched your search

+4
View File
@@ -1921,6 +1921,7 @@
"src/layers/awareness/policy.js", "src/layers/awareness/policy.js",
"src/layers/awareness/presentation.js", "src/layers/awareness/presentation.js",
"src/layers/awareness/queries.js", "src/layers/awareness/queries.js",
"src/layers/awareness/render/cesium.js",
"src/layers/awareness/rendering.js", "src/layers/awareness/rendering.js",
"src/layers/awareness/state.js", "src/layers/awareness/state.js",
"src/layers/awareness/subject.js", "src/layers/awareness/subject.js",
@@ -2806,6 +2807,7 @@
"src/layers/awareness/policy.js", "src/layers/awareness/policy.js",
"src/layers/awareness/presentation.js", "src/layers/awareness/presentation.js",
"src/layers/awareness/queries.js", "src/layers/awareness/queries.js",
"src/layers/awareness/render/cesium.js",
"src/layers/awareness/rendering.js", "src/layers/awareness/rendering.js",
"src/layers/awareness/state.js", "src/layers/awareness/state.js",
"src/layers/awareness/subject.js", "src/layers/awareness/subject.js",
@@ -3668,6 +3670,7 @@
"src/layers/earthquakes/render/navaraGeometry.js", "src/layers/earthquakes/render/navaraGeometry.js",
"src/layers/earthquakes/render/navaraGrid.js", "src/layers/earthquakes/render/navaraGrid.js",
"src/layers/installations/render/navara.js", "src/layers/installations/render/navara.js",
"src/layers/awareness/render/navara.js",
"src/layers/bikeshare/render/navara.js", "src/layers/bikeshare/render/navara.js",
"src/layers/installations/render/navaraGeometry.js", "src/layers/installations/render/navaraGeometry.js",
"src/layers/installations/render/navaraOutlineBatch.js", "src/layers/installations/render/navaraOutlineBatch.js",
@@ -3866,6 +3869,7 @@
"src/layers/awareness/policy.js", "src/layers/awareness/policy.js",
"src/layers/awareness/presentation.js", "src/layers/awareness/presentation.js",
"src/layers/awareness/queries.js", "src/layers/awareness/queries.js",
"src/layers/awareness/render/cesium.js",
"src/layers/awareness/rendering.js", "src/layers/awareness/rendering.js",
"src/layers/awareness/state.js", "src/layers/awareness/state.js",
"src/layers/awareness/subject.js", "src/layers/awareness/subject.js",
+148
View File
@@ -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();
}
+4 -1
View File
@@ -140,10 +140,12 @@ export function createApplicationCatalog({
), ),
); );
const installations = once(() => const installations = once(() =>
sessionModel('military-installations', () =>
createApplicationInstallations({ createApplicationInstallations({
surface, surface,
source: sources.installations, source: sources.installations,
}), }),
),
); );
const satellites = once(() => const satellites = once(() =>
createApplicationSatellites({ createApplicationSatellites({
@@ -261,13 +263,14 @@ export function createApplicationCatalog({
['military-installations', installations], ['military-installations', installations],
[ [
'military-awareness', 'military-awareness',
() => () => sessionModel('military-awareness', () =>
createApplicationAwareness({ createApplicationAwareness({
flights: flights(), flights: flights(),
military: military(), military: military(),
vessels: vessels(), vessels: vessels(),
installations: installations(), installations: installations(),
}), }),
),
], ],
[ [
'wind', 'wind',
+2
View File
@@ -21,6 +21,7 @@ export function createApplicationControls({
initialShareCode = null, initialShareCode = null,
localePicker = true, localePicker = true,
engineSwitch = false, engineSwitch = false,
layerSession,
}) { }) {
// Initialize the style manager (post-processing, HUD, locations, share links) // Initialize the style manager (post-processing, HUD, locations, share links)
const styleManager = new Controls(viewer, { 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. // A share code to open at start-up instead of the page's address.
initialShareCode, initialShareCode,
engineSwitch, engineSwitch,
layerSession,
localePicker, localePicker,
}); });
defer(() => styleManager.orbitController.stop()); defer(() => styleManager.orbitController.stop());
+3 -2
View File
@@ -45,7 +45,7 @@ export function createApplicationData({
onData?.(dataManager); onData?.(dataManager);
if (!catalog?.layers || !catalog?.metadata) if (!catalog?.layers || !catalog?.metadata)
throw new TypeError('An application layer catalog is required'); 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.attachDataManager?.(dataManager);
for (const layer of catalog.layers) for (const layer of catalog.layers)
layer.attachMapStackController?.(mapStackController); layer.attachMapStackController?.(mapStackController);
@@ -75,5 +75,6 @@ export function createApplicationData({
// The sonar scene draws on the viewer; the Navara engine has none. // The sonar scene draws on the viewer; the Navara engine has none.
if (viewer) defer(createCyberSonarScene(viewer, dataManager)); if (viewer) defer(createCyberSonarScene(viewer, dataManager));
return { dataManager, catalog, presentation }; const data = { dataManager, catalog, presentation };
return attached ? attached.then(() => data) : data;
} }
+2
View File
@@ -1,3 +1,4 @@
import { rendererFor } from './renderers.js';
import { createAwarenessLayer } from '../../layers/awareness/index.js'; import { createAwarenessLayer } from '../../layers/awareness/index.js';
import * as navigation from '../../navigationPolicy.js'; import * as navigation from '../../navigationPolicy.js';
import * as geometry from '../../celestialRing.js'; import * as geometry from '../../celestialRing.js';
@@ -17,6 +18,7 @@ export function createApplicationAwareness({
installations, installations,
navigation, navigation,
geometry, geometry,
renderer: (engine) => rendererFor('military-awareness', engine),
render, render,
}, },
}); });
+1
View File
@@ -60,6 +60,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'recent-imagery', 'recent-imagery',
'ais-live-vessels', 'ais-live-vessels',
'military-installations', 'military-installations',
'military-awareness',
'local-firms', 'local-firms',
], ],
); );
+2
View File
@@ -106,6 +106,8 @@ export async function registerNavaraLayerRenderers() {
bikeshare: async () => bikeshare: async () =>
(await import('../layers/bikeshare/render/navara.js')) (await import('../layers/bikeshare/render/navara.js'))
.createBikeshareNavaraRenderer, .createBikeshareNavaraRenderer,
'military-awareness': async () =>
(await import('../layers/awareness/render/navara.js')).createAwarenessNavaraRenderer,
'military-installations': async () => { 'military-installations': async () => {
const { createInstallationsNavaraRenderer } = const { createInstallationsNavaraRenderer } =
await import('../layers/installations/render/navara.js'); await import('../layers/installations/render/navara.js');
+16 -3
View File
@@ -102,6 +102,7 @@ export class LayerStateCoordinator {
start({ start({
shareLayerState = null, shareLayerState = null,
allowLocalState = true, allowLocalState = true,
handoffLayers = null,
shareCreatedAtMs = null, shareCreatedAtMs = null,
} = {}) { } = {}) {
if (this._destroyed) if (this._destroyed)
@@ -139,11 +140,12 @@ export class LayerStateCoordinator {
); );
this.shareLinkManager?.onLayerStateChange?.(); this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState(); this._notifyDurableState();
if (!selected) return this.restorePromise; if (!selected && !handoffLayers) return this.restorePromise;
this.restorePromise = this._restoreSelectedState( this.restorePromise = this._restoreSelectedState(
this._source === 'share' this._source === 'share'
? LAYER_RESTORE_ORIGINS.share ? LAYER_RESTORE_ORIGINS.share
: LAYER_RESTORE_ORIGINS.local, : LAYER_RESTORE_ORIGINS.local,
handoffLayers,
); );
return this.restorePromise; return this.restorePromise;
} }
@@ -365,7 +367,7 @@ export class LayerStateCoordinator {
: this.restoreGate); : this.restoreGate);
} }
async _restoreSelectedState(origin) { async _restoreSelectedState(origin, handoffLayers = null) {
// One controller set per run: a superseded run must not clear the newer // One controller set per run: a superseded run must not clear the newer
// run's controllers when it settles. // run's controllers when it settles.
const controllers = new Map(); const controllers = new Map();
@@ -379,7 +381,18 @@ export class LayerStateCoordinator {
const settled = await Promise.allSettled( const settled = await Promise.allSettled(
LAYER_STATE_REGISTRY.map(async (entry) => { LAYER_STATE_REGISTRY.map(async (entry) => {
const controller = controllers.get(entry.id); 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); const options = layerOptionsForRestore(this._durableState, entry.id);
if (origin === LAYER_RESTORE_ORIGINS.share && options) { if (origin === LAYER_RESTORE_ORIGINS.share && options) {
for (const trackingKey of Object.values( for (const trackingKey of Object.values(
+6 -1
View File
@@ -147,12 +147,16 @@ export class LayerLifecycle {
this.viewer = viewer; this.viewer = viewer;
this.engine = engine ?? engineFromViewer(viewer) ?? null; this.engine = engine ?? engineFromViewer(viewer) ?? null;
this._catalogSourceAvailability = catalog.getSourceAvailability; this._catalogSourceAvailability = catalog.getSourceAvailability;
const attachments = [];
for (const layer of catalog.layers) { for (const layer of catalog.layers) {
const entry = this.layers.get(layer.id); const entry = this.layers.get(layer.id);
if (!entry) this.register(layer); if (!entry) this.register(layer);
else { else {
if (entry.module !== layer) throw new Error(`Layer owner changed: ${layer.id}`); 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 // Retained entries may appear anywhere in the directory. Preserve both
@@ -163,6 +167,7 @@ export class LayerLifecycle {
this.layers.set(id, entry); this.layers.set(id, entry);
} }
this.finalizeRegistrations(catalog.metadata); this.finalizeRegistrations(catalog.metadata);
if (attachments.length) return Promise.all(attachments);
} }
/** Register a synthetic layer after sealing in an explicitly dev-enabled manager. */ /** Register a synthetic layer after sealing in an explicitly dev-enabled manager. */
+2 -12
View File
@@ -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', () => { test('awareness rescans only for invalidation, source change, or meaningful movement', () => {
assert.equal( assert.equal(
awarenessRefreshRequired({ awarenessRefreshRequired({
@@ -3081,6 +3069,7 @@ test('every Context camera flight without a tracked entity takes navigation auth
assert.deepEqual(taken, ['context-vessel-focus']); assert.deepEqual(taken, ['context-vessel-focus']);
// The announcement precedes the flight, never trails it. // The announcement precedes the flight, never trails it.
assert.equal(runtime.cameraFlights.length, 1); assert.equal(runtime.cameraFlights.length, 1);
assert.equal(runtime.cameraFlights[0].sphere.radius, 3000);
militaryAwarenessLayer.focusTarget('military-installations', 'inst-1'); militaryAwarenessLayer.focusTarget('military-installations', 'inst-1');
assert.deepEqual(taken, [ 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. // Activation enables dependencies before it auto-focuses.
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(runtime.cameraFlights.length, 1, 'autofocus framed a vessel'); assert.equal(runtime.cameraFlights.length, 1, 'autofocus framed a vessel');
assert.equal(runtime.cameraFlights[0].sphere.radius, 3000);
assert.deepEqual(taken, [ assert.deepEqual(taken, [
{ {
reason: 'context-vessel-autofocus', reason: 'context-vessel-autofocus',
@@ -18,9 +18,9 @@ function overlay(screenAngle) {
const container = document.body.appendChild(document.createElement('div')); const container = document.body.appendChild(document.createElement('div'));
const subject = Cesium.Cartesian3.fromDegrees(0, 0, 1000); const subject = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
Object.assign(state, { Object.assign(state, {
viewer: { display: {
container, container,
scene: { canvas: { clientWidth: 800, clientHeight: 600 } }, canvas: { clientWidth: 800, clientHeight: 600 },
camera: { camera: {
heading: 0, heading: 0,
rightWC: new Cesium.Cartesian3(0, 1, 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(); rendering.updateDirectionOverlay();
assert.equal(state.directionAngles[0], 1); assert.equal(state.directionAngles[0], 1);
// The heading turns, so the next frame recomputes the contact. // The heading turns, so the next frame recomputes the contact.
state.viewer.camera.heading = 0.1; state.display.camera.heading = 0.1;
rendering.updateDirectionOverlay(); rendering.updateDirectionOverlay();
assert.equal(previous[1], 1, 'the unstable frame is given the last angle'); assert.equal(previous[1], 1, 'the unstable frame is given the last angle');
assert.equal(state.directionAngles[0], 1, 'and does not replace it'); assert.equal(state.directionAngles[0], 1, 'and does not replace it');
+5 -12
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { VESSEL_FOCUS_RADIUS_M } from './policy.js'; import { VESSEL_FOCUS_RADIUS_M } from './policy.js';
@@ -42,14 +41,11 @@ export function createFocus({ state: layerState, services, parts, source }) {
if ( if (
!parts.model.contextTargetFlyToAllowed(layerId) || !parts.model.contextTargetFlyToAllowed(layerId) ||
!vessel?.position || !vessel?.position ||
!layerState.viewer !layerState.display
) )
return true; return true;
announceNavigationAuthority('context-vessel-focus'); announceNavigationAuthority('context-vessel-focus');
layerState.viewer.camera.flyToBoundingSphere( layerState.display.frameContact(vessel.position, VESSEL_FOCUS_RADIUS_M, 1.4);
new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M),
{ duration: 1.4 },
);
return true; return true;
} }
@@ -113,7 +109,7 @@ export function createFocus({ state: layerState, services, parts, source }) {
function closestToCurrentView(candidates) { function closestToCurrentView(candidates) {
if (!Array.isArray(candidates) || !candidates.length) return null; if (!Array.isArray(candidates) || !candidates.length) return null;
const cameraPosition = layerState.viewer?.camera?.positionWC; const cameraPosition = layerState.display?.camera?.positionWC;
if (!cameraPosition) if (!cameraPosition)
return candidates.find((candidate) => candidate?.position) || null; return candidates.find((candidate) => candidate?.position) || null;
@@ -146,7 +142,7 @@ export function createFocus({ state: layerState, services, parts, source }) {
if ( if (
!layerState.enabled || !layerState.enabled ||
layerState.subject || layerState.subject ||
!layerState.viewer || !layerState.display ||
layerState.autoFocusAttempted layerState.autoFocusAttempted
) )
return false; return false;
@@ -181,10 +177,7 @@ export function createFocus({ state: layerState, services, parts, source }) {
announceNavigationAuthority('context-vessel-autofocus', { announceNavigationAuthority('context-vessel-autofocus', {
cancelPendingSelection: false, cancelPendingSelection: false,
}); });
layerState.viewer.camera.flyToBoundingSphere( layerState.display.frameContact(vessel.position, VESSEL_FOCUS_RADIUS_M, 1.6);
new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M),
{ duration: 1.6 },
);
return true; return true;
} }
return { return {
+29 -7
View File
@@ -1,3 +1,4 @@
import { createAwarenessCesiumRenderer } from './render/cesium.js';
import { cameraPoseSignature } from '../../data/iconOrientation.js'; import { cameraPoseSignature } from '../../data/iconOrientation.js';
export function createLifecycle({ export function createLifecycle({
@@ -36,7 +37,7 @@ export function createLifecycle({
} }
function attachRuntimeListeners() { function attachRuntimeListeners() {
if (layerState.runtimeListenersAttached || !layerState.viewer) return; if (layerState.runtimeListenersAttached || !layerState.display) return;
window.addEventListener( window.addEventListener(
'gev:awareness-subject-selected', 'gev:awareness-subject-selected',
layerState.subjectListener, layerState.subjectListener,
@@ -51,14 +52,14 @@ export function createLifecycle({
layerState.subjectClearListener, layerState.subjectClearListener,
); );
layerState.preRenderRemover = layerState.preRenderRemover =
layerState.viewer.scene.preRender.addEventListener(() => { layerState.display.onFrame(() => {
if (!layerState.enabled) return; if (!layerState.enabled) return;
const now = Date.now(); const now = Date.now();
// "Did the camera move" uses the SAME quantized pose signature the fleet // "Did the camera move" uses the SAME quantized pose signature the fleet
// rotation pass gates on (iconOrientation.cameraPoseSignature) rather than // rotation pass gates on (iconOrientation.cameraPoseSignature) rather than
// camera.changed, whose granularity is globally degraded by other layers // camera.changed, whose granularity is globally degraded by other layers
// mutating camera.percentageChanged. // mutating camera.percentageChanged.
const poseSig = cameraMotionSignature(layerState.viewer.camera); const poseSig = cameraMotionSignature(layerState.display.camera);
if (poseSig !== layerState.lastCameraPoseSig) { if (poseSig !== layerState.lastCameraPoseSig) {
layerState.lastCameraPoseSig = poseSig; layerState.lastCameraPoseSig = poseSig;
layerState.lastCameraPoseChangeMs = now; layerState.lastCameraPoseChangeMs = now;
@@ -121,9 +122,7 @@ export function createLifecycle({
layerState.runtimeListenersAttached = false; layerState.runtimeListenersAttached = false;
} }
const methods = { const methods = {
init(viewer) { init(viewer, { engine = null } = {}) {
detachRuntimeListeners();
layerState.viewer = viewer;
layerState.subjectListener = (event) => { layerState.subjectListener = (event) => {
if (!layerState.enabled) return; if (!layerState.enabled) return;
parts.subject.selectSubject(event.detail); parts.subject.selectSubject(event.detail);
@@ -177,6 +176,28 @@ export function createLifecycle({
parts.subject.clearAwarenessSubject(); 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() { enable() {
@@ -235,7 +256,8 @@ export function createLifecycle({
layerState.contextListener = null; layerState.contextListener = null;
layerState.clearListener = null; layerState.clearListener = null;
layerState.subjectClearListener = null; layerState.subjectClearListener = null;
layerState.viewer = null; layerState.display?.destroy?.();
layerState.display = null;
layerState.dataManager = null; layerState.dataManager = null;
}, },
}; };
+40
View File
@@ -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),
};
},
};
}
+63
View File
@@ -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,
};
}
+15 -36
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
import { import {
@@ -8,10 +7,9 @@ import {
import { import {
formatAwarenessLabel, formatAwarenessLabel,
formatAwarenessDistance, formatAwarenessDistance,
AWARENESS_RADIUS_M,
} from '../../data/militaryAwarenessEngine.js'; } from '../../data/militaryAwarenessEngine.js';
import { bearingBetweenCoordinates } from '../../cockpitMath.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'; import { formatAwarenessDirection } from './presentation.js';
export function createRendering({ export function createRendering({
@@ -36,10 +34,10 @@ export function createRendering({
* viewer's container. Each part keeps its last placement while hidden. * viewer's container. Each part keeps its last placement while hidden.
*/ */
function ensureDirectionOverlay() { function ensureDirectionOverlay() {
if (layerState.directionView || !layerState.viewer) if (layerState.directionView || !layerState.display)
return layerState.directionView; return layerState.directionView;
presentDirection({ presentDirection({
container: layerState.viewer.container, container: layerState.display.container,
hidden: true, hidden: true,
ring: null, ring: null,
heading: { text: '', left: null, top: null }, heading: { text: '', left: null, top: null },
@@ -129,7 +127,7 @@ export function createRendering({
return; return;
} }
const canvas = layerState.viewer.scene.canvas; const canvas = layerState.display.canvas;
const width = canvas.clientWidth || canvas.width; const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height; const height = canvas.clientHeight || canvas.height;
const sonar = isCyberSonarActive() const sonar = isCyberSonarActive()
@@ -147,7 +145,7 @@ export function createRendering({
120, 120,
Math.min(geometry.radius - 48, geometry.radius * 0.66), 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 = const headingDeg =
(Math.round(geo.math.toDegrees(cameraHeading)) + 360) % 360; (Math.round(geo.math.toDegrees(cameraHeading)) + 360) % 360;
const ring = { const ring = {
@@ -205,8 +203,8 @@ export function createRendering({
} }
geo.cartesian3.normalize(direction, direction); geo.cartesian3.normalize(direction, direction);
const projection = celestialScreenAngle( const projection = celestialScreenAngle(
geo.cartesian3.dot(direction, layerState.viewer.camera.rightWC), geo.cartesian3.dot(direction, layerState.display.camera.rightWC),
geo.cartesian3.dot(direction, layerState.viewer.camera.upWC), geo.cartesian3.dot(direction, layerState.display.camera.upWC),
layerState.directionAngles[index], layerState.directionAngles[index],
); );
if (projection.stable) 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)`, transform: `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`,
}; };
const label = formatAwarenessLabel(item); const label = formatAwarenessLabel(item);
const subjectCartographic = Cesium.Cartographic.fromCartesian( const subjectCartographic = geo.cartographic.fromCartesian(
layerState.subject.position, layerState.subject.position,
undefined, undefined,
layerState.SUBJECT_CARTOGRAPHIC_SCRATCH, layerState.SUBJECT_CARTOGRAPHIC_SCRATCH,
); );
const targetCartographic = Cesium.Cartographic.fromCartesian( const targetCartographic = geo.cartographic.fromCartesian(
item.position, item.position,
undefined, undefined,
layerState.TARGET_CARTOGRAPHIC_SCRATCH[index], layerState.TARGET_CARTOGRAPHIC_SCRATCH[index],
@@ -273,9 +271,8 @@ export function createRendering({
} }
function clearVisual() { function clearVisual() {
if (layerState.visual?.entities && layerState.viewer) { if (layerState.visual) {
for (const entity of layerState.visual.entities) layerState.visual.rim.destroy();
layerState.viewer.entities.remove(entity);
// Idle mode renders only on request; a removed ring must not linger. // Idle mode renders only on request; a removed ring must not linger.
governorRequestRender('awareness-visual'); governorRequestRender('awareness-visual');
} }
@@ -283,8 +280,8 @@ export function createRendering({
} }
function renderVisual(subject) { function renderVisual(subject) {
if (!layerState.viewer || !subject?.position) return; if (!layerState.display || !subject?.position) return;
const cartographic = Cesium.Cartographic.fromCartesian(subject.position); const cartographic = geo.cartographic.fromCartesian(subject.position);
if (!cartographic) return; if (!cartographic) return;
const groundCenter = geo.cartesian3.fromRadians( const groundCenter = geo.cartesian3.fromRadians(
cartographic.longitude, cartographic.longitude,
@@ -299,8 +296,7 @@ export function createRendering({
if ( if (
geo.cartesian3.distance(layerState.visual.center, groundCenter) >= 250 geo.cartesian3.distance(layerState.visual.center, groundCenter) >= 250
) { ) {
for (const entity of layerState.visual.entities) layerState.visual.rim.move(groundCenter);
entity.position.setValue(groundCenter);
geo.cartesian3.clone(groundCenter, layerState.visual.center); geo.cartesian3.clone(groundCenter, layerState.visual.center);
// Content actually changed — buy exactly one frame instead of holding. // Content actually changed — buy exactly one frame instead of holding.
governorRequestRender('awareness-visual'); governorRequestRender('awareness-visual');
@@ -309,26 +305,9 @@ export function createRendering({
} }
clearVisual(); 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 = { layerState.visual = {
key, key,
entities, rim: layerState.display.createRim(groundCenter),
center: geo.cartesian3.clone(groundCenter), center: geo.cartesian3.clone(groundCenter),
}; };
governorRequestRender('awareness-visual'); governorRequestRender('awareness-visual');
+1 -1
View File
@@ -14,7 +14,7 @@ export function createState({ services }) {
); );
Object.assign(state, { Object.assign(state, {
viewer: null, display: null,
dataManager: null, dataManager: null,
enabled: false, enabled: false,
// Set once a refresh observes the subject gone from a still-reporting source. // Set once a refresh observes the subject gone from a still-reporting source.
+3 -3
View File
@@ -193,7 +193,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
function currentTrackedFlightSubject() { function currentTrackedFlightSubject() {
const trackedKey = normalizeContextId( const trackedKey = normalizeContextId(
layerState.viewer?.trackedEntity?.gevTrackedId, layerState.display?.trackedEntity?.gevTrackedId,
); );
if (!trackedKey) return null; if (!trackedKey) return null;
const subjects = [ const subjects = [
@@ -262,14 +262,14 @@ export function createSubject({ state: layerState, services, parts, source }) {
if (!subject?.position) return null; if (!subject?.position) return null;
if (subject.layerId === 'flights' || subject.layerId === 'military') { if (subject.layerId === 'flights' || subject.layerId === 'military') {
const trackedPosition = const trackedPosition =
layerState.viewer?.trackedEntity?.gevDisplayPosition?.(); layerState.display?.trackedEntity?.gevDisplayPosition?.();
if (trackedPosition) { if (trackedPosition) {
return { return {
position: Cesium.Cartesian3.clone(trackedPosition), position: Cesium.Cartesian3.clone(trackedPosition),
// Only the follow camera's own contact proves presence this way; a // Only the follow camera's own contact proves presence this way; a
// different tracked entity says nothing about this subject. // different tracked entity says nothing about this subject.
presence: presence:
String(layerState.viewer?.trackedEntity?.gevTrackedId || '') === String(layerState.display?.trackedEntity?.gevTrackedId || '') ===
subjectKey(subject) subjectKey(subject)
? SUBJECT_PRESENCE.LIVE ? SUBJECT_PRESENCE.LIVE
: SUBJECT_PRESENCE.UNCHECKED, : SUBJECT_PRESENCE.UNCHECKED,
+3 -3
View File
@@ -49,7 +49,7 @@ export function createControls({ state: layerState, services, parts, source }) {
statsRefreshInterval: 1000, statsRefreshInterval: 1000,
visitNamedMarkers(visitor) { visitNamedMarkers(visitor) {
parts.namedMarkers.visit(visitor); parts.namedMarkers?.visit(visitor);
}, },
/** Request a one-shot Google Maps Places search around the current map view. */ /** 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( position: geo.cartesian3.fromDegrees(
record.longitude, record.longitude,
record.latitude, record.latitude,
parts.rendering.installationSurfaceHeightM(record), parts.model.installationSurfaceHeightM(record),
), ),
distanceM, distanceM,
}); });
@@ -184,7 +184,7 @@ export function createControls({ state: layerState, services, parts, source }) {
center: geo.cartesian3.fromDegrees( center: geo.cartesian3.fromDegrees(
record.longitude, record.longitude,
record.latitude, record.latitude,
parts.rendering.installationSurfaceHeightM(record), parts.model.installationSurfaceHeightM(record),
), ),
radius: 18000, radius: 18000,
}; };
+1 -1
View File
@@ -21,7 +21,7 @@ test('focus frames a selected installation through the globe engine camera', ()
services: {}, services: {},
parts: { parts: {
selection: { selectRecord: (id) => id === record.id }, selection: { selectRecord: (id) => id === record.id },
rendering: { installationSurfaceHeightM: () => 126 }, model: { installationSurfaceHeightM: () => 126 },
}, },
source: {}, source: {},
}).methods; }).methods;
+36 -13
View File
@@ -28,6 +28,9 @@ export function createInstallationsLayer({
let renderer = null; let renderer = null;
const lifecycle = { const lifecycle = {
init(viewer, options = {}) { init(viewer, options = {}) {
if (viewer || options.engine) return lifecycle.attachRenderer(viewer, options);
},
attachRenderer(viewer, options = {}) {
if (renderer) if (renderer)
throw new Error('Installations layer is already initialized'); throw new Error('Installations layer is already initialized');
const engine = options.engine ?? null; const engine = options.engine ?? null;
@@ -35,27 +38,47 @@ export function createInstallationsLayer({
if (!renderer?.lifecycle?.methods) if (!renderer?.lifecycle?.methods)
throw new Error('Installation renderer is unavailable'); throw new Error('Installation renderer is unavailable');
Object.assign(parts, renderer); 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) { enable(...args) {
state.enabled = true;
return renderer?.lifecycle?.methods.enable(...args); return renderer?.lifecycle?.methods.enable(...args);
}, },
disable(...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) { destroy(...args) {
try { if (renderer) renderer.lifecycle.methods.destroy(...args);
return renderer?.lifecycle?.methods.destroy(...args); else {
} finally { lifecycle.disable();
renderer = null; source.destroy?.();
for (const key of [
'rendering',
'namedMarkers',
'selection',
'lifecycle',
])
delete parts[key];
} }
renderer = null;
}, },
}; };
return Object.assign( return Object.assign(
+3 -4
View File
@@ -73,8 +73,7 @@ export function createIngestion({
} }
async function loadInstallations() { async function loadInstallations() {
if (!layerState.enabled || (!layerState.viewer && !layerState.engine)) if (!layerState.enabled) return;
return;
const { box, coverage } = parts.viewport.loadArea(layerState.viewer); const { box, coverage } = parts.viewport.loadArea(layerState.viewer);
// Guidance, not a fault: the layer chose not to query because the view is // 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 // 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') ? msg('Too many mapped sites in view to list them all')
: placesError, : placesError,
); );
parts.rendering.renderRecords(); parts.rendering?.renderRecords();
parts.rendering.warmInstallationFloors( parts.rendering?.warmInstallationFloors(
layerState.records.filter((r) => !r.pointOnly), layerState.records.filter((r) => !r.pointOnly),
); );
}; };
+2 -2
View File
@@ -44,11 +44,11 @@ export function createModel({ state: layerState, services, parts, source }) {
} }
function installationSurfaceHeightM(record) { function installationSurfaceHeightM(record) {
const floor = services.ground?.cachedGroundFloor?.( const floor = services?.ground?.cachedGroundFloor?.(
record?.latitude, record?.latitude,
record?.longitude, record?.longitude,
); );
return services.ground?.floorAltitudeM?.(null, floor) ?? 0; return services?.ground?.floorAltitudeM?.(null, floor) ?? 0;
} }
/** /**
@@ -55,10 +55,11 @@ test('Contacts camera motion reprojects and declutters existing labels without s
h.setX(600); h.lifecycle.enable(); h.setX(600); h.lifecycle.enable();
assert.equal(h.labels.length, 2); assert.equal(h.labels.length, 2);
assert.equal(h.publications(), 5); assert.equal(h.publications(), 5);
const oldScene = h.state.viewer.scene;
h.lifecycle.destroy(h.state.viewer); h.lifecycle.destroy(h.state.viewer);
assert.equal(h.camera.changed.numberOfListeners, 0); assert.equal(h.camera.changed.numberOfListeners, 0);
assert.equal(h.camera.moveEnd.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) => { test('wide named markers retain primitives until close polygons publish with the same identity', async (t) => {
@@ -92,11 +92,19 @@ export function createLifecycle({
destroy(viewer) { destroy(viewer) {
source.destroy?.(); source.destroy?.();
this.disable(); this.disable();
layerState.contextAnchor = null; this.detach(viewer);
layerState.contextPosition = null; },
layerState.coverage = { kind: 'viewport' };
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.moveEndRemove?.();
layerState.changedRemove?.(); layerState.changedRemove?.();
layerState.moveEndRemove = layerState.changedRemove = null;
parts.selection.destroy?.(); parts.selection.destroy?.();
layerState.clickHandler?.destroy(); layerState.clickHandler?.destroy();
layerState.clickHandler = null; layerState.clickHandler = null;
@@ -105,6 +113,7 @@ export function createLifecycle({
if (layerState.dataSource && viewer) if (layerState.dataSource && viewer)
viewer.dataSources.remove(layerState.dataSource, true); viewer.dataSources.remove(layerState.dataSource, true);
layerState.dataSource = null; layerState.dataSource = null;
layerState.viewer = null;
layerState.engine = null; layerState.engine = null;
}, },
}; };
@@ -353,8 +353,9 @@ export function createRendering({
) )
.map((record) => ({ lat: record.latitude, lon: record.longitude })); .map((record) => ({ lat: record.latitude, lon: record.longitude }));
if (!cold.length) return; if (!cold.length) return;
const owner = layerState.dataSource;
warmFireAnchorFloors(cold).then(() => { warmFireAnchorFloors(cold).then(() => {
if (!layerState.enabled || !layerState.dataSource) return; if (!layerState.enabled || !owner || layerState.dataSource !== owner) return;
if ( if (
!cold.some((point) => cachedGroundFloor(point.lat, point.lon) != null) !cold.some((point) => cachedGroundFloor(point.lat, point.lon) != null)
) )
+7 -1
View File
@@ -608,7 +608,7 @@ export function createInstallationsNavaraRenderer(
services.picking.registerPickOwner(LAYER_ID, (id) => services.picking.registerPickOwner(LAYER_ID, (id) =>
layerState.recordById.has(id), layerState.recordById.has(id),
); );
renderRecords(); renderRecords({ claimSelection: Boolean(layerState.selectedId) });
}, },
disable() { disable() {
visible = false; visible = false;
@@ -640,6 +640,12 @@ export function createInstallationsNavaraRenderer(
destroy() { destroy() {
source.destroy?.(); source.destroy?.();
this.disable(); this.disable();
this.detach();
},
detach() {
services.credits?.hideOsmCredit?.(engine, LAYER_ID);
syncPlacesCredit([]);
services.picking.unregisterPickOwner(LAYER_ID);
layerState.moveEndRemove?.(); layerState.moveEndRemove?.();
layerState.changedRemove?.(); layerState.changedRemove?.();
layerState.moveEndRemove = layerState.changedRemove = null; layerState.moveEndRemove = layerState.changedRemove = null;
+3 -1
View File
@@ -19,7 +19,9 @@ export function createViewport({ state: layerState, services, parts, source }) {
return { box: bounds, coverage: { kind: 'subject', radiusM } }; 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) { function viewportBox(viewer) {
+2 -2
View File
@@ -172,11 +172,11 @@ export function createLifecycle({
components.rendering.installRuntime(scene); components.rendering.installRuntime(scene);
restoreSpriteOrder(viewer); restoreSpriteOrder(viewer);
components.rendering.setVisible(state.feed.enabled); components.rendering.setVisible(state.feed.enabled);
if (!state.feed.enabled) return;
bindVisibleScene(viewer, scene);
const horizon = components.rendering.makeOccluder(); const horizon = components.rendering.makeOccluder();
for (const record of state.records.all) for (const record of state.records.all)
components.rendering.addRecordPrimitives(record, horizon); components.rendering.addRecordPrimitives(record, horizon);
if (!state.feed.enabled) return;
bindVisibleScene(viewer, scene);
components.tracking.attachSelectedVesselTrail(); components.tracking.attachSelectedVesselTrail();
components.rendering.updateVisibility(true); components.rendering.updateVisibility(true);
if (state.selectedRecord) if (state.selectedRecord)
+8
View File
@@ -124,12 +124,19 @@ async function reconcile(remember = false) {
const previous = kind; const previous = kind;
let cover; let cover;
let cockpit; let cockpit;
const handoffHash = remember ? location.hash : null;
try { try {
while (pageEngineKind() !== kind) { while (pageEngineKind() !== kind) {
cover ??= keepGlobeFrame(application.getComponents().scene.engine); cover ??= keepGlobeFrame(application.getComponents().scene.engine);
cockpit = application cockpit = application
.getComponents() .getComponents()
.controls.styleManager.getCockpitState(); .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); renderers.retain(kind);
await application.destroy(); await application.destroy();
await start(pageEngineKind(), true, cockpit); await start(pageEngineKind(), true, cockpit);
@@ -156,6 +163,7 @@ async function reconcile(remember = false) {
report(recoveryError); report(recoveryError);
} }
} finally { } finally {
layerSession.handoffLayers = null;
cover?.remove(); cover?.remove();
busy = false; busy = false;
present(); present();
+1
View File
@@ -130,6 +130,7 @@ export function createStandaloneApplication({
createStandaloneControls({ createStandaloneControls({
...context, ...context,
engineSwitch, engineSwitch,
layerSession,
loaderStatus, loaderStatus,
placeSearch, placeSearch,
catalog, catalog,
+11
View File
@@ -105,11 +105,13 @@ export class StyleManager extends ShellFacade {
requestServices, requestServices,
initialShareCode = null, initialShareCode = null,
engineSwitch = false, engineSwitch = false,
layerSession = null,
localePicker = true, localePicker = true,
} = {}, } = {},
) { ) {
super(); super();
this._localePickerEnabled = localePicker; this._localePickerEnabled = localePicker;
this._layerSession = layerSession;
const { const {
IntelHUD, IntelHUD,
ShareLinkManager, ShareLinkManager,
@@ -197,6 +199,7 @@ export class StyleManager extends ShellFacade {
this._shareRestoration = new ShareRestoration({ this._shareRestoration = new ShareRestoration({
viewer, viewer,
canvas: engine?.host.canvas ?? viewer?.canvas, canvas: engine?.host.canvas ?? viewer?.canvas,
handoffLayers: layerSession?.handoffLayers,
navigation: this._navigation, navigation: this._navigation,
syncShareState: () => this._syncShareState(), syncShareState: () => this._syncShareState(),
syncModels3d: (state) => this._syncModels3dFromLayerState(state), syncModels3d: (state) => this._syncModels3dFromLayerState(state),
@@ -972,6 +975,8 @@ export class StyleManager extends ShellFacade {
setClearBusy: (busy) => this._publishClearBusy(busy), 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. // The Contacts layer publishes its panel; the view calls its navigation.
contextStore.connectAwareness(militaryAwarenessLayer); contextStore.connectAwareness(militaryAwarenessLayer);
// A panel left by an earlier shell must not outlive its layer. // 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 // Close camera-entry seams synchronously. Context restoration may await
// layer work, so leaving these listeners attached until afterward lets a // layer work, so leaving these listeners attached until afterward lets a
// focus event release tracking or start a flight during teardown. // focus event release tracking or start a flight during teardown.
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(); await this._contextControls.restoreForDisposal();
}
// IR boost teardown BEFORE detaching the data manager: restore fog and // IR boost teardown BEFORE detaching the data manager: restore fog and
// un-boost both aircraft layers so a surviving viewer or replacement // un-boost both aircraft layers so a surviving viewer or replacement
// manager doesn't inherit sensor state (review P2, 2026-08-16). // manager doesn't inherit sensor state (review P2, 2026-08-16).
+13
View File
@@ -85,6 +85,19 @@ export class ContextControls {
this[field] = null; 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() { stop() {
if (this.destroyed) return; if (this.destroyed) return;
this.destroyed = true; this.destroyed = true;
+5
View File
@@ -110,6 +110,7 @@ export class ShareRestoration {
constructor({ constructor({
viewer, viewer,
canvas = viewer?.canvas, canvas = viewer?.canvas,
handoffLayers = null,
navigation, navigation,
syncShareState, syncShareState,
syncModels3d, syncModels3d,
@@ -121,6 +122,7 @@ export class ShareRestoration {
Object.assign(this, { Object.assign(this, {
viewer, viewer,
canvas, canvas,
handoffLayers,
navigation, navigation,
syncShareState, syncShareState,
syncModels3d, syncModels3d,
@@ -368,11 +370,13 @@ export class ShareRestoration {
); );
this._layerStateRestorePromise = this._layerStateCoordinator.start({ this._layerStateRestorePromise = this._layerStateCoordinator.start({
shareLayerState: this._initialShareState?.layerState || null, shareLayerState: this._initialShareState?.layerState || null,
handoffLayers: this.handoffLayers,
shareCreatedAtMs: this._initialShareState?.sharedAtMs ?? null, shareCreatedAtMs: this._initialShareState?.sharedAtMs ?? null,
// Any valid camera/style share isolates recipient-local preferences, // Any valid camera/style share isolates recipient-local preferences,
// including legacy and malformed-v2 layer payloads. // including legacy and malformed-v2 layer payloads.
allowLocalState: !this._initialShareState, allowLocalState: !this._initialShareState,
}); });
this.handoffLayers = null;
if (this._initialShareSelectionSuperseded) { if (this._initialShareSelectionSuperseded) {
this._layerStateCoordinator.cancelPendingShareTracking( this._layerStateCoordinator.cancelPendingShareTracking(
'superseded-before-layer-coordinator-start', 'superseded-before-layer-coordinator-start',
@@ -425,6 +429,7 @@ export class ShareRestoration {
try { try {
while (live()) { while (live()) {
const vessels = this._dataManager?.layers?.get?.(layerId)?.module; 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 (vessels?.hasContact?.(id) && vessels.selectById(id)) return true;
if (Date.now() >= deadline) break; if (Date.now() >= deadline) break;
await new Promise((resolve) => await new Promise((resolve) =>