Retain live flights and earthquake observations across globe switches

This commit is contained in:
German Khokhlov committed 2026-11-03 02:14:55 +04:00
1 parent a0e5f04c30
commit c0062c4d3d
17 files changed
+428 -66

No files matched your search

+4
View File
@@ -67,6 +67,8 @@ try {
await page.waitForSelector('#globe-engine', { visible: true });
await page.evaluate(() => {
window.__switchDocument = document;
window.__quakeEntry = window.__godsEyeView.dataManager.layers.get('earthquakes');
window.__quakeTimer = window.__quakeEntry.intervalId;
window.__renderers ??= new Map();
window.__renderers.set(
window.__godsEyeView.engine.kind,
@@ -93,6 +95,8 @@ try {
const previous = window.__renderers.get(engine.kind);
return (
!window.__switchSplash &&
window.__quakeEntry === window.__godsEyeView.dataManager.layers.get('earthquakes') &&
window.__quakeTimer === window.__quakeEntry.intervalId &&
(!previous || previous === engine) &&
!window.__outgoingEngine.isDestroyed() &&
!window.__outgoingEngine.host.canvas.isConnected &&
+163
View File
@@ -0,0 +1,163 @@
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer';
import { blockPaidQaMaps } from './qa-map-policy.mjs';
const url = new URL(process.argv[2]);
if (
!['localhost', '127.0.0.1'].includes(url.hostname) ||
['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
url.port,
)
)
throw new Error('Supply an unreserved local keyless preview URL');
url.search = '?engine=navara&welcome=0';
url.hash =
'v=2&lat=40.2539&lon=-125.5779&alt=21130&heading=360&pitch=-35&map=sentinel2&l=f&lo=f.e.1_f.t.71be26';
const browser = await puppeteer.launch({
headless: true,
args: ['--use-angle=metal', '--enable-gpu'],
});
try {
const page = await browser.newPage();
await blockPaidQaMaps(page);
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.setViewport({ width: 1280, height: 720 });
await page.evaluateOnNewDocument(() => {
const fetch = window.fetch.bind(window);
window.__flightPolls = 0;
window.fetch = (input, options) => {
const path = new URL(input.url ?? input, document.baseURI).pathname;
const now = Math.floor(Date.now() / 1000);
if (path === '/api/flights') {
window.__flightPolls++;
return Promise.resolve(
Response.json({
time: now,
states: [
[
'71be26',
'GEV123',
'QA',
now,
now,
-125.5779,
40.2539,
9700,
false,
200,
45,
0,
null,
9700,
null,
false,
0,
],
],
}),
);
}
if (path === '/api/military')
return Promise.resolve(Response.json({ now: Date.now(), ac: [] }));
if (path === '/api/flights/track')
return Promise.resolve(Response.json({ path: [] }));
if (path.startsWith('/api/flights/') || path.startsWith('/api/openai/'))
return Promise.resolve(Response.json({}));
return fetch(input, options);
};
});
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
const ready = (kind) =>
page.waitForFunction(
(engine) =>
window.__godsEyeView?.engine.kind === engine &&
!document.querySelector('#globe-engine')?.disabled &&
document.querySelector('#loading-screen.hidden') &&
!document.querySelector('.engine-switch-frame'),
{ timeout: 90000 },
kind,
);
await ready('navara');
await page.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise,
);
await page.waitForSelector('#cockpit-entry', { visible: true });
await page.click('#cockpit-entry');
await page.waitForFunction(
() => window.__godsEyeView.styleManager.getCockpitState().active,
);
await page.evaluate(() => {
const { dataManager, styleManager, tileset } = window.__godsEyeView;
if (window.__GOOGLE_MAPS_API_KEY__ || tileset)
throw Error('Keyless preview required');
styleManager.cockpitView.setVisionMode('thermal');
const entry = dataManager.layers.get('flights');
window.__handoff = {
dataManager,
entry,
timer: entry.intervalId,
model: entry.module,
pollCount: window.__flightPolls,
timestamp: entry.module.getStats().lastUpdate,
started: Date.now(),
};
});
for (const kind of ['cesium', 'navara']) {
await page.select('#globe-engine', kind);
await ready(kind);
const result = await page.evaluate(() => {
const { dataManager, engine, styleManager } = window.__godsEyeView;
const before = window.__handoff;
const entry = dataManager.layers.get('flights');
const cockpit = styleManager.getCockpitState();
return {
sameOwner:
before.dataManager === dataManager &&
before.entry === entry &&
before.model === entry.module,
sameTimer:
before.timer === entry.intervalId && entry.intervalId != null,
sourcePolls: window.__flightPolls - before.pollCount,
elapsed: Date.now() - before.started,
stamp: entry.module.getStats().lastUpdate,
initialStamp: before.timestamp,
id: cockpit.subject?.id,
active: cockpit.active,
mode: cockpit.visionMode,
hud: !document.querySelector('#cockpit-hud').hidden,
drawn: entry.module.qaDrawn().some((row) => row.id === '71be26'),
height: engine.camera.height(),
};
});
assert.ok(
result.sameOwner &&
result.sameTimer &&
result.active &&
result.hud &&
result.drawn,
JSON.stringify(result),
);
assert.equal(result.id, '71be26');
assert.equal(result.mode, 'thermal');
if (result.elapsed < 30000) {
assert.equal(result.sourcePolls, 0);
assert.equal(result.stamp, result.initialStamp);
}
await page.waitForFunction(
() => Math.abs(window.__godsEyeView.engine.camera.height() - 9700) < 1000,
);
console.log(
`PASS ${kind} cockpit, aircraft, HUD, manager and timer (${result.sourcePolls} polls since entry)`,
);
}
await page.evaluate(() =>
window.__godsEyeView.styleManager.controlCockpit('exit'),
);
await page.waitForFunction(
() => !window.__godsEyeView.styleManager.getCockpitState().active,
);
assert.deepEqual(errors, []);
} finally {
await browser.close();
}
+33 -11
View File
@@ -66,6 +66,7 @@ export function createApplicationCatalog({
metadata = APPLICATION_LAYER_METADATA,
vesselOptions,
resolveAsset,
layerSession,
nepalBoundaryResolver,
streetLevelProviders,
engineKind = 'cesium',
@@ -79,30 +80,48 @@ export function createApplicationCatalog({
const sourceComposition = resolveCatalogSources(sources);
sources = sourceComposition.sources;
const militaryRegistry = createMilitaryRegistry();
const militaryRegistry =
layerSession?.militaryRegistry ?? createMilitaryRegistry();
const weatherClock = createWeatherClock();
const dispose = () => {
signal.removeEventListener('abort', dispose);
militaryRegistry.dispose();
if (!layerSession) militaryRegistry.dispose();
weatherClock.destroy();
};
signal.addEventListener('abort', dispose, { once: true });
try {
if (sourceComposition.isConfigured('military'))
militaryRegistry.configureSource(sources.military, { signal });
if (!layerSession?.militaryRegistry) {
const lifetime = layerSession?.lifetime.signal ?? signal;
if (sourceComposition.isConfigured('military'))
militaryRegistry.configureSource(sources.military, { signal: lifetime });
if (layerSession) {
layerSession.militaryRegistry = militaryRegistry;
lifetime.addEventListener('abort', militaryRegistry.dispose, {
once: true,
});
}
}
// Shared instances (awareness and launches read others) are made once,
// when the first layer that needs them is built.
const once = (create) => {
let value;
return () => (value ??= create());
};
const sessionModel = (id, create) => {
if (!layerSession) return create();
const models = (layerSession.models ??= new Map());
if (!models.has(id)) models.set(id, create());
return models.get(id);
};
const flights = once(() =>
createApplicationFlights({
surface,
source: sources.flights,
militaryRegistry,
resolveAsset,
}),
sessionModel('flights', () =>
createApplicationFlights({
surface,
source: sources.flights,
militaryRegistry,
resolveAsset,
}),
),
);
const military = once(() =>
createApplicationMilitary({
@@ -158,7 +177,10 @@ export function createApplicationCatalog({
],
[
'earthquakes',
() => createApplicationEarthquakes({ source: sources.earthquakes }),
() =>
sessionModel('earthquakes', () =>
createApplicationEarthquakes({ source: sources.earthquakes }),
),
],
[
'air-quality',
+9 -4
View File
@@ -8,12 +8,13 @@ export function createApplicationData({
controls: { styleManager },
catalog,
allowQaRegistration,
layerSession,
onData,
defer,
}) {
// Initialize data layer manager
// The modules get { signal, engine } on every call (viewer null on Navara).
const dataManager = new LayerLifecycle(viewer, {
const dataManager = layerSession?.manager ?? new LayerLifecycle(viewer, {
engine,
allowQaRegistration,
// A panel's host may report it hidden while it is on screen; its layers
@@ -21,7 +22,12 @@ export function createApplicationData({
visibilityTarget: panelAwareVisibility(document),
getSourceAvailability: catalog?.getSourceAvailability,
});
if (layerSession) layerSession.manager = dataManager;
defer(async () => {
if (layerSession && !layerSession.lifetime.signal.aborted) {
await dataManager.detachScene();
return;
}
await dataManager.destroyAll();
if (dataManager.layers.size)
throw new Error(
@@ -35,12 +41,11 @@ export function createApplicationData({
onData?.(dataManager);
if (!catalog?.layers || !catalog?.metadata)
throw new TypeError('An application layer catalog is required');
for (const layer of catalog.layers) dataManager.register(layer);
dataManager.bindCatalog(viewer, engine, catalog);
for (const layer of catalog.layers) layer.attachDataManager?.(dataManager);
for (const layer of catalog.layers)
layer.attachMapStackController?.(mapStackController);
// Restoration starts only after the caller's complete registry is sealed.
dataManager.finalizeRegistrations(catalog.metadata);
if (allowQaRegistration) {
window.__gevQaRegisterLayer = (targetManager, layerModule) => {
if (targetManager !== dataManager)
+12 -2
View File
@@ -29,12 +29,14 @@ export async function createApplicationScene({
signal,
defer,
rendererCache,
layerSession,
...options
}) {
const operations = createApplicationOperations({
const operations = layerSession?.operations ?? createApplicationOperations({
requests: requestServices,
signal,
signal: layerSession?.lifetime.signal ?? signal,
});
if (layerSession) layerSession.operations = operations;
defer(initLogoGaze());
const previousKey = window.__GOOGLE_MAPS_API_KEY__;
if (googleApiKey) {
@@ -55,6 +57,14 @@ export async function createApplicationScene({
const { viewer, engine, tileset, mapStackController } = rendererCache
? await rendererCache.acquire(options.engineKind, build, defer)
: await build({ defer });
if (layerSession) {
// DEM cells belong to the session. Mesh samples came from the outgoing
// scene, including when the incoming canvas was warm in the cache.
operations.surface.groundFloor.clearMeshFloorCells();
operations.surface.groundFloor.setMeshFloorPreferred(
mapStackController.getActiveId() === 'photoreal',
);
}
return { viewer, engine, tileset, mapStackController, operations };
}
+1 -1
View File
@@ -412,7 +412,7 @@ export function contextRestoreLayerIds(snapshot = {}) {
}
/**
* Contacts mode requires both observed-flight feeds. Navara also supports
* Contacts mode requires both observed-flight feeds. Both globes support
* first-person flight directly from a single selected aircraft layer.
*
* @param {object} input Current context and dependency visibility.
+30
View File
@@ -470,6 +470,36 @@ export class LayerStateCoordinator {
return null;
}
// On an in-page engine switch, an attached session model can already own
// this exact contact. Ordinary shared links must still verify the source.
retainedTrackingTarget() {
const selected = this._selectedShareTrackingTarget();
if (!selected || !this.dataManager.isEnabled(selected.layerId)) return null;
const model = this.dataManager.layers.get(selected.layerId)?.module;
if (
model?.attachRenderer &&
model.getParams?.()[selected.optionKey] === selected.targetId &&
model.hasContact?.(selected.targetId) === true
) return selected;
return null;
}
resumeRetainedTracking(previous) {
const selected = this.retainedTrackingTarget();
if (
!selected || selected.layerId !== previous.layerId ||
selected.targetId !== previous.targetId
) return null;
const model = this.dataManager.layers.get(selected.layerId).module;
const followed = model.trackById(selected.targetId, {
origin: LAYER_RESTORE_ORIGINS.share,
});
if (!followed) return null;
const result = { ...selected, status: 'found', classification: 'followed', cleared: false };
this._publishTrackingRestoreStatus(result);
return result;
}
_passivelyClearTrackingSelection(selected) {
const current =
this._durableState.options?.[selected.optionOwner]?.[selected.optionKey];
+44
View File
@@ -121,6 +121,50 @@ 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.
*/
async detachScene() {
for (const [id, entry] of [...this.layers]) {
await entry.toggleChain.catch(() => {});
if (entry.module.attachRenderer && entry.module.detachRenderer) {
if (entry.initialized) entry.module.detachRenderer(this.viewer);
} else if (!(await this.destroyLayer(id))) {
throw new Error(`Cannot detach layer ${id}`);
}
}
this.viewer = null;
this.engine = null;
this._registrationsFinalized = false;
this._registrationDispositions = null;
}
/** Bind the next catalog only after the preceding scene released its entries. */
bindCatalog(viewer, engine, catalog) {
if (this._registrationsFinalized) throw new Error('Scene already attached');
this.viewer = viewer;
this.engine = engine ?? engineFromViewer(viewer) ?? null;
this._catalogSourceAvailability = catalog.getSourceAvailability;
for (const layer of catalog.layers) {
const entry = this.layers.get(layer.id);
if (!entry) this.register(layer);
else {
if (entry.module !== layer) throw new Error(`Layer owner changed: ${layer.id}`);
if (entry.initialized) layer.attachRenderer(viewer, this._moduleContext());
}
}
// 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);
}
/** Register a synthetic layer after sealing in an explicitly dev-enabled manager. */
registerForQa(layerModule) {
if (!this._allowQaRegistration || !this._registrationsFinalized) {
+58 -32
View File
@@ -16,9 +16,9 @@ export { createUsgsEarthquakeSource } from './source.js';
/**
* Own one earthquake display and its refresh lifecycle.
* renderer.create({viewer, engine}) owns drawing and returns:
* setVisible(boolean), replace(discs), records(limit), destroy().
* setVisible(boolean), replace(discs), destroy().
* Discs are plain ECEF anchors, metre radii and Rgba colours; renderers keep
* source order and return plain raw analyst rows (model.mapAnalystRecord).
* source order. The layer retains observations for queries while detached.
*/
export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
if (typeof source?.getSnapshot !== 'function')
@@ -26,7 +26,10 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
if (!overlayHost) throw new TypeError('Earthquakes require an overlay host');
if (typeof renderer?.create !== 'function')
throw new TypeError('Earthquakes require a renderer factory');
let _viewer = null;
let initialized = false;
let observations = [];
let discs = [];
let labels = [];
let _request = null;
let _renderer = null;
let _count = 0;
@@ -34,6 +37,14 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
let _lastError = null;
let _enabled = false;
function publishLabels() {
overlayHost.setEntries(EARTHQUAKE_OVERLAY_SOURCE_ID, labels, {
cohortLimit: EARTHQUAKE_OVERLAY_COHORT_LIMIT,
collisionCapacity: EARTHQUAKE_OVERLAY_COLLISION_CAPACITY,
moving: false,
});
}
const layer = {
id: 'earthquakes',
name: 'Earthquakes (24h)',
@@ -42,17 +53,32 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
updateInterval: 60000,
init(viewer, { engine = null } = {}) {
if (_renderer) throw new Error('Earthquake layer is already initialized');
_viewer = viewer;
if (initialized) throw new Error('Earthquake layer is already initialized');
initialized = true;
try {
if (viewer || engine) this.attachRenderer(viewer, { engine });
} catch (error) {
this.destroy();
throw error;
}
console.log('[Data:Earthquakes] Initialized');
},
attachRenderer(viewer, { engine = null } = {}) {
if (!initialized || _renderer) throw new Error('Invalid earthquake scene attachment');
_renderer = renderer.create({ viewer, engine });
if (!_renderer) throw new Error('Earthquake renderer unavailable');
_renderer.setVisible(false);
_count = 0;
_lastUpdate = null;
_lastError = null;
_enabled = false;
_renderer.setVisible(_enabled);
if (discs.length) _renderer.replace(discs);
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, _enabled);
if (_enabled) publishLabels();
},
detachRenderer() {
overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID);
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false);
console.log('[Data:Earthquakes] Initialized');
_renderer?.destroy();
_renderer = null;
},
enable(viewer) {
@@ -60,7 +86,10 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
// No continuous-render hold: the discs are static geometry now, so the
// layer has no per-frame animator to keep the render loop alive for.
_renderer?.setVisible(true);
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, true);
if (_renderer) {
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, true);
if (labels.length) publishLabels();
}
},
disable(viewer) {
@@ -73,7 +102,7 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
},
async update(viewer) {
if (!_enabled || !_renderer) return false;
if (!_enabled) return false;
_request?.abort();
const request = new AbortController();
_request = request;
@@ -128,17 +157,12 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
);
}
_renderer.replace(nextDiscs);
if (_enabled) {
overlayHost.setEntries(
EARTHQUAKE_OVERLAY_SOURCE_ID,
selectEarthquakeOverlayCohort(overlayEntries),
{
cohortLimit: EARTHQUAKE_OVERLAY_COHORT_LIMIT,
collisionCapacity: EARTHQUAKE_OVERLAY_COLLISION_CAPACITY,
moving: false,
},
);
observations = rows;
discs = nextDiscs;
labels = selectEarthquakeOverlayCohort(overlayEntries);
if (_renderer) {
_renderer.replace(discs);
publishLabels();
}
_count = count;
@@ -157,15 +181,15 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
}
},
destroy(viewer = _viewer) {
destroy() {
_request?.abort();
_request = null;
_viewer = null;
initialized = false;
_enabled = false;
overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID);
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false);
_renderer?.destroy();
_renderer = null;
this.detachRenderer();
observations = [];
discs = [];
labels = [];
_count = 0;
_lastUpdate = null;
_lastError = null;
@@ -180,11 +204,13 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
*/
getAnalystRecords(maxCount = 2000) {
if (!_enabled || !_renderer) return [];
if (!_enabled) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
return _renderer.records(limit).map(mapAnalystRecord);
return observations.slice(0, limit).map((row, index) =>
mapAnalystRecord({ ...row, id: row.usgsId, depth: row.depthKm }, index),
);
},
getStats() {
+25
View File
@@ -92,3 +92,28 @@ test('USGS body completion honors cancellation even with an uncooperative transp
name: 'AbortError',
});
});
test('an observation received between scenes is queryable and draws without a second fetch', async () => {
let resolve, polls = 0;
const h = harness({ getSnapshot() {
polls++;
return new Promise(done => { resolve = done; });
} });
try {
const pending = h.layer.update();
h.layer.detachRenderer();
assert.equal(h.sources.length, 0);
resolve([row]);
assert.equal(await pending, true);
const [record] = h.layer.getAnalystRecords();
assert.equal(record.id, row.usgsId);
assert.equal(record.lat, row.lat);
assert.equal(h.events.length, 0);
h.layer.attachRenderer(h.viewer);
assert.equal(h.sources[0].entities.values.length, 1);
assert.equal(h.events.length, 1);
assert.equal(polls, 1);
} finally {
h.layer.destroy();
}
});
+26 -5
View File
@@ -15,11 +15,14 @@ let kind = pageEngineKind();
let application;
let busy = false;
const renderers = createRendererCache();
const layerSession = { lifetime: new AbortController() };
window.addEventListener('pagehide', (event) => {
if (!event.persisted) renderers.clear();
if (event.persisted) return;
layerSession.lifetime.abort();
void application?.destroy().catch(report).finally(() => renderers.clear());
});
async function start(next, engineSwitch = false) {
async function start(next, engineSwitch = false, cockpit = null) {
kind = next;
application = createStandaloneApplication({
googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY,
@@ -27,11 +30,25 @@ async function start(next, engineSwitch = false) {
allowQaRegistration: import.meta.env.DEV,
engineKind: next,
rendererCache: renderers,
layerSession,
engineSwitch,
});
const { scene, controls } = await application.start();
if (engineSwitch) {
await controls.styleManager.initialRestorePromise;
const style = controls.styleManager;
await style.initialRestorePromise;
// 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);
}
await waitForGlobeFrame(scene.engine);
}
}
@@ -106,12 +123,16 @@ async function reconcile(remember = false) {
present();
const previous = kind;
let cover;
let cockpit;
try {
while (pageEngineKind() !== kind) {
cover ??= keepGlobeFrame(application.getComponents().scene.engine);
cockpit = application
.getComponents()
.controls.styleManager.getCockpitState();
renderers.retain(kind);
await application.destroy();
await start(pageEngineKind(), true);
await start(pageEngineKind(), true, cockpit);
}
if (remember) {
try {
@@ -127,7 +148,7 @@ async function reconcile(remember = false) {
history.replaceState(history.state, '', url);
try {
await application.destroy();
await start(previous, true);
await start(previous, true, cockpit);
application
.getComponents()
.controls.styleManager._showToast(describeError(error));
+3 -2
View File
@@ -500,7 +500,7 @@ export function createGroundFloor({ terrain, signal }) {
}
/** Test hook: clears mesh cells (never called at runtime — cells are valid all session). */
function _clearMeshFloorCellsForTest() {
function clearMeshFloorCells() {
_meshCells.clear();
}
@@ -663,7 +663,8 @@ export function createGroundFloor({ terrain, signal }) {
meshFloorSampleWithinPrior,
reportValidatedMeshFloorCell,
cachedMeshFloor,
_clearMeshFloorCellsForTest,
clearMeshFloorCells,
_clearMeshFloorCellsForTest: clearMeshFloorCells,
cachedGroundFloor,
FLOOR_RESOLVE_DEADLINE_MS,
resolveGroundFloorCellsBounded,
+4 -1
View File
@@ -42,6 +42,7 @@ export function createStandaloneApplication({
allowQaRegistration = false,
engineKind = pageEngineKind(),
rendererCache,
layerSession,
engineSwitch = false,
}) {
if (owner)
@@ -89,6 +90,7 @@ export function createStandaloneApplication({
loaderStatus,
engineKind,
rendererCache,
layerSession,
});
// The catalog of this engine: on Navara only the layers with a Navara
// renderer (src/app/layers/renderers.js; none yet), the others' state
@@ -108,6 +110,7 @@ export function createStandaloneApplication({
: {};
catalog = createStandaloneCatalog({
engineKind,
layerSession,
sources: { ...sources, ...qaSources },
streetLevelProviders,
nepalBoundaryResolver: (signal) =>
@@ -132,7 +135,7 @@ export function createStandaloneApplication({
catalog,
}),
createData: (context) =>
createStandaloneData({ ...context, allowQaRegistration, catalog }),
createStandaloneData({ ...context, allowQaRegistration, catalog, layerSession }),
createTools: async (context) => {
const tools = await createStandaloneTools({
...context,
+2
View File
@@ -8,6 +8,7 @@ export { createStandaloneReferenceSources } from './layerSources.js';
export function createStandaloneCatalog({
nepalBoundaryResolver,
engineKind = 'cesium',
layerSession,
sources = {},
streetLevelProviders,
signal = new AbortController().signal,
@@ -18,6 +19,7 @@ export function createStandaloneCatalog({
} = {}) {
return createApplicationCatalog({
engineKind,
layerSession,
nepalBoundaryResolver,
surface,
sources: createStandaloneLayerSources(sources),
+3 -3
View File
@@ -37,7 +37,7 @@ export class CockpitCoordinator {
operations,
);
this._cockpitDisplayPortal = null;
this.cockpitView = viewer || engine?.kind === 'navara'
this.cockpitView = viewer || (engine?.frames?.preUpdate && engine?.camera?.followChanged)
? this._createCockpitView(viewer, services)
: null;
}
@@ -88,7 +88,7 @@ export class CockpitCoordinator {
this._stampNavigation({ cancelPendingSelection: false }),
isEntryAllowed: () =>
cockpitEntryAllowed({
standaloneAircraft: this.engine?.kind === 'navara',
standaloneAircraft: true,
contextMode: this._contextMode,
contextModeChanging: this._contextModeChanging,
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
@@ -279,7 +279,7 @@ export class CockpitCoordinator {
action: 'control_cockpit',
error: this._contextModeChanging
? 'Contacts is still starting up — try Cockpit again in a moment'
: this.engine?.kind === 'navara'
: !this._contextMode
? 'Enable an aircraft layer and track a flight to enter Cockpit'
: 'Contacts must be active to enter Cockpit — say "open contacts" first',
state: this.getCockpitState(),
+2 -2
View File
@@ -254,7 +254,7 @@ export class NavigationController {
});
}
_reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) {
_reassertNavigationHandoff(generation, { trackingOrigin = 'tool', keepTracking = false } = {}) {
return reassertNavigationHandoff({
generation,
currentGeneration: this._navigationGeneration,
@@ -266,7 +266,7 @@ export class NavigationController {
// was refused by the cockpit leaves the old readout standing.
release: () => {
this.clearLocation();
return this._releaseFollowCamera({ trackingOrigin });
if (!keepTracking) return this._releaseFollowCamera({ trackingOrigin });
},
});
}
+9 -3
View File
@@ -174,12 +174,17 @@ export class ShareRestoration {
this._initialShareRestoreTimeout = null;
if (this._disposed) return;
const generation = this._initialShareNavigationGeneration;
const retained = engineSwitch && this._layerStateCoordinator?.retainedTrackingTarget();
const applyCamera =
Number.isInteger(generation) &&
this.navigation._reassertNavigationHandoff(generation);
this.navigation._reassertNavigationHandoff(generation, {
trackingOrigin: engineSwitch ? 'share-restore' : 'tool',
keepTracking: Boolean(retained),
});
void this._runShareRestore(savedState, {
applyCamera,
instant: engineSwitch,
retained,
navigationToken: generation,
layersPromise: this._layerStateRestorePromise,
settle: (result) => this._settleInitialShareRestore(result),
@@ -238,7 +243,7 @@ export class ShareRestoration {
*/
async _runShareRestore(
state,
{ applyCamera, instant = false, navigationToken, layersPromise, settle },
{ applyCamera, instant = false, retained, navigationToken, layersPromise, settle },
) {
const generation = this._shareRestoreGeneration;
const current = () =>
@@ -253,7 +258,8 @@ export class ShareRestoration {
if (!current()) return;
const tracking =
share.camera === 'applied'
? await this._layerStateCoordinator?.restoreShareTrackingSelection?.()
? (retained && this._layerStateCoordinator.resumeRetainedTracking(retained)) ||
await this._layerStateCoordinator?.restoreShareTrackingSelection?.()
: {
status: 'superseded',
cleared: