mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +00:00
Retain live flights and earthquake observations across globe switches
This commit is contained in:
1 parent
a0e5f04c30
commit
c0062c4d3d
17 files changed
+428
-66
No files matched your search
@@ -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 &&
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user