mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
createStandaloneApplication opens its first globe, attaches the scene host and returns attachScene/detachScene: a switch hands the session off, parks the outgoing renderer and attaches the incoming one with its catalog to the parts the phases registered once. A failed candidate releases what it attached; the working engine attaches again on the same shell, voice, tools and stores. ?switch=legacy keeps the route that builds an application per engine for one release. scripts/qa-app-once.mjs now asserts identity, UI, tool and listener continuity, the rollback and the legacy route.
322 lines
11 KiB
JavaScript
322 lines
11 KiB
JavaScript
import {
|
|
createStandaloneApplication,
|
|
pageEngineKind,
|
|
} 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 { ENGINE_STORAGE_KEY } from './app/engineChoice.js';
|
|
import { createSessionDiagnostics } from './app/sessionDiagnostics.js';
|
|
import { useDisplayStore } from './ui/stores/display.js';
|
|
import { applicationPinia } from './ui/vueRuntime.js';
|
|
import { t } from './i18n/index.js';
|
|
|
|
// The Vite boundary exposes only undefined, null, or a validated URL.
|
|
const photonSetting = import.meta.env.PHOTON_URL;
|
|
// Every engine start composes the same place-search endpoints.
|
|
const geospatial =
|
|
photonSetting === undefined ? {} : { endpoints: { photon: photonSetting } };
|
|
|
|
const display = useDisplayStore(applicationPinia());
|
|
const loading = document.getElementById('loading-screen');
|
|
let application;
|
|
let engineSwitch = null;
|
|
let cover = null;
|
|
let cockpit = null;
|
|
const renderers = createRendererCache();
|
|
const layerSession = { lifetime: new AbortController() };
|
|
const { signal } = layerSession.lifetime;
|
|
// Which session objects this page holds now (scripts/qa-app-once.mjs).
|
|
const session = createSessionDiagnostics(() => {
|
|
const components = application?.getComponents?.() ?? {};
|
|
const style = components.controls?.styleManager;
|
|
return {
|
|
application,
|
|
shell: style,
|
|
shareLink: style?.shareLinkManager,
|
|
hud: style?.hud,
|
|
navigation: style?._navigation,
|
|
placeSearch: style?.placeSearch,
|
|
dataManager: components.data?.dataManager,
|
|
voice: layerSession.voice?.commands ?? components.tools?.voiceCommands,
|
|
annotations: layerSession.annotations ?? components.tools?.annotations,
|
|
geoImport: window.__gevGeoImport,
|
|
drawTool: window.__gevDrawTool,
|
|
};
|
|
});
|
|
window.__gevSession = session;
|
|
window.addEventListener('pagehide', (event) => {
|
|
if (event.persisted) return;
|
|
layerSession.lifetime.abort();
|
|
void application
|
|
?.destroy()
|
|
.catch(report)
|
|
.finally(() => renderers.clear());
|
|
});
|
|
|
|
// Registered before any engine starts, so it runs ahead of the session's own
|
|
// share-link listeners. A history visit that changes the engine belongs to
|
|
// the switch: the outgoing engine's share link neither restores that entry
|
|
// (releasing the followed subject just before the handoff) nor writes its
|
|
// own view onto it. The incoming engine reads the entry as it starts, and
|
|
// settle() hands the address back to whichever engine then runs.
|
|
window.addEventListener('popstate', () => {
|
|
if (!engineSwitch) return;
|
|
if (pageEngineKind() !== engineSwitch.state().engine)
|
|
application
|
|
?.getComponents()
|
|
.controls?.styleManager?.shareLinkManager?.holdNavigation?.();
|
|
void engineSwitch.reconcile();
|
|
});
|
|
|
|
function createApplication(kind, engineSwitch) {
|
|
session.count('applications');
|
|
layerSession.restoringView = engineSwitch;
|
|
application = createStandaloneApplication({
|
|
googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY,
|
|
cesiumToken: import.meta.env.CESIUM_ION_TOKEN,
|
|
geospatial,
|
|
allowQaRegistration: import.meta.env.DEV,
|
|
engineKind: kind,
|
|
rendererCache: renderers,
|
|
layerSession,
|
|
engineSwitch,
|
|
});
|
|
return application;
|
|
}
|
|
|
|
function liveComponents() {
|
|
return application?.getState().status === 'ready'
|
|
? application.getComponents()
|
|
: null;
|
|
}
|
|
|
|
// The page's application is created once; a switch detaches and attaches
|
|
// only its engine-bound parts. `?switch=legacy` keeps the previous route, one
|
|
// application per engine, for one release.
|
|
const legacySwitch =
|
|
new URLSearchParams(globalThis.location?.search ?? '').get('switch') ===
|
|
'legacy';
|
|
|
|
// 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?.scene.engine);
|
|
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 (legacySwitch) await application?.destroy();
|
|
else 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 (legacySwitch) return enterLegacy(kind);
|
|
if (!application || application.getState().status !== 'ready') {
|
|
// The first engine never started: there is no application to attach to.
|
|
return enterLegacy(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);
|
|
}
|
|
}
|
|
|
|
// The legacy route: a new application on the retained session.
|
|
async function enterLegacy(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();
|
|
// 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;
|
|
|
|
function present() {
|
|
const { kind, busy } = engineSwitch.state();
|
|
if (!busy) layerSession.voice?.runner.wake();
|
|
display.presentGlobeEngine({ kind, busy, select });
|
|
for (const button of loading.querySelectorAll('.engine-switch-retry button'))
|
|
button.disabled = busy;
|
|
}
|
|
|
|
// No engine runs after a failed recovery. The error stays on the loading
|
|
// screen with an engine picker, so a new attempt needs no reload.
|
|
function offerEngineRetry() {
|
|
const content = loading.querySelector('.loader-content') ?? loading;
|
|
if (content.querySelector('.engine-switch-retry')) return;
|
|
const group = document.createElement('div');
|
|
group.className = 'engine-switch-retry';
|
|
group.setAttribute('role', 'group');
|
|
group.setAttribute('aria-label', t('Globe Engine'));
|
|
for (const [kind, name] of [
|
|
['cesium', 'Cesium'],
|
|
['navara', 'Navara'],
|
|
]) {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.dataset.engine = kind;
|
|
button.textContent = name;
|
|
button.setAttribute('translate', 'no');
|
|
button.addEventListener('click', () => select(kind));
|
|
group.append(button);
|
|
}
|
|
content.append(group);
|
|
}
|
|
|
|
function withdrawEngineRetry() {
|
|
loading.querySelector('.engine-switch-retry')?.remove();
|
|
}
|
|
|
|
function report(error) {
|
|
console.error("God's Eye View initialization failed:", error);
|
|
loading.querySelector('.loader-status').textContent = t('Error: {message}', {
|
|
message: describeError(error),
|
|
});
|
|
loading.classList.remove('hidden');
|
|
}
|
|
|
|
function select(next) {
|
|
void engineSwitch?.select(next);
|
|
}
|
|
|
|
// Keep the last visible frame while the first visit initializes the other renderer.
|
|
function keepGlobeFrame(engine) {
|
|
const source = engine.host.canvas;
|
|
const frame = document.createElement('canvas');
|
|
frame.width = source.width;
|
|
frame.height = source.height;
|
|
if (!frame.width || !frame.height) return null;
|
|
const context = frame.getContext('2d');
|
|
if (!context) return null;
|
|
context.drawImage(source, 0, 0);
|
|
frame.className = 'engine-switch-frame';
|
|
frame.setAttribute('aria-hidden', 'true');
|
|
document.getElementById('cesiumContainer').append(frame);
|
|
return frame;
|
|
}
|
|
|
|
const initialKind = pageEngineKind();
|
|
createApplication(initialKind, false)
|
|
.start()
|
|
.then(() => {
|
|
session.count('attachments');
|
|
engineSwitch = createEngineSwitch({
|
|
kind: initialKind,
|
|
requested: pageEngineKind,
|
|
signal,
|
|
leave,
|
|
enter,
|
|
settle,
|
|
address: createEngineAddress({
|
|
location,
|
|
history,
|
|
flush: () =>
|
|
liveComponents()?.controls.styleManager.shareLinkManager?.flush(),
|
|
}),
|
|
remember(kind) {
|
|
try {
|
|
localStorage.setItem(ENGINE_STORAGE_KEY, kind);
|
|
} catch {
|
|
// The current URL still carries the choice when storage is blocked.
|
|
}
|
|
},
|
|
onChange: present,
|
|
onFailure(error, { recovered }) {
|
|
const style = recovered && liveComponents()?.controls.styleManager;
|
|
if (style) return style._showToast(describeError(error));
|
|
report(error);
|
|
offerEngineRetry();
|
|
},
|
|
});
|
|
present();
|
|
// A history visit made while the first engine started.
|
|
void engineSwitch.reconcile();
|
|
})
|
|
.catch(report);
|
|
|
|
export { application };
|