mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 21:02:19 +00:00
Separate scene handoff and attachment from the page shell
Move the page handoff sequence into SceneSwitcher and include restoration in the application's candidate attachment. Release a failed candidate once before the request coordinator recovers the working engine. Keep address ordering, parked renderers, retained layers and cockpit resumption intact. Define the layer session and its field owners. Share its retention decision across teardown paths. Separate layer scene binding from lifecycle policy, and shell scene composition from page controls. Keep failed renderer releases in the binding owner, with the existing retry behavior. Give Director the scene and use the engine host for its canvas and pack container. Keep the existing tracked-subject interaction guard so neither engine's behavior changes. Pass startup chrome the first backend's explicit welcome policy instead of testing for a viewer. Cesium remains the behavior reference; this restructuring intentionally changes no visible behavior on either engine. Viewer references outside the Cesium engine fall from 948 to 933. All other coupling counts stay unchanged. Scene service ownership, raw debug access, and the remaining shell and host queues still need consolidation. Checked in the browser, keyless, on the dev server: the engine switch, Director engine switch, layer panel and layer visibility on both engines pass, and 14 of 15 strict scenarios pass (rapid, hidden-tab, context-loss, parked-release, Navara HD, failure matrix and soak among them). app-once and the Director next-shot step fail the same way before this change.
This commit is contained in:
1 parent
f385986383
commit
656943641c
23 files changed
+1265
-888
No files matched your search
@@ -90,7 +90,7 @@ load, removes partial results and reports a stable error without exposing a URL.
|
||||
Successful loading leaves the attribution cards visible with the assets.
|
||||
|
||||
`createApplicationTools({ sceneDataPacks: { sources } })` and
|
||||
`new SceneDirector(viewer, style, data, { dataPacks: { sources } })` accept a
|
||||
`new SceneDirector(scene, style, data, { dataPacks: { sources } })` accept a
|
||||
registry of source functions. Each receives `{ path, signal, maxBytes }` and
|
||||
returns `{ bytes: Uint8Array, mimeType }`. Custom transports must honor the
|
||||
signal, bound reads and return the real MIME type. They remain application code,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"cesiumLayerRendererLines": 14,
|
||||
"navaraLayerRendererLines": 314,
|
||||
"cesiumImportFiles": 5,
|
||||
"viewerLines": 948,
|
||||
"viewerLines": 933,
|
||||
"navaraKindChecks": 2,
|
||||
"engineSniffCalls": 0,
|
||||
"engineExtensionReads": 4,
|
||||
@@ -12,5 +12,5 @@
|
||||
"screenSpaceHandlerFiles": 4,
|
||||
"engineNameLines": 36
|
||||
},
|
||||
"measuredAt": "Draw Bhote Koshi story graphics and range pulse fields through engine.ground"
|
||||
"measuredAt": "Separate scene attachment and page switching; give Director the scene"
|
||||
}
|
||||
@@ -1692,5 +1692,11 @@
|
||||
"src/globe/groundPulse.test.mjs",
|
||||
"src/globe/navara/ground/pulse.test.mjs",
|
||||
"src/layers/bhoteKoshi/render/story.test.mjs",
|
||||
"src/globe/navara/windFlowMesh.js"
|
||||
"src/globe/navara/windFlowMesh.js",
|
||||
"src/data/layerSceneBinding.js",
|
||||
"src/ui/shellScene.js",
|
||||
"src/app/layerSession.js",
|
||||
"src/standalone/sceneSwitcher.js",
|
||||
"src/app/layerSession.test.mjs",
|
||||
"src/standalone/sceneSwitcher.test.mjs"
|
||||
]
|
||||
@@ -1007,6 +1007,8 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/app/layerSession.js",
|
||||
"src/ui/shellScene.js",
|
||||
"src/ui/localePicker.js",
|
||||
"src/ui/cyberSonarControls.js",
|
||||
"src/ui/locationNavigation.js",
|
||||
@@ -2540,6 +2542,7 @@
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/lifecycle.js",
|
||||
"src/data/layerSceneBinding.js",
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js",
|
||||
"src/data/local_data/natural_earth/marine.json",
|
||||
@@ -2881,6 +2884,8 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/app/layerSession.js",
|
||||
"src/ui/shellScene.js",
|
||||
"src/ui/mobileShell.js",
|
||||
"src/ui/localePicker.js",
|
||||
"src/ui/cyberSonarControls.js",
|
||||
@@ -4457,6 +4462,7 @@
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/globe/contract.js",
|
||||
"src/data/lifecycle.js",
|
||||
"src/data/layerSceneBinding.js",
|
||||
"src/data/cameraArrival.js"
|
||||
],
|
||||
"external": []
|
||||
|
||||
@@ -225,8 +225,7 @@ try {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
Math.abs(
|
||||
window.__godsEyeView.sceneDirector.viewer.camera.positionCartographic
|
||||
.height - 5000,
|
||||
window.__godsEyeView.sceneDirector.engine.camera.height() - 5000,
|
||||
) < 1,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ test('the controls pass the scene engine to the shell', () => {
|
||||
});
|
||||
|
||||
test('the shell initializes the world overlay on its engine', () => {
|
||||
const shell = read('../ui/applicationShell.js');
|
||||
const shell = read('../ui/applicationShell.js') + read('../ui/shellScene.js');
|
||||
assert.match(shell, /\{\n\s+engine = null,\n\s+mapStackController = null,/);
|
||||
assert.match(shell, /initWorldOverlay\(engine\);/);
|
||||
assert.doesNotMatch(shell, /initWorldOverlay\(viewer\)/);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* State that survives replacement of the globe. Each field names its owner;
|
||||
* scene attachments keep their own signals and never replace this lifetime.
|
||||
*
|
||||
* @typedef {object} LayerSession
|
||||
* @property {AbortController} lifetime Page owner: ends retained work.
|
||||
* @property {Map<string, object>} models Catalog: retained layer modules.
|
||||
* @property {import('../data/lifecycle.js').LayerLifecycle | null} manager Data phase.
|
||||
* @property {import('./layerPresentation.js').LayerPresentation | null} presentation Data phase.
|
||||
* @property {ReturnType<typeof import('./operations.js').createApplicationOperations> | null} operations Scene factory: session requests and surface caches.
|
||||
* @property {ReturnType<typeof import('../layers/aircraft/classification.js').createMilitaryRegistry> | null} militaryRegistry Catalog.
|
||||
* @property {ReturnType<typeof import('../layers/weather/clock.js').createWeatherClock> | null} weatherClock Catalog.
|
||||
* @property {import('../scenes/director.js').SceneDirector | null} director Tools phase.
|
||||
* @property {ReturnType<typeof import('../annotations/index.js').createSessionAnnotations> | null} annotations Session tools.
|
||||
* @property {{draw?: ReturnType<typeof import('../annotations/drawTool.js').createSessionDrawTool>, geoImport?: ReturnType<typeof import('../ui/geoFileImport.js').createGeoFileImport>}} tools Session tools.
|
||||
* @property {ReturnType<typeof import('../voice/gevRealtime.js').createSceneVoice> | null} voice Session voice.
|
||||
* @property {{locale?: () => void, placeSearch?: object, sceneChrome?: boolean}} chrome Standalone startup and scene factory.
|
||||
* @property {Map<string, {entry: object | null, enabled: boolean, durable: boolean | undefined}> | null} handoffLayers Switch coordinator: outgoing layer intentions.
|
||||
* @property {boolean} restoringView Switch coordinator: incoming address restoration.
|
||||
* @property {() => boolean} switching Switch coordinator: voice waits while busy.
|
||||
* @property {object | null} contextScene Shell: retained Context state.
|
||||
*/
|
||||
|
||||
/** @returns {LayerSession} */
|
||||
export function createLayerSession() {
|
||||
return {
|
||||
lifetime: new AbortController(),
|
||||
models: new Map(),
|
||||
manager: null,
|
||||
presentation: null,
|
||||
operations: null,
|
||||
militaryRegistry: null,
|
||||
weatherClock: null,
|
||||
director: null,
|
||||
annotations: null,
|
||||
tools: {},
|
||||
voice: null,
|
||||
chrome: {},
|
||||
handoffLayers: null,
|
||||
restoringView: false,
|
||||
switching: () => false,
|
||||
contextScene: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether teardown preserves the session for another globe. */
|
||||
export function retainsLayerSession(session) {
|
||||
return Boolean(session?.handoffLayers && !session.lifetime.signal.aborted);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createLayerSession, retainsLayerSession } from './layerSession.js';
|
||||
import { handOffSession, resumeSession } from '../standalone/engineSwitch.js';
|
||||
|
||||
test('a session retains layer intentions through handoff but never after page teardown', () => {
|
||||
const session = createLayerSession();
|
||||
const other = createLayerSession();
|
||||
const calls = [];
|
||||
const entry = {
|
||||
module: {
|
||||
attachRenderer() {},
|
||||
resumeViewport: () => calls.push('viewport'),
|
||||
},
|
||||
};
|
||||
session.models.set('flights', entry.module);
|
||||
session.manager = {
|
||||
layers: new Map([['flights', entry]]),
|
||||
isEffectivelyEnabled: () => true,
|
||||
};
|
||||
session.director = {
|
||||
suspendScene: () => calls.push('suspend'),
|
||||
resumeScene: () => calls.push('resume'),
|
||||
};
|
||||
assert.equal(retainsLayerSession(session), false);
|
||||
handOffSession(session, { addressLayerIds: ['flights'] });
|
||||
assert.equal(retainsLayerSession(session), true);
|
||||
assert.deepEqual(session.handoffLayers.get('flights'), {
|
||||
entry,
|
||||
enabled: true,
|
||||
durable: true,
|
||||
});
|
||||
resumeSession(session);
|
||||
assert.deepEqual(calls, ['suspend', 'viewport', 'resume']);
|
||||
assert.equal(session.models.get('flights'), entry.module);
|
||||
assert.equal(other.models.size, 0);
|
||||
assert.notEqual(session.tools, other.tools);
|
||||
assert.notEqual(session.chrome, other.chrome);
|
||||
session.lifetime.abort();
|
||||
assert.equal(retainsLayerSession(session), false);
|
||||
assert.equal(other.lifetime.signal.aborted, false);
|
||||
assert.equal(retainsLayerSession(null), false);
|
||||
});
|
||||
@@ -148,6 +148,7 @@ export function navaraToolBackend({ engine }) {
|
||||
const views = engine.extensions?.navara ?? null;
|
||||
const drawn = Boolean(views?.view);
|
||||
return {
|
||||
startupChrome: { initializeWelcome: null },
|
||||
board: () =>
|
||||
drawn
|
||||
? { engine, renderer: createNavaraAnnotationRenderer(engine, views) }
|
||||
|
||||
+12
-12
@@ -1,3 +1,4 @@
|
||||
import { retainsLayerSession } from './layerSession.js';
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
|
||||
import { createRecentImageryPanel } from '../ui/recentImagery.js';
|
||||
@@ -73,8 +74,7 @@ export async function createApplicationTools({
|
||||
mapStackController?.isStackAvailable(id) === true;
|
||||
const sceneDirector =
|
||||
layerSession?.director ??
|
||||
new SceneDirector(first.viewer, styleManager, dataManager, {
|
||||
engine: first.engine,
|
||||
new SceneDirector(first, styleManager, dataManager, {
|
||||
dataPacks: sceneDataPacks,
|
||||
isMapStackAvailable: isStackAvailable(first.mapStackController),
|
||||
});
|
||||
@@ -85,7 +85,7 @@ export async function createApplicationTools({
|
||||
.get('bhote-koshi-2026')
|
||||
?.module.attachSceneController(sceneDirector);
|
||||
defer(() => {
|
||||
if (layerSession?.handoffLayers && !layerSession.lifetime.signal.aborted) {
|
||||
if (retainsLayerSession(layerSession)) {
|
||||
sceneDirector.suspendScene();
|
||||
} else {
|
||||
if (layerSession) layerSession.director = null;
|
||||
@@ -123,8 +123,7 @@ export async function createApplicationTools({
|
||||
if (next === directorScene) return;
|
||||
directorScene = null;
|
||||
sceneDirector.attachScene(
|
||||
next.viewer,
|
||||
next.engine,
|
||||
next,
|
||||
styleManager,
|
||||
isStackAvailable(next.mapStackController),
|
||||
);
|
||||
@@ -160,6 +159,7 @@ export async function createApplicationTools({
|
||||
// The attached engine's tools; `annotations` and the voice handle answer
|
||||
// for the engine attached now.
|
||||
const attached = { annotations: null, voiceCommands: null };
|
||||
let startupChromeOptions;
|
||||
const context = {
|
||||
controls,
|
||||
data,
|
||||
@@ -177,6 +177,7 @@ export async function createApplicationTools({
|
||||
name: 'tools',
|
||||
async attachScene(next, attachment) {
|
||||
const tools = await attachEngineTools(next, context, attachment);
|
||||
startupChromeOptions ??= tools.startupChrome;
|
||||
attached.annotations = tools.annotations;
|
||||
attached.voiceCommands = tools.voiceCommands;
|
||||
attachment.defer(() => {
|
||||
@@ -220,12 +221,10 @@ export async function createApplicationTools({
|
||||
{ defer },
|
||||
);
|
||||
detachPartsOnTeardown(scene, defer, () => ({
|
||||
handoff: Boolean(
|
||||
layerSession?.handoffLayers && !layerSession.lifetime.signal.aborted,
|
||||
),
|
||||
handoff: retainsLayerSession(layerSession),
|
||||
}));
|
||||
// The startup chrome belongs to the page: no first-run welcome on Navara
|
||||
// (its choices turn on data layers).
|
||||
// Startup chrome belongs to the page and keeps the first backend
|
||||
// policy across later engine attachments.
|
||||
if (startChrome)
|
||||
defer(
|
||||
startChrome({
|
||||
@@ -233,7 +232,7 @@ export async function createApplicationTools({
|
||||
styleManager,
|
||||
dataManager,
|
||||
signal,
|
||||
...(first.viewer ? {} : { initializeWelcome: null }),
|
||||
...startupChromeOptions,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
@@ -269,6 +268,7 @@ function cesiumToolBackend({ engine, tileset }) {
|
||||
const viewer = cesiumViewerOf(engine);
|
||||
return {
|
||||
viewer,
|
||||
startupChrome: {},
|
||||
board: () => ({ viewer, engine, tileset }),
|
||||
draw: () => ({ engine }),
|
||||
// The tileset lets the layer drape while the globe is hidden.
|
||||
@@ -465,7 +465,7 @@ async function attachEngineTools(
|
||||
{ styleManager, dataManager, voiceCommands },
|
||||
{ signal, defer },
|
||||
);
|
||||
return { annotations, voiceCommands };
|
||||
return { annotations, voiceCommands, startupChrome: backend.startupChrome };
|
||||
}
|
||||
|
||||
/** The annotation board drawn on this globe (`boardScene`), or null. */
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// A scene bind or detach waits this long for asynchronous layer attachments.
|
||||
// A late attachment sees its scene signal aborted and releases its own work.
|
||||
export const ATTACH_SETTLE_TIMEOUT_MS = 5000;
|
||||
|
||||
/** Scene attachment and parked work for the layer lifecycle. */
|
||||
export class LayerSceneBinding {
|
||||
constructor(attachSettleTimeoutMs) {
|
||||
// The bound scene: its signal reaches every attachRenderer() call and is
|
||||
// aborted when the scene detaches. Asynchronous attachments still running
|
||||
// are kept here so the detach releases them only after they stopped.
|
||||
this._failedReleases = new WeakMap();
|
||||
this._sceneController = null;
|
||||
this._sceneDetached = false;
|
||||
this._pendingAttachments = new Set();
|
||||
this._attachSettleTimeoutMs = attachSettleTimeoutMs;
|
||||
// Lifecycle work and explicit refreshes that wait for the next scene.
|
||||
// detachScene() does not wait for parked work, which waits for it.
|
||||
this._sceneWaiters = new Set();
|
||||
this._parkListeners = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the scene only. Models with an explicit renderer lifetime keep
|
||||
* their entry, polling interval, visibility intents and in-flight source.
|
||||
* Legacy modules use their ordinary teardown until they gain that contract.
|
||||
*
|
||||
* Every layer is released even when another one fails, and the manager is
|
||||
* always left unbound, so the next bindCatalog() can attach a scene. The
|
||||
* failures are reported together. A layer whose release failed keeps it
|
||||
* pending: a repeated detachScene(), or the next bindCatalog(), retries it.
|
||||
* Periodic refreshes, explicit refreshes and enables requested while no
|
||||
* scene is bound wait for the next one.
|
||||
*/
|
||||
async detachScene() {
|
||||
// Attachments still in flight stop at their next checkpoint; their
|
||||
// partial work is released below like a finished attachment.
|
||||
this._sceneController?.abort();
|
||||
this._sceneController = null;
|
||||
this._sceneDetached = true;
|
||||
const viewer = this.viewer;
|
||||
const errors = [];
|
||||
const droppedQaLayers = [];
|
||||
try {
|
||||
await this._awaitAttachments([...this._pendingAttachments], 'detach');
|
||||
for (const [id, entry] of [...this.layers]) {
|
||||
try {
|
||||
await this._lifecycleIdleOrParked(entry);
|
||||
if (entry.module.attachRenderer && entry.module.detachRenderer) {
|
||||
await this._detachEntryRenderer(entry, viewer);
|
||||
} else {
|
||||
const qaLayer = this._qaLayerIds.has(id);
|
||||
if (!(await this.destroyLayer(id))) {
|
||||
// The next catalog brings a fresh module for this id. A stale
|
||||
// entry would refuse it, so the failed one is dropped here.
|
||||
this._stopUpdateLoop(entry);
|
||||
this.layers.delete(id);
|
||||
this._qaLayerIds.delete(id);
|
||||
throw new Error(`Cannot detach layer ${id}`);
|
||||
}
|
||||
if (qaLayer) droppedQaLayers.push(id);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.viewer = null;
|
||||
this.engine = null;
|
||||
this._registrationsFinalized = false;
|
||||
this._registrationDispositions = null;
|
||||
}
|
||||
if (droppedQaLayers.length)
|
||||
console.warn(
|
||||
`[Data] QA layers do not survive a scene switch; register them again: ${droppedQaLayers.join(', ')}`,
|
||||
);
|
||||
if (errors.length) {
|
||||
for (const error of errors)
|
||||
console.warn('[Data] scene detach error:', error);
|
||||
throw new AggregateError(errors, 'Data layers could not detach');
|
||||
}
|
||||
}
|
||||
|
||||
/** Release one retained layer's renderer, keeping a failure for a retry. */
|
||||
async _detachEntryRenderer(entry, viewer) {
|
||||
const pending = this._failedReleases.get(entry);
|
||||
if (!pending && (!entry.initialized || entry.sceneDetached)) return;
|
||||
const target = pending ? pending.target : viewer;
|
||||
this._failedReleases.set(entry, { target });
|
||||
await entry.module.detachRenderer(target);
|
||||
this._failedReleases.delete(entry);
|
||||
entry.sceneDetached = true;
|
||||
}
|
||||
|
||||
/** Bind the next catalog only after the preceding scene released its entries. */
|
||||
bindCatalog(viewer, engine, catalog, options = {}) {
|
||||
if (this._registrationsFinalized) throw new Error('Scene already attached');
|
||||
this.viewer = viewer;
|
||||
this.engine = engine ?? null;
|
||||
this._catalogSourceAvailability = catalog.getSourceAvailability;
|
||||
const scene = new AbortController();
|
||||
this._sceneController = scene;
|
||||
// An attachment that is still running when the scene detaches sees this
|
||||
// signal aborted and must stop touching the scene.
|
||||
const context = { ...this._moduleContext(scene.signal), ...options };
|
||||
const 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) {
|
||||
attachments.push(this._attachEntryRenderer(entry, viewer, context));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Retained entries may appear anywhere in the directory. Preserve both
|
||||
// catalog order (panel/serialization) and each live entry's identity.
|
||||
for (const { id } of catalog.layers) {
|
||||
const entry = this.layers.get(id);
|
||||
this.layers.delete(id);
|
||||
this.layers.set(id, entry);
|
||||
}
|
||||
this.finalizeRegistrations(catalog.metadata);
|
||||
const pending = attachments.filter((ready) => ready?.then);
|
||||
// The scene counts as bound for refreshes and parked enables once its
|
||||
// attachments settled (or the wait for them timed out).
|
||||
const bound = () => {
|
||||
if (this._sceneController !== scene) return;
|
||||
this._sceneDetached = false;
|
||||
this._runDeferredRefreshes();
|
||||
this._settleSceneWaiters(true);
|
||||
};
|
||||
if (!pending.length) {
|
||||
bound();
|
||||
return undefined;
|
||||
}
|
||||
return this._awaitAttachments(pending, 'bind').then((settled) => {
|
||||
bound();
|
||||
const failure = settled?.find(({ status }) => status === 'rejected');
|
||||
if (failure) throw failure.reason;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for attachments to settle, at most attachSettleTimeoutMs. Returns
|
||||
* their allSettled results, or null when the wait timed out.
|
||||
*/
|
||||
async _awaitAttachments(attachments, phase) {
|
||||
if (!attachments.length) return [];
|
||||
let timer;
|
||||
const timedOut = new Promise((resolve) => {
|
||||
timer = setTimeout(resolve, this._attachSettleTimeoutMs, null);
|
||||
});
|
||||
const settled = await Promise.race([
|
||||
Promise.allSettled(attachments),
|
||||
timedOut,
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
if (!settled)
|
||||
console.warn(
|
||||
`[Data] layer attachments still running after ${this._attachSettleTimeoutMs} ms; scene ${phase} continues without them`,
|
||||
);
|
||||
return settled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until a scene is bound. Resolves true then, or false when the
|
||||
* signal aborts or the entry is destroyed. `refresh` marks an explicit
|
||||
* refresh, which replaces the entry's deferred periodic one.
|
||||
*/
|
||||
_waitForScene(entry, signal = null, { refresh = false } = {}) {
|
||||
if (!this._sceneDetached) return Promise.resolve(true);
|
||||
if (signal?.aborted) return Promise.resolve(false);
|
||||
return new Promise((resolve) => {
|
||||
const onAbort = () => waiter.settle(false);
|
||||
const waiter = {
|
||||
entry,
|
||||
refresh,
|
||||
settle: (bound) => {
|
||||
if (!this._sceneWaiters.delete(waiter)) return;
|
||||
if (refresh) entry.sceneRefreshWaiters -= 1;
|
||||
signal?.removeEventListener?.('abort', onAbort);
|
||||
resolve(bound);
|
||||
},
|
||||
};
|
||||
if (refresh) entry.sceneRefreshWaiters += 1;
|
||||
this._sceneWaiters.add(waiter);
|
||||
signal?.addEventListener?.('abort', onAbort, { once: true });
|
||||
for (const listener of [...this._parkListeners]) listener(entry);
|
||||
});
|
||||
}
|
||||
|
||||
_settleSceneWaiters(bound, entry = null) {
|
||||
for (const waiter of [...this._sceneWaiters])
|
||||
if (!entry || waiter.entry === entry) waiter.settle(bound);
|
||||
}
|
||||
|
||||
/** The entry's queued lifecycle work settled, or is parked for a scene. */
|
||||
_lifecycleIdleOrParked(entry) {
|
||||
const parked = () =>
|
||||
[...this._sceneWaiters].some(
|
||||
(waiter) => waiter.entry === entry && !waiter.refresh,
|
||||
);
|
||||
if (parked()) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
const done = () => {
|
||||
this._parkListeners.delete(listener);
|
||||
resolve();
|
||||
};
|
||||
const listener = (parkedEntry) => {
|
||||
if (parkedEntry === entry && parked()) done();
|
||||
};
|
||||
this._parkListeners.add(listener);
|
||||
entry.toggleChain.catch(() => {}).then(done);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach one retained layer to the bound scene. A release that failed on
|
||||
* the previous scene is retried first. Asynchronous attachments are tracked
|
||||
* so detachScene() releases them only after they stopped.
|
||||
*/
|
||||
_attachEntryRenderer(entry, viewer, context) {
|
||||
let ready;
|
||||
try {
|
||||
const pending = this._failedReleases.get(entry);
|
||||
entry.sceneDetached = false;
|
||||
if (pending) {
|
||||
ready = Promise.resolve()
|
||||
.then(() => entry.module.detachRenderer(pending.target))
|
||||
.then(() => {
|
||||
this._failedReleases.delete(entry);
|
||||
if (context.signal.aborted) return undefined;
|
||||
return entry.module.attachRenderer(viewer, context);
|
||||
});
|
||||
} else ready = entry.module.attachRenderer(viewer, context);
|
||||
} catch (error) {
|
||||
ready = Promise.reject(error);
|
||||
}
|
||||
if (!ready?.then) return ready;
|
||||
const tracked = Promise.resolve(ready);
|
||||
this._pendingAttachments.add(tracked);
|
||||
const untrack = () => this._pendingAttachments.delete(tracked);
|
||||
tracked.then(untrack, untrack);
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/** Run the periodic refreshes that came due while no scene was bound. */
|
||||
_runDeferredRefreshes() {
|
||||
for (const [layerId, entry] of this.layers) {
|
||||
if (!entry.refreshDeferred) continue;
|
||||
entry.refreshDeferred = false;
|
||||
// An explicit refresh waiting for this scene runs instead.
|
||||
if (entry.sceneRefreshWaiters > 0) continue;
|
||||
void this._runPeriodicUpdate(layerId, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-254
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
LayerSceneBinding,
|
||||
ATTACH_SETTLE_TIMEOUT_MS,
|
||||
} from './layerSceneBinding.js';
|
||||
import { cancelCameraArrival } from './cameraArrival.js';
|
||||
|
||||
function cloneLayerParams(value) {
|
||||
@@ -14,9 +18,6 @@ function cloneLayerParams(value) {
|
||||
}
|
||||
|
||||
const SUPERSEDED_VISIBILITY_INTENT = Symbol('superseded-visibility-intent');
|
||||
// A scene bind or detach waits this long for asynchronous layer attachments.
|
||||
// A late attachment sees its scene signal aborted and releases its own work.
|
||||
const ATTACH_SETTLE_TIMEOUT_MS = 5000;
|
||||
const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([
|
||||
'enabled-only',
|
||||
'enabled+options',
|
||||
@@ -78,7 +79,7 @@ function refreshFailureFromStats(stats, label) {
|
||||
* Hosts may inject a visibilityTarget with hidden/visibilityState and event
|
||||
* listener methods. Without one, periodic work retains its headless behavior.
|
||||
*/
|
||||
export class LayerLifecycle {
|
||||
export class LayerLifecycle extends LayerSceneBinding {
|
||||
/**
|
||||
* @param {object | null} viewer The Cesium viewer (null on Navara).
|
||||
* @param {{ engine?: import('../globe/contract.js').GlobeEngine,
|
||||
@@ -98,6 +99,7 @@ export class LayerLifecycle {
|
||||
attachSettleTimeoutMs = ATTACH_SETTLE_TIMEOUT_MS,
|
||||
} = {},
|
||||
) {
|
||||
super(attachSettleTimeoutMs);
|
||||
this._catalogSourceAvailability = getSourceAvailability;
|
||||
this._visibilityTarget = visibilityTarget;
|
||||
this.viewer = viewer;
|
||||
@@ -112,17 +114,6 @@ export class LayerLifecycle {
|
||||
this._registrationDispositions = null;
|
||||
this._allowQaRegistration = allowQaRegistration === true;
|
||||
this._qaLayerIds = new Set();
|
||||
// The bound scene: its signal reaches every attachRenderer() call and is
|
||||
// aborted when the scene detaches. Asynchronous attachments still running
|
||||
// are kept here so the detach releases them only after they stopped.
|
||||
this._sceneController = null;
|
||||
this._sceneDetached = false;
|
||||
this._pendingAttachments = new Set();
|
||||
this._attachSettleTimeoutMs = attachSettleTimeoutMs;
|
||||
// Lifecycle work and explicit refreshes that wait for the next scene.
|
||||
// detachScene() does not wait for parked work, which waits for it.
|
||||
this._sceneWaiters = new Set();
|
||||
this._parkListeners = new Set();
|
||||
}
|
||||
|
||||
/** The second argument of every layer module call: `{ signal, engine }`. */
|
||||
@@ -137,244 +128,6 @@ export class LayerLifecycle {
|
||||
this._registerLayer(layerModule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the scene only. Models with an explicit renderer lifetime keep
|
||||
* their entry, polling interval, visibility intents and in-flight source.
|
||||
* Legacy modules use their ordinary teardown until they gain that contract.
|
||||
*
|
||||
* Every layer is released even when another one fails, and the manager is
|
||||
* always left unbound, so the next bindCatalog() can attach a scene. The
|
||||
* failures are reported together. A layer whose release failed keeps it
|
||||
* pending: a repeated detachScene(), or the next bindCatalog(), retries it.
|
||||
* Periodic refreshes, explicit refreshes and enables requested while no
|
||||
* scene is bound wait for the next one.
|
||||
*/
|
||||
async detachScene() {
|
||||
// Attachments still in flight stop at their next checkpoint; their
|
||||
// partial work is released below like a finished attachment.
|
||||
this._sceneController?.abort();
|
||||
this._sceneController = null;
|
||||
this._sceneDetached = true;
|
||||
const viewer = this.viewer;
|
||||
const errors = [];
|
||||
const droppedQaLayers = [];
|
||||
try {
|
||||
await this._awaitAttachments([...this._pendingAttachments], 'detach');
|
||||
for (const [id, entry] of [...this.layers]) {
|
||||
try {
|
||||
await this._lifecycleIdleOrParked(entry);
|
||||
if (entry.module.attachRenderer && entry.module.detachRenderer) {
|
||||
await this._detachEntryRenderer(entry, viewer);
|
||||
} else {
|
||||
const qaLayer = this._qaLayerIds.has(id);
|
||||
if (!(await this.destroyLayer(id))) {
|
||||
// The next catalog brings a fresh module for this id. A stale
|
||||
// entry would refuse it, so the failed one is dropped here.
|
||||
this._stopUpdateLoop(entry);
|
||||
this.layers.delete(id);
|
||||
this._qaLayerIds.delete(id);
|
||||
throw new Error(`Cannot detach layer ${id}`);
|
||||
}
|
||||
if (qaLayer) droppedQaLayers.push(id);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.viewer = null;
|
||||
this.engine = null;
|
||||
this._registrationsFinalized = false;
|
||||
this._registrationDispositions = null;
|
||||
}
|
||||
if (droppedQaLayers.length)
|
||||
console.warn(
|
||||
`[Data] QA layers do not survive a scene switch; register them again: ${droppedQaLayers.join(', ')}`,
|
||||
);
|
||||
if (errors.length) {
|
||||
for (const error of errors)
|
||||
console.warn('[Data] scene detach error:', error);
|
||||
throw new AggregateError(errors, 'Data layers could not detach');
|
||||
}
|
||||
}
|
||||
|
||||
/** Release one retained layer's renderer, keeping a failure for a retry. */
|
||||
async _detachEntryRenderer(entry, viewer) {
|
||||
const pending = entry.pendingSceneDetach;
|
||||
if (!pending && (!entry.initialized || entry.sceneDetached)) return;
|
||||
const target = pending ? pending.viewer : viewer;
|
||||
entry.pendingSceneDetach = { viewer: target };
|
||||
await entry.module.detachRenderer(target);
|
||||
entry.pendingSceneDetach = null;
|
||||
entry.sceneDetached = true;
|
||||
}
|
||||
|
||||
/** Bind the next catalog only after the preceding scene released its entries. */
|
||||
bindCatalog(viewer, engine, catalog, options = {}) {
|
||||
if (this._registrationsFinalized) throw new Error('Scene already attached');
|
||||
this.viewer = viewer;
|
||||
this.engine = engine ?? null;
|
||||
this._catalogSourceAvailability = catalog.getSourceAvailability;
|
||||
const scene = new AbortController();
|
||||
this._sceneController = scene;
|
||||
// An attachment that is still running when the scene detaches sees this
|
||||
// signal aborted and must stop touching the scene.
|
||||
const context = { ...this._moduleContext(scene.signal), ...options };
|
||||
const 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) {
|
||||
attachments.push(this._attachEntryRenderer(entry, viewer, context));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Retained entries may appear anywhere in the directory. Preserve both
|
||||
// catalog order (panel/serialization) and each live entry's identity.
|
||||
for (const { id } of catalog.layers) {
|
||||
const entry = this.layers.get(id);
|
||||
this.layers.delete(id);
|
||||
this.layers.set(id, entry);
|
||||
}
|
||||
this.finalizeRegistrations(catalog.metadata);
|
||||
const pending = attachments.filter((ready) => ready?.then);
|
||||
// The scene counts as bound for refreshes and parked enables once its
|
||||
// attachments settled (or the wait for them timed out).
|
||||
const bound = () => {
|
||||
if (this._sceneController !== scene) return;
|
||||
this._sceneDetached = false;
|
||||
this._runDeferredRefreshes();
|
||||
this._settleSceneWaiters(true);
|
||||
};
|
||||
if (!pending.length) {
|
||||
bound();
|
||||
return undefined;
|
||||
}
|
||||
return this._awaitAttachments(pending, 'bind').then((settled) => {
|
||||
bound();
|
||||
const failure = settled?.find(({ status }) => status === 'rejected');
|
||||
if (failure) throw failure.reason;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for attachments to settle, at most attachSettleTimeoutMs. Returns
|
||||
* their allSettled results, or null when the wait timed out.
|
||||
*/
|
||||
async _awaitAttachments(attachments, phase) {
|
||||
if (!attachments.length) return [];
|
||||
let timer;
|
||||
const timedOut = new Promise((resolve) => {
|
||||
timer = setTimeout(resolve, this._attachSettleTimeoutMs, null);
|
||||
});
|
||||
const settled = await Promise.race([
|
||||
Promise.allSettled(attachments),
|
||||
timedOut,
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
if (!settled)
|
||||
console.warn(
|
||||
`[Data] layer attachments still running after ${this._attachSettleTimeoutMs} ms; scene ${phase} continues without them`,
|
||||
);
|
||||
return settled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until a scene is bound. Resolves true then, or false when the
|
||||
* signal aborts or the entry is destroyed. `refresh` marks an explicit
|
||||
* refresh, which replaces the entry's deferred periodic one.
|
||||
*/
|
||||
_waitForScene(entry, signal = null, { refresh = false } = {}) {
|
||||
if (!this._sceneDetached) return Promise.resolve(true);
|
||||
if (signal?.aborted) return Promise.resolve(false);
|
||||
return new Promise((resolve) => {
|
||||
const onAbort = () => waiter.settle(false);
|
||||
const waiter = {
|
||||
entry,
|
||||
refresh,
|
||||
settle: (bound) => {
|
||||
if (!this._sceneWaiters.delete(waiter)) return;
|
||||
if (refresh) entry.sceneRefreshWaiters -= 1;
|
||||
signal?.removeEventListener?.('abort', onAbort);
|
||||
resolve(bound);
|
||||
},
|
||||
};
|
||||
if (refresh) entry.sceneRefreshWaiters += 1;
|
||||
this._sceneWaiters.add(waiter);
|
||||
signal?.addEventListener?.('abort', onAbort, { once: true });
|
||||
for (const listener of [...this._parkListeners]) listener(entry);
|
||||
});
|
||||
}
|
||||
|
||||
_settleSceneWaiters(bound, entry = null) {
|
||||
for (const waiter of [...this._sceneWaiters])
|
||||
if (!entry || waiter.entry === entry) waiter.settle(bound);
|
||||
}
|
||||
|
||||
/** The entry's queued lifecycle work settled, or is parked for a scene. */
|
||||
_lifecycleIdleOrParked(entry) {
|
||||
const parked = () =>
|
||||
[...this._sceneWaiters].some(
|
||||
(waiter) => waiter.entry === entry && !waiter.refresh,
|
||||
);
|
||||
if (parked()) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
const done = () => {
|
||||
this._parkListeners.delete(listener);
|
||||
resolve();
|
||||
};
|
||||
const listener = (parkedEntry) => {
|
||||
if (parkedEntry === entry && parked()) done();
|
||||
};
|
||||
this._parkListeners.add(listener);
|
||||
entry.toggleChain.catch(() => {}).then(done);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach one retained layer to the bound scene. A release that failed on
|
||||
* the previous scene is retried first. Asynchronous attachments are tracked
|
||||
* so detachScene() releases them only after they stopped.
|
||||
*/
|
||||
_attachEntryRenderer(entry, viewer, context) {
|
||||
let ready;
|
||||
try {
|
||||
const pending = entry.pendingSceneDetach;
|
||||
entry.sceneDetached = false;
|
||||
if (pending) {
|
||||
ready = Promise.resolve()
|
||||
.then(() => entry.module.detachRenderer(pending.viewer))
|
||||
.then(() => {
|
||||
entry.pendingSceneDetach = null;
|
||||
if (context.signal.aborted) return undefined;
|
||||
return entry.module.attachRenderer(viewer, context);
|
||||
});
|
||||
} else ready = entry.module.attachRenderer(viewer, context);
|
||||
} catch (error) {
|
||||
ready = Promise.reject(error);
|
||||
}
|
||||
if (!ready?.then) return ready;
|
||||
const tracked = Promise.resolve(ready);
|
||||
this._pendingAttachments.add(tracked);
|
||||
const untrack = () => this._pendingAttachments.delete(tracked);
|
||||
tracked.then(untrack, untrack);
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/** Run the periodic refreshes that came due while no scene was bound. */
|
||||
_runDeferredRefreshes() {
|
||||
for (const [layerId, entry] of this.layers) {
|
||||
if (!entry.refreshDeferred) continue;
|
||||
entry.refreshDeferred = false;
|
||||
// An explicit refresh waiting for this scene runs instead.
|
||||
if (entry.sceneRefreshWaiters > 0) continue;
|
||||
void this._runPeriodicUpdate(layerId, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/** Register a synthetic layer after sealing in an explicitly dev-enabled manager. */
|
||||
registerForQa(layerModule) {
|
||||
if (!this._allowQaRegistration || !this._registrationsFinalized) {
|
||||
@@ -456,7 +209,6 @@ export class LayerLifecycle {
|
||||
// renderer is released, a release that failed and is retried, and a
|
||||
// periodic refresh that came due while no scene was bound.
|
||||
sceneDetached: false,
|
||||
pendingSceneDetach: null,
|
||||
refreshDeferred: false,
|
||||
sceneRefreshWaiters: 0,
|
||||
});
|
||||
|
||||
+15
-123
@@ -4,14 +4,10 @@ import {
|
||||
} from './standalone/application.js';
|
||||
import { createRendererCache } from './standalone/rendererCache.js';
|
||||
import { describeError } from './standalone/errors.js';
|
||||
import {
|
||||
createEngineAddress,
|
||||
createEngineSwitch,
|
||||
handOffSession,
|
||||
resumeSession,
|
||||
waitForFirstFrame,
|
||||
} from './standalone/engineSwitch.js';
|
||||
import { createEngineAddress } from './standalone/engineSwitch.js';
|
||||
import { ENGINE_STORAGE_KEY, engineOfChoice } from './app/engineChoice.js';
|
||||
import { createSceneSwitcher } from './standalone/sceneSwitcher.js';
|
||||
import { createLayerSession } from './app/layerSession.js';
|
||||
import { createSessionDiagnostics } from './app/sessionDiagnostics.js';
|
||||
import { useDisplayStore } from './ui/stores/display.js';
|
||||
import { applicationPinia } from './ui/vueRuntime.js';
|
||||
@@ -31,8 +27,6 @@ const display = useDisplayStore(applicationPinia());
|
||||
const loading = document.getElementById('loading-screen');
|
||||
let application;
|
||||
let engineSwitch = null;
|
||||
let cover = null;
|
||||
let cockpit = null;
|
||||
// ?parkedMs= shortens how long a parked globe waits before it is released,
|
||||
// so a QA scenario can switch back to a released one.
|
||||
const renderers = createRendererCache({
|
||||
@@ -41,7 +35,7 @@ const renderers = createRendererCache({
|
||||
parkedMs:
|
||||
Number(new URLSearchParams(location.search).get('parkedMs')) || undefined,
|
||||
});
|
||||
const layerSession = { lifetime: new AbortController() };
|
||||
const layerSession = createLayerSession();
|
||||
const { signal } = layerSession.lifetime;
|
||||
// Which session objects this page holds now (scripts/qa-app-once.mjs).
|
||||
const session = createSessionDiagnostics(() => {
|
||||
@@ -113,115 +107,6 @@ function liveComponents() {
|
||||
// The page's application is created once; a switch detaches and attaches
|
||||
// only its engine-bound parts.
|
||||
|
||||
// Hand the session off and release the running engine. Its renderer is
|
||||
// parked for a later switch back.
|
||||
async function leave(from) {
|
||||
const components = liveComponents();
|
||||
const attached = Boolean(components);
|
||||
if (attached) {
|
||||
cover ??= keepGlobeFrame(components.scene.engine);
|
||||
cockpit = components.controls.styleManager.getCockpitState();
|
||||
handOffSession(layerSession, {
|
||||
addressLayerIds:
|
||||
components.controls.styleManager.shareLinkManager?.currentLayerState?.()
|
||||
?.enabledLayerIds,
|
||||
});
|
||||
renderers.retain(from);
|
||||
}
|
||||
if (attached) await application.detachScene({ handoff: true });
|
||||
session.count('detaches');
|
||||
}
|
||||
|
||||
// Start an engine on the retained session. Success means the engine runs
|
||||
// with the session attached and the address's view restored; its first frame
|
||||
// only decides when the cover goes (settle).
|
||||
async function enter(kind, { recovery = false } = {}) {
|
||||
if (!application || application.getState().status !== 'ready') {
|
||||
// The first engine never started: there is no application to attach to.
|
||||
return enterNewApplication(kind);
|
||||
}
|
||||
layerSession.restoringView = true;
|
||||
try {
|
||||
await application.attachScene(kind);
|
||||
signal.throwIfAborted();
|
||||
await resume(application.getComponents().controls.styleManager);
|
||||
if (recovery) session.count('rollbacks');
|
||||
} catch (error) {
|
||||
// pagehide owns the teardown once the lifetime is aborted. A failed
|
||||
// candidate is detached; the session stays for the working engine.
|
||||
if (!signal.aborted)
|
||||
await application
|
||||
.detachScene({ handoff: true })
|
||||
.catch((cleanupError) =>
|
||||
console.error('A failed globe could not be released:', cleanupError),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// The session continues on the engine that attached: the view is restored,
|
||||
// parked work resumes and Cockpit re-enters on the aircraft it had.
|
||||
async function resume(style) {
|
||||
await style.initialRestorePromise;
|
||||
signal.throwIfAborted();
|
||||
layerSession.restoringView = false;
|
||||
session.count('attachments');
|
||||
resumeSession(layerSession);
|
||||
// Only resume the aircraft that actually survived the handoff. A history
|
||||
// entry may select a different subject, and a stale track may disappear.
|
||||
const target = style.getAircraftTrackingTarget();
|
||||
if (
|
||||
cockpit?.active &&
|
||||
target &&
|
||||
target?.id === cockpit.subject?.id &&
|
||||
target?.layerId === cockpit.subject?.layerId &&
|
||||
style.controlCockpit('enter').ok
|
||||
) {
|
||||
style.cockpitView.setVisionMode(cockpit.visionMode);
|
||||
}
|
||||
}
|
||||
|
||||
// A new application on the retained session, for a page whose first engine
|
||||
// never started.
|
||||
async function enterNewApplication(kind) {
|
||||
const app = createApplication(kind, true);
|
||||
try {
|
||||
const { controls } = await app.start();
|
||||
signal.throwIfAborted();
|
||||
await resume(controls.styleManager);
|
||||
} catch (error) {
|
||||
// pagehide owns the teardown once the lifetime is aborted.
|
||||
if (!signal.aborted)
|
||||
await app
|
||||
.destroy()
|
||||
.catch((cleanupError) =>
|
||||
console.error('A failed globe could not be released:', cleanupError),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
const components = liveComponents();
|
||||
try {
|
||||
if (cover && components) await waitForFirstFrame(components.scene.engine);
|
||||
} finally {
|
||||
cover?.remove();
|
||||
cover = null;
|
||||
}
|
||||
// A page left without an engine keeps the last handoff for the next try.
|
||||
if (!components) return;
|
||||
layerSession.handoffLayers = null;
|
||||
cockpit = null;
|
||||
withdrawEngineRetry();
|
||||
// An engine runs again after a failed recovery showed its error: the
|
||||
// application, built once, does not start its loading chrome again.
|
||||
loading.classList.add('hidden');
|
||||
// The running engine owns the address again, including an entry visited
|
||||
// before its share link listened or while it was held.
|
||||
components.controls.styleManager.shareLinkManager?.resumeNavigation?.();
|
||||
}
|
||||
|
||||
// Voice calls made while the engines switch wait for the next engine
|
||||
// (src/voice/sceneRunner.js); a settled switch lets them look again.
|
||||
layerSession.switching = () => engineSwitch?.state().busy === true;
|
||||
@@ -296,13 +181,20 @@ createApplication(initialKind, false)
|
||||
.start()
|
||||
.then(() => {
|
||||
session.count('attachments');
|
||||
engineSwitch = createEngineSwitch({
|
||||
engineSwitch = createSceneSwitcher({
|
||||
kind: initialKind,
|
||||
requested: pageEngineKind,
|
||||
signal,
|
||||
leave,
|
||||
enter,
|
||||
settle,
|
||||
getApplication: () => application,
|
||||
startApplication: (kind) => createApplication(kind, true),
|
||||
layerSession,
|
||||
renderers,
|
||||
diagnostics: session,
|
||||
keepFrame: keepGlobeFrame,
|
||||
onSettled() {
|
||||
withdrawEngineRetry();
|
||||
loading.classList.add('hidden');
|
||||
},
|
||||
address: createEngineAddress({
|
||||
location,
|
||||
history,
|
||||
|
||||
+19
-17
@@ -73,33 +73,31 @@ const STORAGE_CHECKPOINT_KEY = 'godsEyeView.sceneProject.checkpoint.v1';
|
||||
* Orchestrates deterministic cinematic scene playback.
|
||||
*
|
||||
* The director owns a mutable project (persisted in localStorage) containing
|
||||
* scenes and shots. It drives camera flights via Cesium, applies visual/style
|
||||
* state through the styleManager, toggles data layers via the dataManager, and
|
||||
* scenes and shots. It drives camera flights through the globe engine, applies
|
||||
* visual state through the styleManager, toggles data layers via the dataManager, and
|
||||
* records timestamped telemetry events during each run for later export.
|
||||
*/
|
||||
export class SceneDirector {
|
||||
/**
|
||||
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
|
||||
* @param {{ engine: import('../globe/contract.js').GlobeEngine, viewer?: object }} scene
|
||||
* @param {Object} styleManager - Controls visual state (bloom, sharpen, HUD, detection, style presets)
|
||||
* @param {Object} dataManager - Manages data layer enable/disable and per-layer params
|
||||
* @param {Object} [options] `engine`: the globe engine whose camera the
|
||||
* scene flights drive; the viewer still serves tracking, interactions and
|
||||
* data packs.
|
||||
* @param {Object} [options] Scene packs, data sources and map availability.
|
||||
*/
|
||||
constructor(
|
||||
viewer,
|
||||
scene,
|
||||
styleManager,
|
||||
dataManager,
|
||||
{
|
||||
engine = null,
|
||||
isMapStackAvailable = () => false,
|
||||
scenePacks = createDefaultScenePacks(),
|
||||
dataPacks = {},
|
||||
} = {},
|
||||
) {
|
||||
const { engine } = scene;
|
||||
this._scene = scene;
|
||||
this._destroyed = false;
|
||||
this._pendingWork = new Set();
|
||||
this.viewer = viewer;
|
||||
this.engine = engine;
|
||||
this.styleManager = styleManager;
|
||||
this.dataManager = dataManager;
|
||||
@@ -108,7 +106,7 @@ export class SceneDirector {
|
||||
this._bundleAssets = createBundleAssets();
|
||||
this._dataPacks = createSceneDataPacks(engine, {
|
||||
...dataPacks,
|
||||
container: engine?.host?.container ?? viewer?.container,
|
||||
container: engine?.host?.container,
|
||||
sources: {
|
||||
...dataPacks.sources,
|
||||
[BUNDLE_SOURCE]: this._bundleAssets.source,
|
||||
@@ -119,7 +117,9 @@ export class SceneDirector {
|
||||
this._interactions = createSceneInteractions({
|
||||
engine,
|
||||
available: () =>
|
||||
!this._destroyed && !this._running && !this.viewer?.trackedEntity,
|
||||
!this._destroyed &&
|
||||
!this._running &&
|
||||
!this._scene?.viewer?.trackedEntity,
|
||||
execute: (action, signal) =>
|
||||
this._trackWork(this._executeInteraction(action, signal)),
|
||||
});
|
||||
@@ -235,11 +235,11 @@ export class SceneDirector {
|
||||
}
|
||||
|
||||
_bindCameraInput() {
|
||||
const { viewer, engine, styleManager } = this;
|
||||
const { engine, styleManager } = this;
|
||||
this._cameraHandoffUnsubscribe = styleManager.subscribeCameraHandoff?.(
|
||||
this._yieldCamera,
|
||||
);
|
||||
const canvas = engine?.host.canvas ?? viewer?.scene?.canvas;
|
||||
const canvas = engine?.host.canvas;
|
||||
for (const event of ['pointerdown', 'wheel'])
|
||||
canvas?.addEventListener?.(event, this._yieldCamera, { passive: true });
|
||||
this._removeCameraInput = () => {
|
||||
@@ -258,11 +258,13 @@ export class SceneDirector {
|
||||
this._interactions.setScene(null);
|
||||
this._dataPacks.setScene(null);
|
||||
this._flight?.suspend();
|
||||
this.engine = this.viewer = null;
|
||||
this._scene = null;
|
||||
this.engine = null;
|
||||
}
|
||||
|
||||
attachScene(viewer, engine, styleManager, isAvailable) {
|
||||
this.viewer = viewer;
|
||||
attachScene(scene, styleManager, isAvailable) {
|
||||
const { engine } = scene;
|
||||
this._scene = scene;
|
||||
this.engine = engine;
|
||||
this.styleManager = styleManager;
|
||||
this._isMapStackAvailable = isAvailable;
|
||||
@@ -327,7 +329,7 @@ export class SceneDirector {
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Stop playback and pending loads before releasing their viewer. */
|
||||
/** Stop playback and pending loads before releasing their engine. */
|
||||
destroy() {
|
||||
if (this._destroyPromise) return this._destroyPromise;
|
||||
this._destroyed = true;
|
||||
|
||||
@@ -20,6 +20,8 @@ import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { SceneDirector } from './director.js';
|
||||
import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js';
|
||||
import { SCENE_RECIPES, getSceneAppendRecipeById } from './recipes.js';
|
||||
import { GROUND_HARNESSES } from '../testSupport/groundDisplayEngines.mjs';
|
||||
import { useSceneInteractionsStore } from '../ui/stores/sceneInteractions.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
|
||||
const NEPAL_ORIGINAL_SHOT_TITLES = [
|
||||
@@ -257,7 +259,7 @@ test('same-shot seeking restores a layer whose superseded LOAD transition rolls
|
||||
update() {},
|
||||
});
|
||||
}
|
||||
const realDirector = new SceneDirector(viewer, styleManager, lifecycle, { engine: engineFor(viewer) });
|
||||
const realDirector = new SceneDirector({ engine: engineFor(viewer), viewer }, styleManager, lifecycle);
|
||||
try {
|
||||
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||
await dataManager.setEnabled('flights', false);
|
||||
@@ -371,7 +373,7 @@ test('same-shot seeking aborts in-flight admission when superseded by an early-r
|
||||
disable() {},
|
||||
update() {},
|
||||
});
|
||||
const director = new SceneDirector(viewer, styleManager, lifecycle, { engine: engineFor(viewer) });
|
||||
const director = new SceneDirector({ engine: engineFor(viewer), viewer }, styleManager, lifecycle);
|
||||
try {
|
||||
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||
assert.equal(await director.seekScene('scene-1', 0.1), true);
|
||||
@@ -449,7 +451,7 @@ test('a rejected loadShot does not cancel an in-flight LOAD', async () => {
|
||||
disable() {},
|
||||
update() {},
|
||||
});
|
||||
const director = new SceneDirector(viewer, styleManager, lifecycle, { engine: engineFor(viewer) });
|
||||
const director = new SceneDirector({ engine: engineFor(viewer), viewer }, styleManager, lifecycle);
|
||||
try {
|
||||
const load = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||
for (
|
||||
@@ -1113,8 +1115,7 @@ function makeDirector(options = {}) {
|
||||
const viewer = fakeViewer();
|
||||
const styleManager = fakeStyleManager(options.style);
|
||||
const dataManager = fakeDataManager(options.data);
|
||||
const director = new SceneDirector(viewer, styleManager, dataManager, {
|
||||
engine: engineFor(viewer),
|
||||
const director = new SceneDirector({ engine: engineFor(viewer), viewer }, styleManager, dataManager, {
|
||||
isMapStackAvailable: options.isMapStackAvailable,
|
||||
});
|
||||
// Telemetry is only accumulated during a run; observable-failure assertions
|
||||
@@ -1790,7 +1791,7 @@ test('destroy drains cancelled LOAD work before its viewer can be discarded', as
|
||||
let finishVisual;
|
||||
const dataManager = fakeDataManager();
|
||||
const viewer = { camera: { cancelFlight() {} } };
|
||||
const director = new SceneDirector(viewer, {
|
||||
const director = new SceneDirector({ engine: engineFor(viewer), viewer }, {
|
||||
applyVisualState: () => new Promise((resolve) => { finishVisual = resolve; }),
|
||||
}, dataManager);
|
||||
const loading = director.loadShot('scene-1', 'shot-a');
|
||||
@@ -1981,7 +1982,7 @@ test('scene placement, flights and stop run on a globe engine without a viewer',
|
||||
const restore = installSceneRuntime();
|
||||
try {
|
||||
const engine = createFakeEngine();
|
||||
const director = new SceneDirector(null, fakeStyleManager(), fakeDataManager(), { engine });
|
||||
const director = new SceneDirector({ engine }, fakeStyleManager(), fakeDataManager());
|
||||
const pose = { lat: 27.95, lon: 85.95, alt: 4200, heading: 40, pitch: -25, roll: 0 };
|
||||
assert.equal(director._setCameraView(pose), true);
|
||||
const placed = engine.camera.pose().cartographic;
|
||||
@@ -2087,8 +2088,7 @@ test('a failed scene-pack rebind releases the partial renderer and a stopped sho
|
||||
assert.throws(
|
||||
() =>
|
||||
director.attachScene(
|
||||
{},
|
||||
{ host: { container: {} } },
|
||||
{ engine: { host: { container: {} } } },
|
||||
{},
|
||||
() => true,
|
||||
),
|
||||
@@ -2105,3 +2105,37 @@ test('a failed scene-pack rebind releases the partial renderer and a stopped sho
|
||||
director.resumeScene();
|
||||
assert.equal(await waiting, true);
|
||||
});
|
||||
|
||||
|
||||
for (const createHarness of GROUND_HARNESSES) {
|
||||
test(`Director retains the existing tracking interaction policy on ${createHarness.name}`, async () => {
|
||||
const restore = installSceneRuntime();
|
||||
const harness = createHarness();
|
||||
const { engine } = harness;
|
||||
const director = new SceneDirector({ engine, viewer: harness.viewer }, fakeStyleManager(), fakeDataManager());
|
||||
const store = useSceneInteractionsStore(applicationPinia());
|
||||
const target = { packId: 'test', featureId: 'point' };
|
||||
const shot = { interactions: [{ id: 'card', label: 'Read', target, action: { type: 'card', text: 'Details' } }] };
|
||||
try {
|
||||
director._interactions.activate(shot, new Map([[JSON.stringify(['test', 'point']), 'point']]));
|
||||
const follow = engine.camera.follow({ position: () => ({ x: 6378137, y: 0, z: 1000 }), viewFrom: { x: 0, y: -4800, z: 3300 } });
|
||||
store.controls.dispatch('card', store.panel.owner);
|
||||
await settle();
|
||||
if (harness.cesium) assert.equal(store.panel.card, null, 'tracked subjects keep camera ownership');
|
||||
else assert.equal(store.panel.card.text, 'Details', 'the engine without a legacy tracked subject keeps its interaction policy');
|
||||
follow.stop();
|
||||
store.controls.dispatch('card', store.panel.owner);
|
||||
await settle();
|
||||
assert.equal(store.panel.card.text, 'Details');
|
||||
director.suspendScene();
|
||||
assert.equal(director.engine, null);
|
||||
director.attachScene({ engine, viewer: harness.viewer }, director.styleManager, () => true);
|
||||
director.resumeScene();
|
||||
assert.equal(director.engine, engine);
|
||||
} finally {
|
||||
await director.destroy();
|
||||
harness.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { retainsLayerSession } from '../app/layerSession.js';
|
||||
import { createStandaloneCatalog } from './catalog.js';
|
||||
import { createStandalonePlaceSearch } from './placeSearch.js';
|
||||
import { CITY_POIS } from '../cityPois.js';
|
||||
@@ -94,7 +95,9 @@ export function createStandaloneApplication({
|
||||
async function openScene(kind) {
|
||||
const controller = new AbortController();
|
||||
const releases = [];
|
||||
const release = async () => {
|
||||
let releasing;
|
||||
const release = () => (releasing ??= releaseResources());
|
||||
const releaseResources = async () => {
|
||||
// A reason made here would keep this call's stack, and with it the
|
||||
// released scene, alive in every closure that still holds `signal`.
|
||||
controller.abort(SCENE_RELEASED);
|
||||
@@ -156,6 +159,7 @@ export function createStandaloneApplication({
|
||||
});
|
||||
return Object.freeze({
|
||||
...scene,
|
||||
signal,
|
||||
kind,
|
||||
catalog: sceneCatalog,
|
||||
release,
|
||||
@@ -254,10 +258,7 @@ export function createStandaloneApplication({
|
||||
// the globe's renderer is parked or released.
|
||||
context.defer(async () => {
|
||||
await sceneHost.detach({
|
||||
handoff: Boolean(
|
||||
layerSession?.handoffLayers &&
|
||||
!layerSession.lifetime.signal.aborted,
|
||||
),
|
||||
handoff: retainsLayerSession(layerSession),
|
||||
});
|
||||
const scene = current;
|
||||
current = null;
|
||||
@@ -332,14 +333,30 @@ export function createStandaloneApplication({
|
||||
* detaches what attached and releases the candidate; the session stays as
|
||||
* it was, ready for the working engine to attach again.
|
||||
*/
|
||||
async function attachScene(kind) {
|
||||
async function attachScene(kind, { resume } = {}) {
|
||||
if (application.getState().status !== 'ready')
|
||||
throw new Error('The application is not running');
|
||||
if (current) throw new Error('A globe is attached; detach it first');
|
||||
const next = await openScene(kind);
|
||||
try {
|
||||
await sceneHost.attach(next);
|
||||
current = next;
|
||||
await resume?.();
|
||||
lifetime.throwIfAborted();
|
||||
} catch (error) {
|
||||
// attach() releases partial parts itself; a failed resume leaves a
|
||||
// complete attachment for this transaction to detach.
|
||||
if (current === next) {
|
||||
current = null;
|
||||
await sceneHost
|
||||
.detach({ handoff: !lifetime.aborted })
|
||||
.catch((cleanupError) =>
|
||||
console.error(
|
||||
'A failed globe could not be released:',
|
||||
cleanupError,
|
||||
),
|
||||
);
|
||||
}
|
||||
await next
|
||||
.release()
|
||||
.catch((cleanupError) =>
|
||||
@@ -347,7 +364,6 @@ export function createStandaloneApplication({
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
current = next;
|
||||
return sceneView;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { retainsLayerSession } from '../app/layerSession.js';
|
||||
import { createSceneHost } from '../app/sceneHost.js';
|
||||
import { attachScenePart } from '../app/sceneParts.js';
|
||||
|
||||
@@ -50,6 +51,7 @@ function standalone({ failOpen = null } = {}) {
|
||||
return { viewer: null, engine, operations: { kind: 'operations' } };
|
||||
},
|
||||
createSceneHost,
|
||||
retainsLayerSession,
|
||||
attachScenePart,
|
||||
AbortController,
|
||||
AbortSignal,
|
||||
@@ -111,3 +113,52 @@ test('a failed candidate releases what it attached and the working engine attach
|
||||
);
|
||||
assert.equal(opened.length, 3);
|
||||
});
|
||||
|
||||
test('a failed resume detaches and releases its candidate once before recovery', async () => {
|
||||
const { app, opened, released } = standalone();
|
||||
const scene = await app.createScene({ signal: new AbortController().signal, defer() {} });
|
||||
const detached = [];
|
||||
await scene.sceneHost.register({
|
||||
name: 'part',
|
||||
attachScene(next) { this.kind = next.kind; },
|
||||
detachScene(options) { detached.push([this.kind, options.handoff]); },
|
||||
});
|
||||
await app.detachScene({ handoff: true });
|
||||
await assert.rejects(app.attachScene('navara', {
|
||||
resume() {
|
||||
assert.equal(scene.engine, opened[1], 'resume sees the attached candidate');
|
||||
throw new Error('restore failed');
|
||||
},
|
||||
}), /restore failed/);
|
||||
assert.equal(scene.engine, null);
|
||||
assert.equal(scene.sceneHost.state(), 'detached');
|
||||
assert.deepEqual(detached, [['cesium', true], ['navara', true]]);
|
||||
assert.deepEqual(released, opened);
|
||||
await app.attachScene('cesium');
|
||||
assert.equal(scene.engine, opened[2]);
|
||||
assert.equal(released.length, 2);
|
||||
});
|
||||
|
||||
test('page teardown during resume releases the candidate only once', async () => {
|
||||
const { app, opened, released } = standalone();
|
||||
const lifetime = new AbortController();
|
||||
const scene = await app.createScene({ signal: lifetime.signal, defer() {} });
|
||||
await app.detachScene({ handoff: true });
|
||||
let finishResume;
|
||||
let enteredResume;
|
||||
const entered = new Promise((resolve) => { enteredResume = resolve; });
|
||||
const pending = app.attachScene('navara', {
|
||||
resume: () => {
|
||||
enteredResume();
|
||||
return new Promise((resolve) => { finishResume = resolve; });
|
||||
},
|
||||
});
|
||||
const rejected = assert.rejects(pending, { name: 'AbortError' });
|
||||
await entered;
|
||||
lifetime.abort();
|
||||
const leaving = app.detachScene({ handoff: false });
|
||||
finishResume();
|
||||
await Promise.all([leaving, rejected]);
|
||||
assert.equal(scene.engine, null);
|
||||
assert.deepEqual(released, opened);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
createEngineSwitch,
|
||||
handOffSession,
|
||||
resumeSession,
|
||||
waitForFirstFrame,
|
||||
} from './engineSwitch.js';
|
||||
|
||||
/**
|
||||
* One scene-switch transaction: retain the session, detach and park the
|
||||
* outgoing globe, attach and resume the candidate, then uncover its frame.
|
||||
* The request coordinator owns ordering and the single rollback. The
|
||||
* application owns candidate cleanup, including a failure during resume.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {import('../app/layerSession.js').LayerSession} options.layerSession
|
||||
*/
|
||||
export function createSceneSwitcher({
|
||||
getApplication,
|
||||
startApplication,
|
||||
layerSession,
|
||||
renderers,
|
||||
diagnostics,
|
||||
keepFrame,
|
||||
onSettled,
|
||||
...options
|
||||
}) {
|
||||
const { signal } = layerSession.lifetime;
|
||||
let cover = null;
|
||||
let cockpit = null;
|
||||
const count = (name) => diagnostics?.count(name);
|
||||
const liveComponents = () => {
|
||||
const app = getApplication();
|
||||
if (app?.getState().status !== 'ready') return null;
|
||||
const components = app.getComponents();
|
||||
return components.scene?.engine ? components : null;
|
||||
};
|
||||
|
||||
async function leave(from) {
|
||||
const components = liveComponents();
|
||||
if (components) {
|
||||
cover ??= keepFrame?.(components.scene.engine);
|
||||
const style = components.controls.styleManager;
|
||||
cockpit = style.getCockpitState();
|
||||
handOffSession(layerSession, {
|
||||
addressLayerIds:
|
||||
style.shareLinkManager?.currentLayerState?.()?.enabledLayerIds,
|
||||
});
|
||||
renderers.retain(from);
|
||||
await getApplication().detachScene({ handoff: true });
|
||||
}
|
||||
count('detaches');
|
||||
}
|
||||
|
||||
async function resume(style) {
|
||||
signal.throwIfAborted();
|
||||
await style.initialRestorePromise;
|
||||
signal.throwIfAborted();
|
||||
layerSession.restoringView = false;
|
||||
count('attachments');
|
||||
resumeSession(layerSession);
|
||||
// A visited entry or a stale track may replace the outgoing subject.
|
||||
const target = style.getAircraftTrackingTarget();
|
||||
if (
|
||||
cockpit?.active &&
|
||||
target &&
|
||||
target.id === cockpit.subject?.id &&
|
||||
target.layerId === cockpit.subject?.layerId &&
|
||||
style.controlCockpit('enter').ok
|
||||
) {
|
||||
style.cockpitView.setVisionMode(cockpit.visionMode);
|
||||
}
|
||||
}
|
||||
|
||||
async function enter(kind, { recovery = false } = {}) {
|
||||
layerSession.restoringView = true;
|
||||
let app = getApplication();
|
||||
if (!app || app.getState().status !== 'ready') {
|
||||
app = startApplication(kind);
|
||||
try {
|
||||
const { controls } = await app.start();
|
||||
await resume(controls.styleManager);
|
||||
} catch (error) {
|
||||
if (!signal.aborted)
|
||||
await app
|
||||
.destroy()
|
||||
.catch((cleanupError) =>
|
||||
console.error(
|
||||
'A failed globe could not be released:',
|
||||
cleanupError,
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
await app.attachScene(kind, {
|
||||
resume: () => resume(app.getComponents().controls.styleManager),
|
||||
});
|
||||
}
|
||||
if (recovery) count('rollbacks');
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
const components = liveComponents();
|
||||
try {
|
||||
if (cover && components) await waitForFirstFrame(components.scene.engine);
|
||||
} finally {
|
||||
cover?.remove();
|
||||
cover = null;
|
||||
}
|
||||
if (!components) return;
|
||||
layerSession.handoffLayers = null;
|
||||
cockpit = null;
|
||||
onSettled?.();
|
||||
components.controls.styleManager.shareLinkManager?.resumeNavigation?.();
|
||||
}
|
||||
|
||||
return createEngineSwitch({ ...options, signal, leave, enter, settle });
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createSceneSwitcher } from './sceneSwitcher.js';
|
||||
import { createLayerSession } from '../app/layerSession.js';
|
||||
|
||||
function fixture({ failResume = false, changeTarget = false } = {}) {
|
||||
const session = createLayerSession();
|
||||
const log = [];
|
||||
let current = 'first';
|
||||
let address = 'first';
|
||||
const target = { id: 'track', layerId: 'flights' };
|
||||
const style = {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
getCockpitState: () => ({
|
||||
active: true,
|
||||
subject: target,
|
||||
visionMode: 'ir',
|
||||
}),
|
||||
getAircraftTrackingTarget: () =>
|
||||
changeTarget ? { ...target, id: 'other' } : target,
|
||||
controlCockpit: () => {
|
||||
log.push('cockpit');
|
||||
return { ok: true };
|
||||
},
|
||||
cockpitView: { setVisionMode: (mode) => log.push(mode) },
|
||||
shareLinkManager: {
|
||||
currentLayerState: () => ({ enabledLayerIds: ['flights'] }),
|
||||
resumeNavigation: () => log.push('navigation'),
|
||||
},
|
||||
};
|
||||
const entry = {
|
||||
module: {
|
||||
attachRenderer() {},
|
||||
resumeViewport() {
|
||||
log.push('viewport');
|
||||
},
|
||||
},
|
||||
};
|
||||
session.manager = {
|
||||
layers: new Map([['flights', entry]]),
|
||||
isEffectivelyEnabled: () => true,
|
||||
};
|
||||
session.director = {
|
||||
suspendScene: () => log.push('suspend-director'),
|
||||
resumeScene: () => {
|
||||
log.push('resume-director');
|
||||
if (failResume && current === 'navara') throw new Error('resume failed');
|
||||
},
|
||||
};
|
||||
const app = {
|
||||
getState: () => ({ status: 'ready' }),
|
||||
getComponents: () => ({
|
||||
scene: { engine: current ? { kind: current } : null },
|
||||
controls: { styleManager: style },
|
||||
}),
|
||||
async detachScene({ handoff }) {
|
||||
log.push(['detach', current, handoff]);
|
||||
current = null;
|
||||
},
|
||||
async attachScene(kind, { resume }) {
|
||||
current = kind;
|
||||
log.push(['attach', kind]);
|
||||
try {
|
||||
await resume();
|
||||
} catch (error) {
|
||||
log.push(['release', kind]);
|
||||
current = null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
const switcher = createSceneSwitcher({
|
||||
kind: 'first',
|
||||
requested: () => address,
|
||||
layerSession: session,
|
||||
getApplication: () => app,
|
||||
renderers: { retain: (kind) => log.push(['park', kind]) },
|
||||
address: {
|
||||
select: () => 'old-entry',
|
||||
commit: (token, kind) => {
|
||||
address = kind;
|
||||
log.push(['commit', token, kind]);
|
||||
},
|
||||
restore: (kind) => {
|
||||
address = kind;
|
||||
},
|
||||
},
|
||||
onSettled: () => log.push('settled'),
|
||||
onFailure: (error, { recovered }) =>
|
||||
log.push(['failure', error.message, recovered]),
|
||||
});
|
||||
return { switcher, session, log, current: () => current };
|
||||
}
|
||||
|
||||
test('a scene switch parks once, resumes the session and cockpit, then returns navigation', async () => {
|
||||
const { switcher, session, log, current } = fixture();
|
||||
await switcher.select('navara');
|
||||
assert.equal(current(), 'navara');
|
||||
assert.equal(session.handoffLayers, null);
|
||||
assert.equal(session.restoringView, false);
|
||||
assert.deepEqual(log, [
|
||||
'suspend-director',
|
||||
['park', 'first'],
|
||||
['detach', 'first', true],
|
||||
['attach', 'navara'],
|
||||
'viewport',
|
||||
'resume-director',
|
||||
'cockpit',
|
||||
'ir',
|
||||
['commit', 'old-entry', 'navara'],
|
||||
'settled',
|
||||
'navigation',
|
||||
]);
|
||||
});
|
||||
|
||||
test('resume failure has one candidate release and one rollback on the retained session', async () => {
|
||||
const { switcher, session, log, current } = fixture({ failResume: true });
|
||||
await switcher.select('navara');
|
||||
assert.equal(current(), 'first');
|
||||
assert.deepEqual(log.filter(Array.isArray), [
|
||||
['park', 'first'],
|
||||
['detach', 'first', true],
|
||||
['attach', 'navara'],
|
||||
['release', 'navara'],
|
||||
['attach', 'first'],
|
||||
['failure', 'resume failed', true],
|
||||
]);
|
||||
assert.equal(session.handoffLayers, null);
|
||||
assert.equal(switcher.state().busy, false);
|
||||
});
|
||||
|
||||
test('a changed tracking target does not resume the outgoing cockpit', async () => {
|
||||
const { switcher, log } = fixture({ changeTarget: true });
|
||||
await switcher.select('navara');
|
||||
assert.equal(log.includes('cockpit'), false);
|
||||
assert.equal(log.includes('ir'), false);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ShellScene } from '../ui/shellScene.js';
|
||||
import { ShellFacade } from '../ui/shellFacade.js';
|
||||
import { AircraftDisplay } from '../ui/aircraftDisplay.js';
|
||||
import { LayerBindings } from '../ui/layerBindings.js';
|
||||
@@ -24,6 +25,7 @@ export function readShellSource() {
|
||||
'layerBindings',
|
||||
'displayBindings',
|
||||
'shellFacade',
|
||||
'shellScene',
|
||||
'applicationShell',
|
||||
]
|
||||
.map((name) =>
|
||||
@@ -45,6 +47,7 @@ export function shellMethod(name) {
|
||||
LayerBindings,
|
||||
DisplayBindings,
|
||||
StyleManager,
|
||||
ShellScene,
|
||||
ShellFacade,
|
||||
]) {
|
||||
const method = Object.getOwnPropertyDescriptor(
|
||||
|
||||
+5
-458
@@ -1,23 +1,15 @@
|
||||
import { retainsLayerSession } from '../app/layerSession.js';
|
||||
import { nextTick } from 'vue';
|
||||
import { resolveImageryHost } from '../layers/weather/imageryHost.js';
|
||||
import { ShellFacade } from './shellFacade.js';
|
||||
import { ShellScene } from './shellScene.js';
|
||||
import { AircraftDisplay } from './aircraftDisplay.js';
|
||||
import { LayerBindings } from './layerBindings.js';
|
||||
import { PanelChrome } from './panelChrome.js';
|
||||
import { initMobileShell } from './mobileShell.js';
|
||||
import { VisualSettings } from './visualSettings.js';
|
||||
import { NavigationController } from './navigationController.js';
|
||||
import { ShareRestoration } from './shareRestoration.js';
|
||||
import { DisplayBindings } from './displayBindings.js';
|
||||
import {
|
||||
applyPendingVisualChoices,
|
||||
pendingVisualSettings,
|
||||
} from './pendingVisualSettings.js';
|
||||
import { createStateChannel } from '../app/stateChannel.js';
|
||||
import { UiLifetime } from './uiLifetime.js';
|
||||
import { RecordingControls } from './recordingControls.js';
|
||||
import { readShellElements } from './shellElements.js';
|
||||
import { CockpitCoordinator } from './cockpitCoordinator.js';
|
||||
import { ContextControls } from './context.js';
|
||||
import { CctvControls } from './cctv.js';
|
||||
import { StreetLevelControls } from './streetLevelControls.js';
|
||||
@@ -40,10 +32,7 @@ import { cesiumHeading, cesiumRoll } from '../cameraAngles.js';
|
||||
import { aircraftTrackingTarget } from '../cockpitTracking.js';
|
||||
|
||||
import { ShellFeedback } from './shellFeedback.js';
|
||||
import {
|
||||
applyShareFollowOffset,
|
||||
readShareFollowSubject,
|
||||
} from './shareFollow.js';
|
||||
import { readShareFollowSubject } from './shareFollow.js';
|
||||
|
||||
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
|
||||
|
||||
@@ -59,7 +48,6 @@ import { PanelNavigation } from './panelNavigation.js';
|
||||
import { applicationPinia, createUiRuntime } from './vueRuntime.js';
|
||||
import { useContextStore } from './stores/context.js';
|
||||
import { useMissionReplayStore } from './stores/missionReplay.js';
|
||||
import { useCelestialRingStore } from './stores/celestialRing.js';
|
||||
import { useWorldOverlayActionsStore } from './stores/worldOverlayActions.js';
|
||||
import { useAwarenessDirectionStore } from './stores/awarenessDirection.js';
|
||||
import { useHudStore } from './stores/hud.js';
|
||||
@@ -89,7 +77,7 @@ import { useRecordingStore } from './stores/recording.js';
|
||||
* - Intel HUD lifecycle and variant switching.
|
||||
*/
|
||||
|
||||
export class StyleManager extends ShellFacade {
|
||||
export class StyleManager extends ShellScene {
|
||||
/**
|
||||
* @param {Cesium.Viewer} viewer - The CesiumJS viewer instance.
|
||||
* @param {object} [options] `engine`: the page's globe engine (the world
|
||||
@@ -438,445 +426,6 @@ export class StyleManager extends ShellFacade {
|
||||
this._layerBindings.observeCamera();
|
||||
}
|
||||
|
||||
// ── Scene attachment ──────────────────────────────────────────────────
|
||||
// The shell lives for the page; an engine switch only rebuilds the parts
|
||||
// bound to one globe (share restoration, visual effects, layer bindings,
|
||||
// Cockpit, the celestial ring, the overlay host, detection, the map-source
|
||||
// and camera-orientation controls) and points the kept ones (navigation,
|
||||
// location search, HUD, share link, display bindings) at the next globe.
|
||||
|
||||
/** Share restoration, visual settings and layer bindings of this globe. */
|
||||
_buildSceneControls() {
|
||||
const { viewer, engine, mapStackController } = this._scene;
|
||||
const services = this.services;
|
||||
const layerSession = this._layerSession;
|
||||
this._shareRestoration = new ShareRestoration({
|
||||
engine,
|
||||
canvas: engine?.host.canvas ?? viewer?.canvas,
|
||||
handoffLayers: layerSession?.handoffLayers,
|
||||
navigation: this._navigation,
|
||||
syncShareState: () => this._syncShareState(),
|
||||
syncModels3d: (state) => this._syncModels3dFromLayerState(state),
|
||||
showStatus: (message, options) =>
|
||||
this._showGlobalStatusNotice(message, options),
|
||||
feedback: this._feedback,
|
||||
updateFeedback: () => this._updateGlobalLoadingFeedback(),
|
||||
applyFollowOffset: (handle, offset) =>
|
||||
applyShareFollowOffset(engine, handle, offset),
|
||||
});
|
||||
|
||||
this._visualSettings = new VisualSettings({
|
||||
viewer,
|
||||
engine,
|
||||
mapStackController,
|
||||
services: {
|
||||
setScopeTerminusOverride: services.setScopeTerminusOverride,
|
||||
clampScopeTerminusPct: services.clampScopeTerminusPct,
|
||||
getDetectionMode: services.getDetectionMode,
|
||||
getDetectionTuning: services.getDetectionTuning,
|
||||
getKeyholeFadeTuning: services.getKeyholeFadeTuning,
|
||||
getScopeMaskFeather: services.getScopeMaskFeather,
|
||||
getScopeTerminusOverride: services.getScopeTerminusOverride,
|
||||
governorRequestRender: services.governorRequestRender,
|
||||
holdContinuousRender: services.holdContinuousRender,
|
||||
isCelestialRingStyleSupported: services.isCelestialRingStyleSupported,
|
||||
isScopeMaskEnabled: services.isScopeMaskEnabled,
|
||||
readDetectionDiagnostics: services.readDetectionDiagnostics,
|
||||
releaseContinuousRender: services.releaseContinuousRender,
|
||||
setDetectionModeByLabel: services.setDetectionModeByLabel,
|
||||
setDetectionStyle: services.setDetectionStyle,
|
||||
setDetectionTuning: services.setDetectionTuning,
|
||||
setKeyholeFadeTuning: services.setKeyholeFadeTuning,
|
||||
setScopeMaskEnabled: services.setScopeMaskEnabled,
|
||||
setScopeMaskFeather: services.setScopeMaskFeather,
|
||||
},
|
||||
elements: {
|
||||
_ppToggles: this._ppToggles,
|
||||
_sliderPanel: this._sliderPanel,
|
||||
},
|
||||
operations: {
|
||||
_restorePanelState: (...args) => this._restorePanelState(...args),
|
||||
_layoutRightPanels: (...args) => this._layoutRightPanels(...args),
|
||||
_scheduleAdaptivePanelLayout: (...args) =>
|
||||
this._scheduleAdaptivePanelLayout(...args),
|
||||
_scheduleRightPanelLayout: (...args) =>
|
||||
this._scheduleRightPanelLayout(...args),
|
||||
_setCockpitDisclosure: (...args) => this._setCockpitDisclosure(...args),
|
||||
_cockpitDisclosureOpen: (kind) => this._cockpitDisclosureOpen(kind),
|
||||
_setMapStack: (...args) => this._setMapStack(...args),
|
||||
_presentPanel: (...args) => this._presentPanel(...args),
|
||||
_syncShareState: (...args) => this._syncShareState(...args),
|
||||
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
|
||||
},
|
||||
readHud: () => this.hud,
|
||||
readCockpit: () => this.cockpitView,
|
||||
readDataManager: () => this._dataManager,
|
||||
readShareLinks: () => this.shareLinkManager,
|
||||
readCelestialRing: () => this.celestialRing,
|
||||
readContextMode: () => this._contextMode,
|
||||
readContextChanging: () => this._contextModeChanging,
|
||||
readDisplayPortalActive: () => this._cockpitDisplayPortalActive,
|
||||
});
|
||||
|
||||
this._layerBindings = new LayerBindings({
|
||||
viewer,
|
||||
engine,
|
||||
services: {
|
||||
imageryHost: () =>
|
||||
resolveImageryHost({
|
||||
viewer,
|
||||
tileset: mapStackController?.getImageryHostTileset?.(),
|
||||
}),
|
||||
cachedGroundFloor: services.cachedGroundFloor,
|
||||
warmGroundFloor: services.warmGroundFloor,
|
||||
cctvLayer: services.cctvLayer,
|
||||
},
|
||||
readControls: () => ({
|
||||
hud: this.hud,
|
||||
_contextControls: this._contextControls,
|
||||
_cctvControls: this._cctvControls,
|
||||
_radioControls: this._radioControls,
|
||||
_streetLevelControls: this._streetLevelControls,
|
||||
}),
|
||||
operations: {
|
||||
_updateTrafficSyncChip: (...args) =>
|
||||
this._updateTrafficSyncChip(...args),
|
||||
_updateGlobalLoadingFeedback: (...args) =>
|
||||
this._updateGlobalLoadingFeedback(...args),
|
||||
_syncContextModeButtons: (...args) =>
|
||||
this._syncContextModeButtons(...args),
|
||||
_stampNavigation: (...args) => this._stampNavigation(...args),
|
||||
_runExplicitCctvFocus: (...args) => this._runExplicitCctvFocus(...args),
|
||||
_runExplicitWorldFocus: (...args) =>
|
||||
this._runExplicitWorldFocus(...args),
|
||||
runImmediateNavigation: (...args) =>
|
||||
this.runImmediateNavigation(...args),
|
||||
_showToast: (...args) => this._showToast(...args),
|
||||
},
|
||||
feedback: this._feedback,
|
||||
shareRestoration: this._shareRestoration,
|
||||
});
|
||||
}
|
||||
|
||||
/** Cockpit and the celestial ring of this globe. */
|
||||
_buildSceneCockpit() {
|
||||
const { viewer, engine } = this._scene;
|
||||
const services = this.services;
|
||||
const { CelestialRing } = services;
|
||||
this._cockpitCoordinator = new CockpitCoordinator({
|
||||
viewer,
|
||||
engine,
|
||||
services: {
|
||||
flightsLayer: services.flightsLayer,
|
||||
militaryFlightsLayer: services.militaryFlightsLayer,
|
||||
isTr3b: services.isTr3b,
|
||||
toggleTr3b: services.toggleTr3b,
|
||||
militaryAwarenessLayer: services.militaryAwarenessLayer,
|
||||
cachedGroundFloor: services.cachedGroundFloor,
|
||||
cachedMeshFloor: services.cachedMeshFloor,
|
||||
GROUND_FLOOR_LIFT_M: services.GROUND_FLOOR_LIFT_M,
|
||||
meshFloorPreferred: services.meshFloorPreferred,
|
||||
warmGroundFloor: services.warmGroundFloor,
|
||||
sampleMeshFloorCells: services.sampleMeshFloorCells,
|
||||
holdContinuousRender: services.holdContinuousRender,
|
||||
releaseContinuousRender: services.releaseContinuousRender,
|
||||
fetchRegionalBrief: services.fetchRegionalBrief,
|
||||
regionalDistanceM: services.regionalDistanceM,
|
||||
weatherCodeLabel: services.weatherCodeLabel,
|
||||
},
|
||||
elements: {
|
||||
_ppToggles: this._ppToggles,
|
||||
_cockpitDisplayPanel: this._cockpitDisplayPanel,
|
||||
_hudBtn: this._hudBtn,
|
||||
_detectionBtn: this._detectionBtn,
|
||||
_sliderPanel: this._sliderPanel,
|
||||
_models3dBtn: this._models3dBtn,
|
||||
},
|
||||
operations: {
|
||||
publishCockpit: {
|
||||
presence: (view) =>
|
||||
useCockpitStore(applicationPinia()).publishPresence(view),
|
||||
brief: (view) =>
|
||||
useCockpitStore(applicationPinia()).publishBrief(view),
|
||||
context: (view) =>
|
||||
useCockpitStore(applicationPinia()).publishContext(view),
|
||||
instruments: (view) =>
|
||||
useCockpitStore(applicationPinia()).publishInstruments(view),
|
||||
route: (view) =>
|
||||
useCockpitStore(applicationPinia()).publishRoute(view),
|
||||
vision: (view) =>
|
||||
useCockpitStore(applicationPinia()).publishVision(view),
|
||||
signals: (rows) =>
|
||||
useCockpitStore(applicationPinia()).publishSignals(rows),
|
||||
utilityLayout: (layout) =>
|
||||
useCockpitStore(applicationPinia()).publishUtilityLayout(layout),
|
||||
},
|
||||
publishCockpitPortal: (slots) =>
|
||||
useDisplayStore(applicationPinia()).publishCockpitPortal(slots),
|
||||
_layoutRightPanels: (...args) => this._layoutRightPanels(...args),
|
||||
_setCockpitVision: (...args) => this._setCockpitVision(...args),
|
||||
_stampNavigation: (...args) => this._stampNavigation(...args),
|
||||
getAircraftTrackingTarget: (...args) =>
|
||||
this.getAircraftTrackingTarget(...args),
|
||||
_scheduleRightPanelLayout: (...args) =>
|
||||
this._scheduleRightPanelLayout(...args),
|
||||
},
|
||||
readDataManager: () => this._dataManager,
|
||||
readContext: () => this.getContextModeState(),
|
||||
readActiveStyle: () => this.activeStyle,
|
||||
readUtilityDisclosureOpen: () =>
|
||||
this._cockpitDisclosureOpen('display') ||
|
||||
this._cockpitDisclosureOpen('radio'),
|
||||
enterPanels: () => this._panelChrome.enterCockpit(),
|
||||
exitPanels: () => this._panelChrome.exitCockpit(),
|
||||
});
|
||||
|
||||
// Full-globe sun/moon ring on either engine, below the HUD/readout.
|
||||
this.celestialRing = new CelestialRing(viewer ?? engine, {
|
||||
engine,
|
||||
enabled: false,
|
||||
store: useCelestialRingStore(applicationPinia()),
|
||||
onAutoDisable: () =>
|
||||
this.setCelestialRingEnabled(false, {
|
||||
syncShare: !!this.shareLinkManager,
|
||||
focus: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** The world-overlay host, detection and post-process stages of this globe. */
|
||||
_initSceneOverlays() {
|
||||
const { engine } = this._scene;
|
||||
const {
|
||||
initWorldOverlay,
|
||||
initDetection,
|
||||
initTrackedReadout,
|
||||
setDetectionStyle,
|
||||
trafficLayer,
|
||||
flightsLayer,
|
||||
militaryFlightsLayer,
|
||||
localAdsbLayer,
|
||||
satellitesLayer,
|
||||
cctvLayer,
|
||||
bikeshareLayer,
|
||||
transitLayer,
|
||||
aisLiveVesselsLayer,
|
||||
} = this.services;
|
||||
// The shared world-overlay host must own its one postRender lane before
|
||||
// detection and tracked-readout initialize. It stays transparent until a
|
||||
// production source explicitly registers entries.
|
||||
initWorldOverlay(engine);
|
||||
// Initialize detection overlay BEFORE style stages so the composite
|
||||
// stage is first in the post-process pipeline. Both run on the engine,
|
||||
// Navara's too.
|
||||
{
|
||||
initDetection(
|
||||
engine,
|
||||
[
|
||||
trafficLayer,
|
||||
flightsLayer,
|
||||
militaryFlightsLayer,
|
||||
localAdsbLayer,
|
||||
satellitesLayer,
|
||||
cctvLayer,
|
||||
bikeshareLayer,
|
||||
transitLayer,
|
||||
aisLiveVesselsLayer,
|
||||
].filter(Boolean),
|
||||
(modeLabel) => {
|
||||
this._updateDetectionButton(modeLabel);
|
||||
},
|
||||
);
|
||||
initTrackedReadout(engine);
|
||||
}
|
||||
setDetectionStyle(this.activeStyle);
|
||||
this._setDetectionDensity();
|
||||
|
||||
this._initStages();
|
||||
this._initBloomSharpen();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the shell to another globe (an engine switch): `{ viewer, engine,
|
||||
* mapStackController, services }`, where `services` are that catalog's
|
||||
* layer roles. The shell, its stores, open panels, typed text and focus
|
||||
* stay; the parts bound to a globe are built for this one and the
|
||||
* address's view is restored on it.
|
||||
*/
|
||||
attachScene(options = {}) {
|
||||
return this._queueSceneTransition(() => this._attachScene(options));
|
||||
}
|
||||
|
||||
_queueSceneTransition(work) {
|
||||
const run = (this._sceneTransition ?? Promise.resolve()).then(work, work);
|
||||
this._sceneTransition = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
async _attachScene({
|
||||
viewer = null,
|
||||
engine = null,
|
||||
mapStackController = null,
|
||||
services = {},
|
||||
engineSwitch = true,
|
||||
} = {}) {
|
||||
if (this._disposed) return;
|
||||
if (this._scene.engine === engine && this._scene.viewer === viewer) return;
|
||||
if (this._scene.engine)
|
||||
throw new Error('The shell is attached to a globe already');
|
||||
// An attachment that fails part-way releases what it built: the shell
|
||||
// stays detached, ready for the working engine.
|
||||
try {
|
||||
const previousServices = this.services;
|
||||
this.services = { ...this.services, ...services };
|
||||
this._scene = { viewer, engine, mapStackController };
|
||||
const {
|
||||
flightsLayer,
|
||||
militaryFlightsLayer,
|
||||
satellitesLayer,
|
||||
aisLiveVesselsLayer,
|
||||
militaryAwarenessLayer,
|
||||
rocketLaunchesLayer,
|
||||
} = this.services;
|
||||
this._navigation.tracking = {
|
||||
flightsLayer,
|
||||
militaryFlightsLayer,
|
||||
satellitesLayer,
|
||||
aisLiveVesselsLayer,
|
||||
militaryAwarenessLayer,
|
||||
rocketLaunchesLayer,
|
||||
};
|
||||
this._navigation.setScene({ viewer, engine });
|
||||
this._locationNavigation.attachScene({ viewer, engine });
|
||||
this.hud.attachEngine(engine, { viewer });
|
||||
this.shareLinkManager.attachEngine(engine);
|
||||
this._displayBindings.attachViewer(viewer);
|
||||
this._buildSceneControls();
|
||||
this._buildSceneCockpit();
|
||||
this._shareRestoration.attachLinks(this.shareLinkManager);
|
||||
this._initSceneOverlays();
|
||||
this._initMapStackControl();
|
||||
this._refreshLayerControls(previousServices);
|
||||
this._initCameraOrientationControls();
|
||||
this._initHudScene();
|
||||
this._initCockpitDisplayPortal();
|
||||
this._applyGlobalPostDefaults();
|
||||
this._startAnimationLoop();
|
||||
this._publishActiveStyle();
|
||||
this._shareRestoration.start({
|
||||
engineSwitch,
|
||||
retainScene: Boolean(this._layerSession?.handoffLayers),
|
||||
});
|
||||
this._layerBindings.observeCamera();
|
||||
this._layoutRightPanels();
|
||||
this._syncCctvPanelViewport();
|
||||
// Choices made between the engines apply once this engine restored
|
||||
// the address's view: the person's choice wins over the link.
|
||||
const pending = this._pendingVisual?.queue;
|
||||
this._pendingVisual = null;
|
||||
if (pending?.length) {
|
||||
const settings = this._visualSettings;
|
||||
void this.initialRestorePromise.then(() => {
|
||||
if (!this._disposed && this._visualSettings === settings)
|
||||
applyPendingVisualChoices(settings, pending);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await this._detachScene().catch((cleanupError) =>
|
||||
console.error(
|
||||
'The shell could not leave a failed globe:',
|
||||
cleanupError,
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach the shell from its globe (an engine switch, or the page going).
|
||||
* The parts bound to that globe are released; the shell, its stores and
|
||||
* the open panels stay. `handoff` keeps the selection and a retainable
|
||||
* Context mode for the next globe.
|
||||
*/
|
||||
detachScene(options = {}) {
|
||||
return this._queueSceneTransition(() => this._detachScene(options));
|
||||
}
|
||||
|
||||
async _detachScene({
|
||||
handoff = Boolean(this._layerSession?.handoffLayers),
|
||||
} = {}) {
|
||||
if (!this._scene.engine && !this._scene.viewer) return;
|
||||
const { destroyTrackedReadout, destroyWorldOverlay, destroyDetection } =
|
||||
this.services;
|
||||
this._shareRestoration.destroy();
|
||||
this._navigation.setScene({}, { retainScene: handoff });
|
||||
this._layerBindings.stop();
|
||||
this._cockpitCoordinator.stop();
|
||||
this._visualSettings.stop();
|
||||
// A Context mode that cannot be carried to the next globe (a transition
|
||||
// in flight) restores the layers it changed, as a teardown does.
|
||||
if (!this._contextControls.canRetainScene()) {
|
||||
this._contextControls._contextModeGeneration++;
|
||||
await this._contextControls.restoreForDisposal();
|
||||
this._contextControls._contextModeChanging = false;
|
||||
}
|
||||
// IR boost teardown BEFORE detaching the data manager: restore fog and
|
||||
// un-boost both aircraft layers so the next globe does not inherit
|
||||
// sensor state (review P2, 2026-08-16).
|
||||
this._visualSettings.releaseIrBoost();
|
||||
this._cockpitCoordinator.destroy();
|
||||
this._layerBindings.disconnect();
|
||||
destroyTrackedReadout();
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
this.celestialRing?.destroy();
|
||||
this.celestialRing = null;
|
||||
this._visualSettings.destroy();
|
||||
// Until the next globe: choices wait for it (pendingVisualSettings.js),
|
||||
// and the map-source chips and camera buttons answer nothing.
|
||||
this._pendingVisual = pendingVisualSettings(this._visualSettings);
|
||||
this._visualSettings = this._pendingVisual.settings;
|
||||
this._mapSourceControls?.destroy();
|
||||
this._mapSourceControls = null;
|
||||
useDisplayStore(applicationPinia()).connectMapSource(null);
|
||||
this._cameraOrientationControls?.destroy();
|
||||
this._cameraOrientationControls = null;
|
||||
if (this._orientationToken)
|
||||
useGlobeActionsStore(applicationPinia()).detach(this._orientationToken);
|
||||
this._orientationToken = null;
|
||||
this._displayBindings.attachViewer(null);
|
||||
this._locationNavigation.detachScene();
|
||||
this.hud.attachEngine(null);
|
||||
this.shareLinkManager.attachEngine(null);
|
||||
this._scene = { viewer: null, engine: null, mapStackController: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a panel's controls whose layer is another instance in the new
|
||||
* catalog (a layer this engine left out, then shows). Layers the session
|
||||
* keeps are the same instances, so their controls stay.
|
||||
*/
|
||||
_refreshLayerControls(previous) {
|
||||
const changed = (...roles) =>
|
||||
roles.some((role) => previous?.[role] !== this.services[role]);
|
||||
if (changed('cctvLayer')) this._initCctvPanel();
|
||||
if (changed('radioLayer', 'localAdsbLayer')) this._initRadioPanel();
|
||||
if (changed('streetLevelLayer')) this._initStreetLevelPanel();
|
||||
this._initWildlifeSightingCard();
|
||||
if (
|
||||
changed(
|
||||
'radioLayer',
|
||||
'militaryInstallationsLayer',
|
||||
'militaryAwarenessLayer',
|
||||
'rocketLaunchesLayer',
|
||||
)
|
||||
) {
|
||||
this._contextControls.stop();
|
||||
this._contextControls.disconnect();
|
||||
this._initGlobalContextPanel();
|
||||
}
|
||||
}
|
||||
|
||||
/** The attached globe's Cesium viewer; null on Navara or between engines. */
|
||||
get viewer() {
|
||||
return this._scene?.viewer ?? null;
|
||||
@@ -2074,9 +1623,7 @@ export class StyleManager extends ShellFacade {
|
||||
async dispose() {
|
||||
if (this._disposed) return;
|
||||
const layerSession = this._layerSession;
|
||||
const handoff =
|
||||
Boolean(layerSession?.handoffLayers) &&
|
||||
!layerSession.lifetime.signal.aborted;
|
||||
const handoff = retainsLayerSession(layerSession);
|
||||
// A Context mode the next shell can resume when the session is handed to
|
||||
// a new application.
|
||||
const retainContext = handoff && this._contextControls.canRetainScene();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { assertSameNode } from '../testSupport/domAssert.mjs';
|
||||
@@ -383,6 +384,7 @@ test('the real director preserves a selected shot label for the following double
|
||||
const f = fixture();
|
||||
const previousStorage = globalThis.localStorage;
|
||||
let director;
|
||||
const engine = createFakeEngine();
|
||||
try {
|
||||
f.owner.destroy();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
@@ -393,7 +395,7 @@ test('the real director preserves a selected shot label for the following double
|
||||
setItem() {},
|
||||
},
|
||||
});
|
||||
director = new SceneDirector({ camera: { cancelFlight() {} } }, {}, {});
|
||||
director = new SceneDirector({ engine }, {}, {});
|
||||
await nextTick();
|
||||
const label = f.label();
|
||||
label.click();
|
||||
@@ -406,6 +408,7 @@ test('the real director preserves a selected shot label for the following double
|
||||
assert.equal(director._getSelectedScene().shots[0].title, 'Renamed');
|
||||
} finally {
|
||||
await director?.destroy();
|
||||
engine.destroy();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import { retainsLayerSession } from '../app/layerSession.js';
|
||||
import { resolveImageryHost } from '../layers/weather/imageryHost.js';
|
||||
import { LayerBindings } from './layerBindings.js';
|
||||
import { VisualSettings } from './visualSettings.js';
|
||||
import { ShareRestoration } from './shareRestoration.js';
|
||||
import { CockpitCoordinator } from './cockpitCoordinator.js';
|
||||
import { useCelestialRingStore } from './stores/celestialRing.js';
|
||||
import {
|
||||
applyPendingVisualChoices,
|
||||
pendingVisualSettings,
|
||||
} from './pendingVisualSettings.js';
|
||||
import { ShellFacade } from './shellFacade.js';
|
||||
import { applyShareFollowOffset } from './shareFollow.js';
|
||||
import { applicationPinia } from './vueRuntime.js';
|
||||
import { useCockpitStore } from './stores/cockpit.js';
|
||||
import { useDisplayStore } from './stores/display.js';
|
||||
import { useGlobeActionsStore } from './stores/globeActions.js';
|
||||
|
||||
/** Globe-bound composition kept separately from the page shell. */
|
||||
export class ShellScene extends ShellFacade {
|
||||
// ── Scene attachment ──────────────────────────────────────────────────
|
||||
// The shell lives for the page; an engine switch only rebuilds the parts
|
||||
// bound to one globe (share restoration, visual effects, layer bindings,
|
||||
// Cockpit, the celestial ring, the overlay host, detection, the map-source
|
||||
// and camera-orientation controls) and points the kept ones (navigation,
|
||||
// location search, HUD, share link, display bindings) at the next globe.
|
||||
|
||||
/** Share restoration, visual settings and layer bindings of this globe. */
|
||||
_buildSceneControls() {
|
||||
const { viewer, engine, mapStackController } = this._scene;
|
||||
const services = this.services;
|
||||
const layerSession = this._layerSession;
|
||||
this._shareRestoration = new ShareRestoration({
|
||||
engine,
|
||||
canvas: engine?.host.canvas ?? viewer?.canvas,
|
||||
handoffLayers: layerSession?.handoffLayers,
|
||||
navigation: this._navigation,
|
||||
syncShareState: () => this._syncShareState(),
|
||||
syncModels3d: (state) => this._syncModels3dFromLayerState(state),
|
||||
showStatus: (message, options) =>
|
||||
this._showGlobalStatusNotice(message, options),
|
||||
feedback: this._feedback,
|
||||
updateFeedback: () => this._updateGlobalLoadingFeedback(),
|
||||
applyFollowOffset: (handle, offset) =>
|
||||
applyShareFollowOffset(engine, handle, offset),
|
||||
});
|
||||
|
||||
this._visualSettings = new VisualSettings({
|
||||
viewer,
|
||||
engine,
|
||||
mapStackController,
|
||||
services: {
|
||||
setScopeTerminusOverride: services.setScopeTerminusOverride,
|
||||
clampScopeTerminusPct: services.clampScopeTerminusPct,
|
||||
getDetectionMode: services.getDetectionMode,
|
||||
getDetectionTuning: services.getDetectionTuning,
|
||||
getKeyholeFadeTuning: services.getKeyholeFadeTuning,
|
||||
getScopeMaskFeather: services.getScopeMaskFeather,
|
||||
getScopeTerminusOverride: services.getScopeTerminusOverride,
|
||||
governorRequestRender: services.governorRequestRender,
|
||||
holdContinuousRender: services.holdContinuousRender,
|
||||
isCelestialRingStyleSupported: services.isCelestialRingStyleSupported,
|
||||
isScopeMaskEnabled: services.isScopeMaskEnabled,
|
||||
readDetectionDiagnostics: services.readDetectionDiagnostics,
|
||||
releaseContinuousRender: services.releaseContinuousRender,
|
||||
setDetectionModeByLabel: services.setDetectionModeByLabel,
|
||||
setDetectionStyle: services.setDetectionStyle,
|
||||
setDetectionTuning: services.setDetectionTuning,
|
||||
setKeyholeFadeTuning: services.setKeyholeFadeTuning,
|
||||
setScopeMaskEnabled: services.setScopeMaskEnabled,
|
||||
setScopeMaskFeather: services.setScopeMaskFeather,
|
||||
},
|
||||
elements: {
|
||||
_ppToggles: this._ppToggles,
|
||||
_sliderPanel: this._sliderPanel,
|
||||
},
|
||||
operations: {
|
||||
_restorePanelState: (...args) => this._restorePanelState(...args),
|
||||
_layoutRightPanels: (...args) => this._layoutRightPanels(...args),
|
||||
_scheduleAdaptivePanelLayout: (...args) =>
|
||||
this._scheduleAdaptivePanelLayout(...args),
|
||||
_scheduleRightPanelLayout: (...args) =>
|
||||
this._scheduleRightPanelLayout(...args),
|
||||
_setCockpitDisclosure: (...args) => this._setCockpitDisclosure(...args),
|
||||
_cockpitDisclosureOpen: (kind) => this._cockpitDisclosureOpen(kind),
|
||||
_setMapStack: (...args) => this._setMapStack(...args),
|
||||
_presentPanel: (...args) => this._presentPanel(...args),
|
||||
_syncShareState: (...args) => this._syncShareState(...args),
|
||||
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
|
||||
},
|
||||
readHud: () => this.hud,
|
||||
readCockpit: () => this.cockpitView,
|
||||
readDataManager: () => this._dataManager,
|
||||
readShareLinks: () => this.shareLinkManager,
|
||||
readCelestialRing: () => this.celestialRing,
|
||||
readContextMode: () => this._contextMode,
|
||||
readContextChanging: () => this._contextModeChanging,
|
||||
readDisplayPortalActive: () => this._cockpitDisplayPortalActive,
|
||||
});
|
||||
|
||||
this._layerBindings = new LayerBindings({
|
||||
viewer,
|
||||
engine,
|
||||
services: {
|
||||
imageryHost: () =>
|
||||
resolveImageryHost({
|
||||
viewer,
|
||||
tileset: mapStackController?.getImageryHostTileset?.(),
|
||||
}),
|
||||
cachedGroundFloor: services.cachedGroundFloor,
|
||||
warmGroundFloor: services.warmGroundFloor,
|
||||
cctvLayer: services.cctvLayer,
|
||||
},
|
||||
readControls: () => ({
|
||||
hud: this.hud,
|
||||
_contextControls: this._contextControls,
|
||||
_cctvControls: this._cctvControls,
|
||||
_radioControls: this._radioControls,
|
||||
_streetLevelControls: this._streetLevelControls,
|
||||
}),
|
||||
operations: {
|
||||
_updateTrafficSyncChip: (...args) =>
|
||||
this._updateTrafficSyncChip(...args),
|
||||
_updateGlobalLoadingFeedback: (...args) =>
|
||||
this._updateGlobalLoadingFeedback(...args),
|
||||
_syncContextModeButtons: (...args) =>
|
||||
this._syncContextModeButtons(...args),
|
||||
_stampNavigation: (...args) => this._stampNavigation(...args),
|
||||
_runExplicitCctvFocus: (...args) => this._runExplicitCctvFocus(...args),
|
||||
_runExplicitWorldFocus: (...args) =>
|
||||
this._runExplicitWorldFocus(...args),
|
||||
runImmediateNavigation: (...args) =>
|
||||
this.runImmediateNavigation(...args),
|
||||
_showToast: (...args) => this._showToast(...args),
|
||||
},
|
||||
feedback: this._feedback,
|
||||
shareRestoration: this._shareRestoration,
|
||||
});
|
||||
}
|
||||
|
||||
/** Cockpit and the celestial ring of this globe. */
|
||||
_buildSceneCockpit() {
|
||||
const { viewer, engine } = this._scene;
|
||||
const services = this.services;
|
||||
const { CelestialRing } = services;
|
||||
this._cockpitCoordinator = new CockpitCoordinator({
|
||||
viewer,
|
||||
engine,
|
||||
services: {
|
||||
flightsLayer: services.flightsLayer,
|
||||
militaryFlightsLayer: services.militaryFlightsLayer,
|
||||
isTr3b: services.isTr3b,
|
||||
toggleTr3b: services.toggleTr3b,
|
||||
militaryAwarenessLayer: services.militaryAwarenessLayer,
|
||||
cachedGroundFloor: services.cachedGroundFloor,
|
||||
cachedMeshFloor: services.cachedMeshFloor,
|
||||
GROUND_FLOOR_LIFT_M: services.GROUND_FLOOR_LIFT_M,
|
||||
meshFloorPreferred: services.meshFloorPreferred,
|
||||
warmGroundFloor: services.warmGroundFloor,
|
||||
sampleMeshFloorCells: services.sampleMeshFloorCells,
|
||||
holdContinuousRender: services.holdContinuousRender,
|
||||
releaseContinuousRender: services.releaseContinuousRender,
|
||||
fetchRegionalBrief: services.fetchRegionalBrief,
|
||||
regionalDistanceM: services.regionalDistanceM,
|
||||
weatherCodeLabel: services.weatherCodeLabel,
|
||||
},
|
||||
elements: {
|
||||
_ppToggles: this._ppToggles,
|
||||
_cockpitDisplayPanel: this._cockpitDisplayPanel,
|
||||
_hudBtn: this._hudBtn,
|
||||
_detectionBtn: this._detectionBtn,
|
||||
_sliderPanel: this._sliderPanel,
|
||||
_models3dBtn: this._models3dBtn,
|
||||
},
|
||||
operations: {
|
||||
publishCockpit: {
|
||||
presence: (view) =>
|
||||
useCockpitStore(applicationPinia()).publishPresence(view),
|
||||
brief: (view) =>
|
||||
useCockpitStore(applicationPinia()).publishBrief(view),
|
||||
context: (view) =>
|
||||
useCockpitStore(applicationPinia()).publishContext(view),
|
||||
instruments: (view) =>
|
||||
useCockpitStore(applicationPinia()).publishInstruments(view),
|
||||
route: (view) =>
|
||||
useCockpitStore(applicationPinia()).publishRoute(view),
|
||||
vision: (view) =>
|
||||
useCockpitStore(applicationPinia()).publishVision(view),
|
||||
signals: (rows) =>
|
||||
useCockpitStore(applicationPinia()).publishSignals(rows),
|
||||
utilityLayout: (layout) =>
|
||||
useCockpitStore(applicationPinia()).publishUtilityLayout(layout),
|
||||
},
|
||||
publishCockpitPortal: (slots) =>
|
||||
useDisplayStore(applicationPinia()).publishCockpitPortal(slots),
|
||||
_layoutRightPanels: (...args) => this._layoutRightPanels(...args),
|
||||
_setCockpitVision: (...args) => this._setCockpitVision(...args),
|
||||
_stampNavigation: (...args) => this._stampNavigation(...args),
|
||||
getAircraftTrackingTarget: (...args) =>
|
||||
this.getAircraftTrackingTarget(...args),
|
||||
_scheduleRightPanelLayout: (...args) =>
|
||||
this._scheduleRightPanelLayout(...args),
|
||||
},
|
||||
readDataManager: () => this._dataManager,
|
||||
readContext: () => this.getContextModeState(),
|
||||
readActiveStyle: () => this.activeStyle,
|
||||
readUtilityDisclosureOpen: () =>
|
||||
this._cockpitDisclosureOpen('display') ||
|
||||
this._cockpitDisclosureOpen('radio'),
|
||||
enterPanels: () => this._panelChrome.enterCockpit(),
|
||||
exitPanels: () => this._panelChrome.exitCockpit(),
|
||||
});
|
||||
|
||||
// Full-globe sun/moon ring on either engine, below the HUD/readout.
|
||||
this.celestialRing = new CelestialRing(viewer ?? engine, {
|
||||
engine,
|
||||
enabled: false,
|
||||
store: useCelestialRingStore(applicationPinia()),
|
||||
onAutoDisable: () =>
|
||||
this.setCelestialRingEnabled(false, {
|
||||
syncShare: !!this.shareLinkManager,
|
||||
focus: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** The world-overlay host, detection and post-process stages of this globe. */
|
||||
_initSceneOverlays() {
|
||||
const { engine } = this._scene;
|
||||
const {
|
||||
initWorldOverlay,
|
||||
initDetection,
|
||||
initTrackedReadout,
|
||||
setDetectionStyle,
|
||||
trafficLayer,
|
||||
flightsLayer,
|
||||
militaryFlightsLayer,
|
||||
localAdsbLayer,
|
||||
satellitesLayer,
|
||||
cctvLayer,
|
||||
bikeshareLayer,
|
||||
transitLayer,
|
||||
aisLiveVesselsLayer,
|
||||
} = this.services;
|
||||
// The shared world-overlay host must own its one postRender lane before
|
||||
// detection and tracked-readout initialize. It stays transparent until a
|
||||
// production source explicitly registers entries.
|
||||
initWorldOverlay(engine);
|
||||
// Initialize detection overlay BEFORE style stages so the composite
|
||||
// stage is first in the post-process pipeline. Both run on the engine,
|
||||
// Navara's too.
|
||||
{
|
||||
initDetection(
|
||||
engine,
|
||||
[
|
||||
trafficLayer,
|
||||
flightsLayer,
|
||||
militaryFlightsLayer,
|
||||
localAdsbLayer,
|
||||
satellitesLayer,
|
||||
cctvLayer,
|
||||
bikeshareLayer,
|
||||
transitLayer,
|
||||
aisLiveVesselsLayer,
|
||||
].filter(Boolean),
|
||||
(modeLabel) => {
|
||||
this._updateDetectionButton(modeLabel);
|
||||
},
|
||||
);
|
||||
initTrackedReadout(engine);
|
||||
}
|
||||
setDetectionStyle(this.activeStyle);
|
||||
this._setDetectionDensity();
|
||||
|
||||
this._initStages();
|
||||
this._initBloomSharpen();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the shell to another globe (an engine switch): `{ viewer, engine,
|
||||
* mapStackController, services }`, where `services` are that catalog's
|
||||
* layer roles. The shell, its stores, open panels, typed text and focus
|
||||
* stay; the parts bound to a globe are built for this one and the
|
||||
* address's view is restored on it.
|
||||
*/
|
||||
attachScene(options = {}) {
|
||||
return this._queueSceneTransition(() => this._attachScene(options));
|
||||
}
|
||||
|
||||
_queueSceneTransition(work) {
|
||||
const run = (this._sceneTransition ?? Promise.resolve()).then(work, work);
|
||||
this._sceneTransition = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
async _attachScene({
|
||||
viewer = null,
|
||||
engine = null,
|
||||
mapStackController = null,
|
||||
services = {},
|
||||
engineSwitch = true,
|
||||
} = {}) {
|
||||
if (this._disposed) return;
|
||||
if (this._scene.engine === engine && this._scene.viewer === viewer) return;
|
||||
if (this._scene.engine)
|
||||
throw new Error('The shell is attached to a globe already');
|
||||
// An attachment that fails part-way releases what it built: the shell
|
||||
// stays detached, ready for the working engine.
|
||||
try {
|
||||
const previousServices = this.services;
|
||||
this.services = { ...this.services, ...services };
|
||||
this._scene = { viewer, engine, mapStackController };
|
||||
const {
|
||||
flightsLayer,
|
||||
militaryFlightsLayer,
|
||||
satellitesLayer,
|
||||
aisLiveVesselsLayer,
|
||||
militaryAwarenessLayer,
|
||||
rocketLaunchesLayer,
|
||||
} = this.services;
|
||||
this._navigation.tracking = {
|
||||
flightsLayer,
|
||||
militaryFlightsLayer,
|
||||
satellitesLayer,
|
||||
aisLiveVesselsLayer,
|
||||
militaryAwarenessLayer,
|
||||
rocketLaunchesLayer,
|
||||
};
|
||||
this._navigation.setScene({ viewer, engine });
|
||||
this._locationNavigation.attachScene({ viewer, engine });
|
||||
this.hud.attachEngine(engine, { viewer });
|
||||
this.shareLinkManager.attachEngine(engine);
|
||||
this._displayBindings.attachViewer(viewer);
|
||||
this._buildSceneControls();
|
||||
this._buildSceneCockpit();
|
||||
this._shareRestoration.attachLinks(this.shareLinkManager);
|
||||
this._initSceneOverlays();
|
||||
this._initMapStackControl();
|
||||
this._refreshLayerControls(previousServices);
|
||||
this._initCameraOrientationControls();
|
||||
this._initHudScene();
|
||||
this._initCockpitDisplayPortal();
|
||||
this._applyGlobalPostDefaults();
|
||||
this._startAnimationLoop();
|
||||
this._publishActiveStyle();
|
||||
this._shareRestoration.start({
|
||||
engineSwitch,
|
||||
retainScene: Boolean(this._layerSession?.handoffLayers),
|
||||
});
|
||||
this._layerBindings.observeCamera();
|
||||
this._layoutRightPanels();
|
||||
this._syncCctvPanelViewport();
|
||||
// Choices made between the engines apply once this engine restored
|
||||
// the address's view: the person's choice wins over the link.
|
||||
const pending = this._pendingVisual?.queue;
|
||||
this._pendingVisual = null;
|
||||
if (pending?.length) {
|
||||
const settings = this._visualSettings;
|
||||
void this.initialRestorePromise.then(() => {
|
||||
if (!this._disposed && this._visualSettings === settings)
|
||||
applyPendingVisualChoices(settings, pending);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await this._detachScene().catch((cleanupError) =>
|
||||
console.error(
|
||||
'The shell could not leave a failed globe:',
|
||||
cleanupError,
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach the shell from its globe (an engine switch, or the page going).
|
||||
* The parts bound to that globe are released; the shell, its stores and
|
||||
* the open panels stay. `handoff` keeps the selection and a retainable
|
||||
* Context mode for the next globe.
|
||||
*/
|
||||
detachScene(options = {}) {
|
||||
return this._queueSceneTransition(() => this._detachScene(options));
|
||||
}
|
||||
|
||||
async _detachScene({
|
||||
handoff = retainsLayerSession(this._layerSession),
|
||||
} = {}) {
|
||||
if (!this._scene.engine && !this._scene.viewer) return;
|
||||
const { destroyTrackedReadout, destroyWorldOverlay, destroyDetection } =
|
||||
this.services;
|
||||
this._shareRestoration.destroy();
|
||||
this._navigation.setScene({}, { retainScene: handoff });
|
||||
this._layerBindings.stop();
|
||||
this._cockpitCoordinator.stop();
|
||||
this._visualSettings.stop();
|
||||
// A Context mode that cannot be carried to the next globe (a transition
|
||||
// in flight) restores the layers it changed, as a teardown does.
|
||||
if (!this._contextControls.canRetainScene()) {
|
||||
this._contextControls._contextModeGeneration++;
|
||||
await this._contextControls.restoreForDisposal();
|
||||
this._contextControls._contextModeChanging = false;
|
||||
}
|
||||
// IR boost teardown BEFORE detaching the data manager: restore fog and
|
||||
// un-boost both aircraft layers so the next globe does not inherit
|
||||
// sensor state.
|
||||
this._visualSettings.releaseIrBoost();
|
||||
this._cockpitCoordinator.destroy();
|
||||
this._layerBindings.disconnect();
|
||||
destroyTrackedReadout();
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
this.celestialRing?.destroy();
|
||||
this.celestialRing = null;
|
||||
this._visualSettings.destroy();
|
||||
// Until the next globe: choices wait for it (pendingVisualSettings.js),
|
||||
// and the map-source chips and camera buttons answer nothing.
|
||||
this._pendingVisual = pendingVisualSettings(this._visualSettings);
|
||||
this._visualSettings = this._pendingVisual.settings;
|
||||
this._mapSourceControls?.destroy();
|
||||
this._mapSourceControls = null;
|
||||
useDisplayStore(applicationPinia()).connectMapSource(null);
|
||||
this._cameraOrientationControls?.destroy();
|
||||
this._cameraOrientationControls = null;
|
||||
if (this._orientationToken)
|
||||
useGlobeActionsStore(applicationPinia()).detach(this._orientationToken);
|
||||
this._orientationToken = null;
|
||||
this._displayBindings.attachViewer(null);
|
||||
this._locationNavigation.detachScene();
|
||||
this.hud.attachEngine(null);
|
||||
this.shareLinkManager.attachEngine(null);
|
||||
this._scene = { viewer: null, engine: null, mapStackController: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a panel's controls whose layer is another instance in the new
|
||||
* catalog (a layer this engine left out, then shows). Layers the session
|
||||
* keeps are the same instances, so their controls stay.
|
||||
*/
|
||||
_refreshLayerControls(previous) {
|
||||
const changed = (...roles) =>
|
||||
roles.some((role) => previous?.[role] !== this.services[role]);
|
||||
if (changed('cctvLayer')) this._initCctvPanel();
|
||||
if (changed('radioLayer', 'localAdsbLayer')) this._initRadioPanel();
|
||||
if (changed('streetLevelLayer')) this._initStreetLevelPanel();
|
||||
this._initWildlifeSightingCard();
|
||||
if (
|
||||
changed(
|
||||
'radioLayer',
|
||||
'militaryInstallationsLayer',
|
||||
'militaryAwarenessLayer',
|
||||
'rocketLaunchesLayer',
|
||||
)
|
||||
) {
|
||||
this._contextControls.stop();
|
||||
this._contextControls.disconnect();
|
||||
this._initGlobalContextPanel();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user