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.waitForSelector('#globe-engine', { visible: true });
await page.evaluate(() => { await page.evaluate(() => {
window.__switchDocument = document; window.__switchDocument = document;
window.__quakeEntry = window.__godsEyeView.dataManager.layers.get('earthquakes');
window.__quakeTimer = window.__quakeEntry.intervalId;
window.__renderers ??= new Map(); window.__renderers ??= new Map();
window.__renderers.set( window.__renderers.set(
window.__godsEyeView.engine.kind, window.__godsEyeView.engine.kind,
@@ -93,6 +95,8 @@ try {
const previous = window.__renderers.get(engine.kind); const previous = window.__renderers.get(engine.kind);
return ( return (
!window.__switchSplash && !window.__switchSplash &&
window.__quakeEntry === window.__godsEyeView.dataManager.layers.get('earthquakes') &&
window.__quakeTimer === window.__quakeEntry.intervalId &&
(!previous || previous === engine) && (!previous || previous === engine) &&
!window.__outgoingEngine.isDestroyed() && !window.__outgoingEngine.isDestroyed() &&
!window.__outgoingEngine.host.canvas.isConnected && !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, metadata = APPLICATION_LAYER_METADATA,
vesselOptions, vesselOptions,
resolveAsset, resolveAsset,
layerSession,
nepalBoundaryResolver, nepalBoundaryResolver,
streetLevelProviders, streetLevelProviders,
engineKind = 'cesium', engineKind = 'cesium',
@@ -79,30 +80,48 @@ export function createApplicationCatalog({
const sourceComposition = resolveCatalogSources(sources); const sourceComposition = resolveCatalogSources(sources);
sources = sourceComposition.sources; sources = sourceComposition.sources;
const militaryRegistry = createMilitaryRegistry(); const militaryRegistry =
layerSession?.militaryRegistry ?? createMilitaryRegistry();
const weatherClock = createWeatherClock(); const weatherClock = createWeatherClock();
const dispose = () => { const dispose = () => {
signal.removeEventListener('abort', dispose); signal.removeEventListener('abort', dispose);
militaryRegistry.dispose(); if (!layerSession) militaryRegistry.dispose();
weatherClock.destroy(); weatherClock.destroy();
}; };
signal.addEventListener('abort', dispose, { once: true }); signal.addEventListener('abort', dispose, { once: true });
try { try {
if (sourceComposition.isConfigured('military')) if (!layerSession?.militaryRegistry) {
militaryRegistry.configureSource(sources.military, { signal }); 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, // Shared instances (awareness and launches read others) are made once,
// when the first layer that needs them is built. // when the first layer that needs them is built.
const once = (create) => { const once = (create) => {
let value; let value;
return () => (value ??= create()); 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(() => const flights = once(() =>
createApplicationFlights({ sessionModel('flights', () =>
surface, createApplicationFlights({
source: sources.flights, surface,
militaryRegistry, source: sources.flights,
resolveAsset, militaryRegistry,
}), resolveAsset,
}),
),
); );
const military = once(() => const military = once(() =>
createApplicationMilitary({ createApplicationMilitary({
@@ -158,7 +177,10 @@ export function createApplicationCatalog({
], ],
[ [
'earthquakes', 'earthquakes',
() => createApplicationEarthquakes({ source: sources.earthquakes }), () =>
sessionModel('earthquakes', () =>
createApplicationEarthquakes({ source: sources.earthquakes }),
),
], ],
[ [
'air-quality', 'air-quality',
+9 -4
View File
@@ -8,12 +8,13 @@ export function createApplicationData({
controls: { styleManager }, controls: { styleManager },
catalog, catalog,
allowQaRegistration, allowQaRegistration,
layerSession,
onData, onData,
defer, defer,
}) { }) {
// Initialize data layer manager // Initialize data layer manager
// The modules get { signal, engine } on every call (viewer null on Navara). // 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, engine,
allowQaRegistration, allowQaRegistration,
// A panel's host may report it hidden while it is on screen; its layers // 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), visibilityTarget: panelAwareVisibility(document),
getSourceAvailability: catalog?.getSourceAvailability, getSourceAvailability: catalog?.getSourceAvailability,
}); });
if (layerSession) layerSession.manager = dataManager;
defer(async () => { defer(async () => {
if (layerSession && !layerSession.lifetime.signal.aborted) {
await dataManager.detachScene();
return;
}
await dataManager.destroyAll(); await dataManager.destroyAll();
if (dataManager.layers.size) if (dataManager.layers.size)
throw new Error( throw new Error(
@@ -35,12 +41,11 @@ export function createApplicationData({
onData?.(dataManager); onData?.(dataManager);
if (!catalog?.layers || !catalog?.metadata) if (!catalog?.layers || !catalog?.metadata)
throw new TypeError('An application layer catalog is required'); throw new TypeError('An application layer catalog is required');
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.attachDataManager?.(dataManager);
for (const layer of catalog.layers) for (const layer of catalog.layers)
layer.attachMapStackController?.(mapStackController); layer.attachMapStackController?.(mapStackController);
// Restoration starts only after the caller's complete registry is sealed.
dataManager.finalizeRegistrations(catalog.metadata);
if (allowQaRegistration) { if (allowQaRegistration) {
window.__gevQaRegisterLayer = (targetManager, layerModule) => { window.__gevQaRegisterLayer = (targetManager, layerModule) => {
if (targetManager !== dataManager) if (targetManager !== dataManager)
+12 -2
View File
@@ -29,12 +29,14 @@ export async function createApplicationScene({
signal, signal,
defer, defer,
rendererCache, rendererCache,
layerSession,
...options ...options
}) { }) {
const operations = createApplicationOperations({ const operations = layerSession?.operations ?? createApplicationOperations({
requests: requestServices, requests: requestServices,
signal, signal: layerSession?.lifetime.signal ?? signal,
}); });
if (layerSession) layerSession.operations = operations;
defer(initLogoGaze()); defer(initLogoGaze());
const previousKey = window.__GOOGLE_MAPS_API_KEY__; const previousKey = window.__GOOGLE_MAPS_API_KEY__;
if (googleApiKey) { if (googleApiKey) {
@@ -55,6 +57,14 @@ export async function createApplicationScene({
const { viewer, engine, tileset, mapStackController } = rendererCache const { viewer, engine, tileset, mapStackController } = rendererCache
? await rendererCache.acquire(options.engineKind, build, defer) ? await rendererCache.acquire(options.engineKind, build, defer)
: await 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 }; 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. * first-person flight directly from a single selected aircraft layer.
* *
* @param {object} input Current context and dependency visibility. * @param {object} input Current context and dependency visibility.
+30
View File
@@ -470,6 +470,36 @@ export class LayerStateCoordinator {
return null; 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) { _passivelyClearTrackingSelection(selected) {
const current = const current =
this._durableState.options?.[selected.optionOwner]?.[selected.optionKey]; this._durableState.options?.[selected.optionOwner]?.[selected.optionKey];
+44
View File
@@ -121,6 +121,50 @@ export class LayerLifecycle {
this._registerLayer(layerModule); 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. */ /** Register a synthetic layer after sealing in an explicitly dev-enabled manager. */
registerForQa(layerModule) { registerForQa(layerModule) {
if (!this._allowQaRegistration || !this._registrationsFinalized) { 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. * Own one earthquake display and its refresh lifecycle.
* renderer.create({viewer, engine}) owns drawing and returns: * 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 * 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 } = {}) { export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
if (typeof source?.getSnapshot !== 'function') 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 (!overlayHost) throw new TypeError('Earthquakes require an overlay host');
if (typeof renderer?.create !== 'function') if (typeof renderer?.create !== 'function')
throw new TypeError('Earthquakes require a renderer factory'); throw new TypeError('Earthquakes require a renderer factory');
let _viewer = null; let initialized = false;
let observations = [];
let discs = [];
let labels = [];
let _request = null; let _request = null;
let _renderer = null; let _renderer = null;
let _count = 0; let _count = 0;
@@ -34,6 +37,14 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
let _lastError = null; let _lastError = null;
let _enabled = false; 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 = { const layer = {
id: 'earthquakes', id: 'earthquakes',
name: 'Earthquakes (24h)', name: 'Earthquakes (24h)',
@@ -42,17 +53,32 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
updateInterval: 60000, updateInterval: 60000,
init(viewer, { engine = null } = {}) { init(viewer, { engine = null } = {}) {
if (_renderer) throw new Error('Earthquake layer is already initialized'); if (initialized) throw new Error('Earthquake layer is already initialized');
_viewer = viewer; 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 }); _renderer = renderer.create({ viewer, engine });
if (!_renderer) throw new Error('Earthquake renderer unavailable'); if (!_renderer) throw new Error('Earthquake renderer unavailable');
_renderer.setVisible(false); _renderer.setVisible(_enabled);
_count = 0; if (discs.length) _renderer.replace(discs);
_lastUpdate = null; overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, _enabled);
_lastError = null; if (_enabled) publishLabels();
_enabled = false; },
detachRenderer() {
overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID);
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false);
console.log('[Data:Earthquakes] Initialized'); _renderer?.destroy();
_renderer = null;
}, },
enable(viewer) { enable(viewer) {
@@ -60,7 +86,10 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
// No continuous-render hold: the discs are static geometry now, so the // 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. // layer has no per-frame animator to keep the render loop alive for.
_renderer?.setVisible(true); _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) { disable(viewer) {
@@ -73,7 +102,7 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
}, },
async update(viewer) { async update(viewer) {
if (!_enabled || !_renderer) return false; if (!_enabled) return false;
_request?.abort(); _request?.abort();
const request = new AbortController(); const request = new AbortController();
_request = request; _request = request;
@@ -128,17 +157,12 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
); );
} }
_renderer.replace(nextDiscs); observations = rows;
if (_enabled) { discs = nextDiscs;
overlayHost.setEntries( labels = selectEarthquakeOverlayCohort(overlayEntries);
EARTHQUAKE_OVERLAY_SOURCE_ID, if (_renderer) {
selectEarthquakeOverlayCohort(overlayEntries), _renderer.replace(discs);
{ publishLabels();
cohortLimit: EARTHQUAKE_OVERLAY_COHORT_LIMIT,
collisionCapacity: EARTHQUAKE_OVERLAY_COLLISION_CAPACITY,
moving: false,
},
);
} }
_count = count; _count = count;
@@ -157,15 +181,15 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
} }
}, },
destroy(viewer = _viewer) { destroy() {
_request?.abort(); _request?.abort();
_request = null; _request = null;
_viewer = null; initialized = false;
_enabled = false; _enabled = false;
overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID); this.detachRenderer();
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); observations = [];
_renderer?.destroy(); discs = [];
_renderer = null; labels = [];
_count = 0; _count = 0;
_lastUpdate = null; _lastUpdate = null;
_lastError = null; _lastError = null;
@@ -180,11 +204,13 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
* @returns {Array<Object>} See mapAnalystRecord for the record shape. * @returns {Array<Object>} See mapAnalystRecord for the record shape.
*/ */
getAnalystRecords(maxCount = 2000) { getAnalystRecords(maxCount = 2000) {
if (!_enabled || !_renderer) return []; if (!_enabled) return [];
const limit = Number.isFinite(maxCount) const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount)) ? Math.max(1, Math.floor(maxCount))
: 2000; : 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() { getStats() {
+25
View File
@@ -92,3 +92,28 @@ test('USGS body completion honors cancellation even with an uncooperative transp
name: 'AbortError', 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 application;
let busy = false; let busy = false;
const renderers = createRendererCache(); const renderers = createRendererCache();
const layerSession = { lifetime: new AbortController() };
window.addEventListener('pagehide', (event) => { 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; kind = next;
application = createStandaloneApplication({ application = createStandaloneApplication({
googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY, googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY,
@@ -27,11 +30,25 @@ async function start(next, engineSwitch = false) {
allowQaRegistration: import.meta.env.DEV, allowQaRegistration: import.meta.env.DEV,
engineKind: next, engineKind: next,
rendererCache: renderers, rendererCache: renderers,
layerSession,
engineSwitch, engineSwitch,
}); });
const { scene, controls } = await application.start(); const { scene, controls } = await application.start();
if (engineSwitch) { 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); await waitForGlobeFrame(scene.engine);
} }
} }
@@ -106,12 +123,16 @@ async function reconcile(remember = false) {
present(); present();
const previous = kind; const previous = kind;
let cover; let cover;
let cockpit;
try { try {
while (pageEngineKind() !== kind) { while (pageEngineKind() !== kind) {
cover ??= keepGlobeFrame(application.getComponents().scene.engine); cover ??= keepGlobeFrame(application.getComponents().scene.engine);
cockpit = application
.getComponents()
.controls.styleManager.getCockpitState();
renderers.retain(kind); renderers.retain(kind);
await application.destroy(); await application.destroy();
await start(pageEngineKind(), true); await start(pageEngineKind(), true, cockpit);
} }
if (remember) { if (remember) {
try { try {
@@ -127,7 +148,7 @@ async function reconcile(remember = false) {
history.replaceState(history.state, '', url); history.replaceState(history.state, '', url);
try { try {
await application.destroy(); await application.destroy();
await start(previous, true); await start(previous, true, cockpit);
application application
.getComponents() .getComponents()
.controls.styleManager._showToast(describeError(error)); .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). */ /** Test hook: clears mesh cells (never called at runtime — cells are valid all session). */
function _clearMeshFloorCellsForTest() { function clearMeshFloorCells() {
_meshCells.clear(); _meshCells.clear();
} }
@@ -663,7 +663,8 @@ export function createGroundFloor({ terrain, signal }) {
meshFloorSampleWithinPrior, meshFloorSampleWithinPrior,
reportValidatedMeshFloorCell, reportValidatedMeshFloorCell,
cachedMeshFloor, cachedMeshFloor,
_clearMeshFloorCellsForTest, clearMeshFloorCells,
_clearMeshFloorCellsForTest: clearMeshFloorCells,
cachedGroundFloor, cachedGroundFloor,
FLOOR_RESOLVE_DEADLINE_MS, FLOOR_RESOLVE_DEADLINE_MS,
resolveGroundFloorCellsBounded, resolveGroundFloorCellsBounded,
+4 -1
View File
@@ -42,6 +42,7 @@ export function createStandaloneApplication({
allowQaRegistration = false, allowQaRegistration = false,
engineKind = pageEngineKind(), engineKind = pageEngineKind(),
rendererCache, rendererCache,
layerSession,
engineSwitch = false, engineSwitch = false,
}) { }) {
if (owner) if (owner)
@@ -89,6 +90,7 @@ export function createStandaloneApplication({
loaderStatus, loaderStatus,
engineKind, engineKind,
rendererCache, rendererCache,
layerSession,
}); });
// The catalog of this engine: on Navara only the layers with a Navara // The catalog of this engine: on Navara only the layers with a Navara
// renderer (src/app/layers/renderers.js; none yet), the others' state // renderer (src/app/layers/renderers.js; none yet), the others' state
@@ -108,6 +110,7 @@ export function createStandaloneApplication({
: {}; : {};
catalog = createStandaloneCatalog({ catalog = createStandaloneCatalog({
engineKind, engineKind,
layerSession,
sources: { ...sources, ...qaSources }, sources: { ...sources, ...qaSources },
streetLevelProviders, streetLevelProviders,
nepalBoundaryResolver: (signal) => nepalBoundaryResolver: (signal) =>
@@ -132,7 +135,7 @@ export function createStandaloneApplication({
catalog, catalog,
}), }),
createData: (context) => createData: (context) =>
createStandaloneData({ ...context, allowQaRegistration, catalog }), createStandaloneData({ ...context, allowQaRegistration, catalog, layerSession }),
createTools: async (context) => { createTools: async (context) => {
const tools = await createStandaloneTools({ const tools = await createStandaloneTools({
...context, ...context,
+2
View File
@@ -8,6 +8,7 @@ export { createStandaloneReferenceSources } from './layerSources.js';
export function createStandaloneCatalog({ export function createStandaloneCatalog({
nepalBoundaryResolver, nepalBoundaryResolver,
engineKind = 'cesium', engineKind = 'cesium',
layerSession,
sources = {}, sources = {},
streetLevelProviders, streetLevelProviders,
signal = new AbortController().signal, signal = new AbortController().signal,
@@ -18,6 +19,7 @@ export function createStandaloneCatalog({
} = {}) { } = {}) {
return createApplicationCatalog({ return createApplicationCatalog({
engineKind, engineKind,
layerSession,
nepalBoundaryResolver, nepalBoundaryResolver,
surface, surface,
sources: createStandaloneLayerSources(sources), sources: createStandaloneLayerSources(sources),
+3 -3
View File
@@ -37,7 +37,7 @@ export class CockpitCoordinator {
operations, operations,
); );
this._cockpitDisplayPortal = null; this._cockpitDisplayPortal = null;
this.cockpitView = viewer || engine?.kind === 'navara' this.cockpitView = viewer || (engine?.frames?.preUpdate && engine?.camera?.followChanged)
? this._createCockpitView(viewer, services) ? this._createCockpitView(viewer, services)
: null; : null;
} }
@@ -88,7 +88,7 @@ export class CockpitCoordinator {
this._stampNavigation({ cancelPendingSelection: false }), this._stampNavigation({ cancelPendingSelection: false }),
isEntryAllowed: () => isEntryAllowed: () =>
cockpitEntryAllowed({ cockpitEntryAllowed({
standaloneAircraft: this.engine?.kind === 'navara', standaloneAircraft: true,
contextMode: this._contextMode, contextMode: this._contextMode,
contextModeChanging: this._contextModeChanging, contextModeChanging: this._contextModeChanging,
flightsEnabled: !!this._dataManager?.isEnabled('flights'), flightsEnabled: !!this._dataManager?.isEnabled('flights'),
@@ -279,7 +279,7 @@ export class CockpitCoordinator {
action: 'control_cockpit', action: 'control_cockpit',
error: this._contextModeChanging error: this._contextModeChanging
? 'Contacts is still starting up — try Cockpit again in a moment' ? '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' ? 'Enable an aircraft layer and track a flight to enter Cockpit'
: 'Contacts must be active to enter Cockpit — say "open contacts" first', : 'Contacts must be active to enter Cockpit — say "open contacts" first',
state: this.getCockpitState(), 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({ return reassertNavigationHandoff({
generation, generation,
currentGeneration: this._navigationGeneration, currentGeneration: this._navigationGeneration,
@@ -266,7 +266,7 @@ export class NavigationController {
// was refused by the cockpit leaves the old readout standing. // was refused by the cockpit leaves the old readout standing.
release: () => { release: () => {
this.clearLocation(); 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; this._initialShareRestoreTimeout = null;
if (this._disposed) return; if (this._disposed) return;
const generation = this._initialShareNavigationGeneration; const generation = this._initialShareNavigationGeneration;
const retained = engineSwitch && this._layerStateCoordinator?.retainedTrackingTarget();
const applyCamera = const applyCamera =
Number.isInteger(generation) && Number.isInteger(generation) &&
this.navigation._reassertNavigationHandoff(generation); this.navigation._reassertNavigationHandoff(generation, {
trackingOrigin: engineSwitch ? 'share-restore' : 'tool',
keepTracking: Boolean(retained),
});
void this._runShareRestore(savedState, { void this._runShareRestore(savedState, {
applyCamera, applyCamera,
instant: engineSwitch, instant: engineSwitch,
retained,
navigationToken: generation, navigationToken: generation,
layersPromise: this._layerStateRestorePromise, layersPromise: this._layerStateRestorePromise,
settle: (result) => this._settleInitialShareRestore(result), settle: (result) => this._settleInitialShareRestore(result),
@@ -238,7 +243,7 @@ export class ShareRestoration {
*/ */
async _runShareRestore( async _runShareRestore(
state, state,
{ applyCamera, instant = false, navigationToken, layersPromise, settle }, { applyCamera, instant = false, retained, navigationToken, layersPromise, settle },
) { ) {
const generation = this._shareRestoreGeneration; const generation = this._shareRestoreGeneration;
const current = () => const current = () =>
@@ -253,7 +258,8 @@ export class ShareRestoration {
if (!current()) return; if (!current()) return;
const tracking = const tracking =
share.camera === 'applied' share.camera === 'applied'
? await this._layerStateCoordinator?.restoreShareTrackingSelection?.() ? (retained && this._layerStateCoordinator.resumeRetainedTracking(retained)) ||
await this._layerStateCoordinator?.restoreShareTrackingSelection?.()
: { : {
status: 'superseded', status: 'superseded',
cleared: cleared: