mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Hand the session off on Back/Forward and roll failed globe switches back to the last working engine
The engine switch moves out of main.js into src/standalone/engineSwitch.js: every engine change in the page hands the session off (layers, Director, Contacts, directions and satellite cameras), whether it came from the picker or from history. Each attempt has a generation; a failure rolls back to the last engine that started, requests made during a switch or recovery are reconciled once it settles, and a picked engine adds its history entry only after it starts. The first-frame wait only lifts the cover and never fails a started engine; a hidden tab does not wait. Nothing starts after pagehide, and a failed recovery leaves the picker usable.
This commit is contained in:
1 parent
64c5cd25e6
commit
4d2b061e3b
5 files changed
+1320
-201
No files matched your search
+126
-106
@@ -4,6 +4,13 @@ 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 { ENGINE_STORAGE_KEY } from './app/engineChoice.js';
|
||||
import { useDisplayStore } from './ui/stores/display.js';
|
||||
import { applicationPinia } from './ui/vueRuntime.js';
|
||||
@@ -11,37 +18,90 @@ import { t } from './i18n/index.js';
|
||||
|
||||
const display = useDisplayStore(applicationPinia());
|
||||
const loading = document.getElementById('loading-screen');
|
||||
let kind = pageEngineKind();
|
||||
let application;
|
||||
let busy = false;
|
||||
let engineSwitch = null;
|
||||
let cover = null;
|
||||
let cockpit = null;
|
||||
const renderers = createRendererCache();
|
||||
const layerSession = { lifetime: new AbortController() };
|
||||
const { signal } = layerSession.lifetime;
|
||||
window.addEventListener('pagehide', (event) => {
|
||||
if (event.persisted) return;
|
||||
layerSession.lifetime.abort();
|
||||
void application?.destroy().catch(report).finally(() => renderers.clear());
|
||||
void application
|
||||
?.destroy()
|
||||
.catch(report)
|
||||
.finally(() => renderers.clear());
|
||||
});
|
||||
|
||||
async function start(next, engineSwitch = false, cockpit = null) {
|
||||
kind = next;
|
||||
// Registered before any engine starts, so these run ahead of the running
|
||||
// session's own share-link listeners. A history visit that changes the
|
||||
// engine belongs to the switch: the outgoing engine must not start restoring
|
||||
// that entry's share (and release the followed subject) just before it hands
|
||||
// the session off. The incoming engine reads the entry's view as it starts.
|
||||
let swallowedHash = null;
|
||||
window.addEventListener('popstate', (event) => {
|
||||
if (!engineSwitch) return;
|
||||
swallowedHash = null;
|
||||
if (pageEngineKind() !== engineSwitch.state().engine) {
|
||||
event.stopImmediatePropagation();
|
||||
swallowedHash = location.hash;
|
||||
}
|
||||
void engineSwitch.reconcile();
|
||||
});
|
||||
window.addEventListener('hashchange', (event) => {
|
||||
// Back/Forward fires hashchange after popstate for the same entry.
|
||||
if (swallowedHash !== null && location.hash === swallowedHash)
|
||||
event.stopImmediatePropagation();
|
||||
swallowedHash = null;
|
||||
});
|
||||
|
||||
function createApplication(kind, engineSwitch) {
|
||||
layerSession.restoringView = engineSwitch;
|
||||
application = createStandaloneApplication({
|
||||
googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY,
|
||||
cesiumToken: import.meta.env.CESIUM_ION_TOKEN,
|
||||
allowQaRegistration: import.meta.env.DEV,
|
||||
engineKind: next,
|
||||
engineKind: kind,
|
||||
rendererCache: renderers,
|
||||
layerSession,
|
||||
engineSwitch,
|
||||
});
|
||||
const { scene, controls } = await application.start();
|
||||
if (engineSwitch) {
|
||||
return application;
|
||||
}
|
||||
|
||||
function liveComponents() {
|
||||
return application?.getState().status === 'ready'
|
||||
? application.getComponents()
|
||||
: null;
|
||||
}
|
||||
|
||||
// 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();
|
||||
if (components) {
|
||||
cover ??= keepGlobeFrame(components.scene.engine);
|
||||
cockpit = components.controls.styleManager.getCockpitState();
|
||||
handOffSession(layerSession);
|
||||
renderers.retain(from);
|
||||
}
|
||||
await application?.destroy();
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const app = createApplication(kind, true);
|
||||
try {
|
||||
const { controls } = await app.start();
|
||||
signal.throwIfAborted();
|
||||
const style = controls.styleManager;
|
||||
await style.initialRestorePromise;
|
||||
signal.throwIfAborted();
|
||||
layerSession.restoringView = false;
|
||||
for (const {module} of layerSession.manager.layers.values()) module.resumeViewport?.();
|
||||
if (layerSession.handoffLayers) layerSession.manager.layers.get('directions')?.module.resumeCamera?.();
|
||||
layerSession.director?.resumeScene();
|
||||
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();
|
||||
@@ -54,11 +114,32 @@ async function start(next, engineSwitch = false, cockpit = null) {
|
||||
) {
|
||||
style.cockpitView.setVisionMode(cockpit.visionMode);
|
||||
}
|
||||
await waitForGlobeFrame(scene.engine);
|
||||
} 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 engine = liveComponents()?.scene.engine;
|
||||
if (cover && engine) await waitForFirstFrame(engine);
|
||||
// A page left without an engine keeps the last handoff for the next try.
|
||||
if (engine) {
|
||||
layerSession.handoffLayers = null;
|
||||
cockpit = null;
|
||||
}
|
||||
cover?.remove();
|
||||
cover = null;
|
||||
}
|
||||
|
||||
function present() {
|
||||
const { kind, busy } = engineSwitch.state();
|
||||
display.presentGlobeEngine({ kind, busy, select });
|
||||
}
|
||||
|
||||
@@ -71,14 +152,7 @@ function report(error) {
|
||||
}
|
||||
|
||||
function select(next) {
|
||||
if (busy || kind === next || !['cesium', 'navara'].includes(next)) return;
|
||||
application.getComponents().controls.styleManager.shareLinkManager.flush();
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('engine', kind);
|
||||
history.replaceState(history.state, '', url);
|
||||
url.searchParams.set('engine', next);
|
||||
history.pushState(null, '', url);
|
||||
void reconcile(true);
|
||||
void engineSwitch?.select(next);
|
||||
}
|
||||
|
||||
// Keep the last visible frame while the first visit initializes the other renderer.
|
||||
@@ -97,94 +171,40 @@ function keepGlobeFrame(engine) {
|
||||
return frame;
|
||||
}
|
||||
|
||||
// Hand off the visible globe only after the new camera has actually drawn.
|
||||
function waitForGlobeFrame(engine) {
|
||||
if (document.hidden) return;
|
||||
return new Promise((resolve, reject) => {
|
||||
const finish = () => {
|
||||
remove();
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('visibilitychange', done);
|
||||
};
|
||||
const remove = engine.frames.postRender.addEventListener(done);
|
||||
const timer = setTimeout(() => {
|
||||
finish();
|
||||
reject(new Error('The globe did not draw a frame'));
|
||||
}, 8000);
|
||||
function done() {
|
||||
finish();
|
||||
resolve();
|
||||
}
|
||||
document.addEventListener('visibilitychange', done, { once: true });
|
||||
engine.render.request('engine-switch');
|
||||
});
|
||||
}
|
||||
|
||||
// popstate can arrive again while an engine is starting. After teardown and
|
||||
// after startup, read the actual address so the latest history entry wins.
|
||||
async function reconcile(remember = false) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
present();
|
||||
const previous = kind;
|
||||
let cover;
|
||||
let cockpit;
|
||||
const handoffHash = remember ? location.hash : null;
|
||||
try {
|
||||
while (pageEngineKind() !== kind) {
|
||||
cover ??= keepGlobeFrame(application.getComponents().scene.engine);
|
||||
cockpit = application
|
||||
.getComponents()
|
||||
.controls.styleManager.getCockpitState();
|
||||
const manager = application.getComponents().data.dataManager;
|
||||
layerSession.handoffLayers = location.hash === handoffHash
|
||||
? new Map([...manager.layers].map(([id, entry]) => [
|
||||
id, { entry: entry.module.attachRenderer ? entry : null, enabled: manager.isEffectivelyEnabled(id) },
|
||||
]))
|
||||
: null;
|
||||
if (layerSession.handoffLayers) {
|
||||
manager.layers.get('directions')?.module.suspendCamera?.();
|
||||
manager.layers.get('satellites')?.module.suspendCamera?.();
|
||||
layerSession.director?.suspendScene();
|
||||
}
|
||||
renderers.retain(kind);
|
||||
await application.destroy();
|
||||
await start(pageEngineKind(), true, cockpit);
|
||||
}
|
||||
if (remember) {
|
||||
try {
|
||||
localStorage.setItem(ENGINE_STORAGE_KEY, kind);
|
||||
} catch {
|
||||
// The current URL still carries the choice when storage is blocked.
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Keep a failed renderer from stranding a previously working session.
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('engine', previous);
|
||||
history.replaceState(history.state, '', url);
|
||||
try {
|
||||
await application.destroy();
|
||||
await start(previous, true, cockpit);
|
||||
application
|
||||
.getComponents()
|
||||
.controls.styleManager._showToast(describeError(error));
|
||||
} catch (recoveryError) {
|
||||
report(recoveryError);
|
||||
}
|
||||
} finally {
|
||||
layerSession.handoffLayers = null;
|
||||
cover?.remove();
|
||||
busy = false;
|
||||
present();
|
||||
}
|
||||
}
|
||||
|
||||
start(kind)
|
||||
const initialKind = pageEngineKind();
|
||||
createApplication(initialKind, false)
|
||||
.start()
|
||||
.then(() => {
|
||||
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) style._showToast(describeError(error));
|
||||
else report(error);
|
||||
},
|
||||
});
|
||||
present();
|
||||
window.addEventListener('popstate', () => void reconcile());
|
||||
void reconcile();
|
||||
// A history visit made while the first engine started.
|
||||
void engineSwitch.reconcile();
|
||||
})
|
||||
.catch(report);
|
||||
|
||||
|
||||
Reference in new issue
Block a user