From 8666705e12e81119b77d0910077b511d3e9c3e1a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 11 Sep 2026 17:03:35 -0700 Subject: [PATCH] style: adopt application modules into scoped formatting --- docs/APPLICATION.md | 12 +- docs/CODE-BOUNDARIES.md | 14 +- scripts/format-scope.json | 18 ++- scripts/package-boundaries.json | 24 +--- scripts/qa-application.mjs | 39 ++++-- src/app/application.js | 57 +++++++-- src/app/application.test.mjs | 136 +++++++++++++------- src/app/mapLifecycle.test.mjs | 29 ++++- src/app/startupCamera.test.mjs | 10 +- src/app/viewer.js | 17 +-- src/editions/local/application.js | 18 ++- src/editions/local/controls.js | 34 ++--- src/editions/local/data.js | 100 ++++++++------- src/editions/local/scene.js | 99 ++++++++------ src/editions/local/startupChrome.js | 23 +++- src/editions/local/startupChrome.test.mjs | 40 ++++-- src/editions/local/tools.js | 149 +++++++++++++--------- 17 files changed, 528 insertions(+), 291 deletions(-) diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index a4af4660b..4b218f686 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -6,12 +6,12 @@ listeners. Construction is also inactive until the caller invokes `start()`. The caller supplies four constructors, in this order: -| Constructor | Receives | Standalone implementation | -| --- | --- | --- | -| `createScene` | `signal`, `defer` | Viewer, attribution and initial map | -| `createControls` | `scene`, `signal`, `defer` | Style manager and camera presentation | -| `createData` | `scene`, `controls`, `signal`, `defer` | Layer catalog, registration and restoration | -| `createTools` | `scene`, `controls`, `data`, `signal`, `defer` | Scenes, annotations, voice and page listeners | +| Constructor | Receives | Standalone implementation | +| ---------------- | ---------------------------------------------- | --------------------------------------------- | +| `createScene` | `signal`, `defer` | Viewer, attribution and initial map | +| `createControls` | `scene`, `signal`, `defer` | Style manager and camera presentation | +| `createData` | `scene`, `controls`, `signal`, `defer` | Layer catalog, registration and restoration | +| `createTools` | `scene`, `controls`, `data`, `signal`, `defer` | Scenes, annotations, voice and page listeners | Each constructor returns its component object, or a promise for that object. Those objects are passed unchanged to later constructors. Configuration and diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 3bf1ac1c0..e4100230c 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -16,13 +16,13 @@ excluded. The formatter validates every entry before writing any file. ## Current component ownership -| Surface | Owns | Receives from its caller | -| -------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | -| `gods-eye-view/infrastructure` | Datacenter/dam definitions and fresh layer construction | Context, overlay and render operations | -| `gods-eye-view/infrastructure/geojson` | Data loading, Cesium entities, selection handling and resource cleanup | A viewer and those same operations | -| `gods-eye-view/infrastructure/lod` | Pure visibility budgets and selection policy | Position/visibility records and camera measurements | -| `src/data/localGeojson.js` | Standalone compatibility wiring | The application's existing shared services | -| `src/main.js`, `src/editions/local/` and `vite.config.js` | Standalone startup and local Node services | Local configuration | +| Surface | Owns | Receives from its caller | +| --------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | +| `gods-eye-view/infrastructure` | Datacenter/dam definitions and fresh layer construction | Context, overlay and render operations | +| `gods-eye-view/infrastructure/geojson` | Data loading, Cesium entities, selection handling and resource cleanup | A viewer and those same operations | +| `gods-eye-view/infrastructure/lod` | Pure visibility budgets and selection policy | Position/visibility records and camera measurements | +| `src/data/localGeojson.js` | Standalone compatibility wiring | The application's existing shared services | +| `src/main.js`, `src/editions/local/` and `vite.config.js` | Standalone startup and local Node services | Local configuration | The scoped package exports are browser source modules. Use their documented exports instead of importing standalone startup or reaching into internal files. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 647fffacf..fa8e0c755 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -11,5 +11,21 @@ "src/data/infrastructure.test.mjs", "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", - "docs/CODE-BOUNDARIES.md" + "docs/CODE-BOUNDARIES.md", + "src/main.js", + "docs/APPLICATION.md", + "scripts/qa-application.mjs", + "src/app/application.js", + "src/app/application.test.mjs", + "src/app/mapLifecycle.test.mjs", + "src/app/startupCamera.test.mjs", + "src/app/viewer.js", + "src/editions/local/application.js", + "src/editions/local/controls.js", + "src/editions/local/data.js", + "src/editions/local/errors.js", + "src/editions/local/scene.js", + "src/editions/local/startupChrome.js", + "src/editions/local/startupChrome.test.mjs", + "src/editions/local/tools.js" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c5b410813..86d15e7ff 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -10,28 +10,16 @@ "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "application": { - "exports": [ - "./application" - ], - "modules": [ - "src/app/application.js" - ], + "exports": ["./application"], + "modules": ["src/app/application.js"], "external": [] }, "viewer": { - "exports": [ - "./application/viewer" - ], - "modules": [ - "src/app/viewer.js" - ], - "external": [ - "cesium" - ] + "exports": ["./application/viewer"], + "modules": ["src/app/viewer.js"], + "external": ["cesium"] } } diff --git a/scripts/qa-application.mjs b/scripts/qa-application.mjs index ae173fda0..41b77c8dc 100644 --- a/scripts/qa-application.mjs +++ b/scripts/qa-application.mjs @@ -8,22 +8,30 @@ const urlIndex = args.indexOf('--url'); const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; const browser = await puppeteer.launch({ headless: true, - executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || await puppeteer.executablePath(), + executablePath: + process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()), args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], }); try { const page = await browser.newPage(); const errors = []; page.on('console', (message) => { - if (message.type() === 'warn' && /^\[Data\].*(?:destroy|disable) error:/.test(message.text())) { + if ( + message.type() === 'warn' && + /^\[Data\].*(?:destroy|disable) error:/.test(message.text()) + ) { console.log(message.text()); } }); page.on('pageerror', (error) => errors.push(error.stack || error.message)); await page.goto(`${url}/?welcome=1`, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction(() => window.__godsEyeView?.voiceCommands, { timeout: 60_000 }); + await page.waitForFunction(() => window.__godsEyeView?.voiceCommands, { + timeout: 60_000, + }); const before = await page.evaluate(async () => { - const entry = [...document.scripts].find((script) => /\/src\/main\.js(?:\?|$)/.test(script.src)); + const entry = [...document.scripts].find((script) => + /\/src\/main\.js(?:\?|$)/.test(script.src), + ); const { application } = await import(entry.src); window.__qaApplication = application; const app = window.__godsEyeView; @@ -32,7 +40,12 @@ try { await app.styleManager.initialRestorePromise; const enabled = await app.dataManager.setEnabled('local-datacenters', true); const annotation = await app.annotations.annotate([ - { type: 'pin', longitude: -97.74, latitude: 30.27, label: 'Lifecycle check' }, + { + type: 'pin', + longitude: -97.74, + latitude: 30.27, + label: 'Lifecycle check', + }, ]); return { status: application.getState().status, @@ -49,7 +62,9 @@ try { assert.equal(before.annotationCount, 1); assert.equal(before.viewerAlive, true); assert.equal(before.credits, true); - console.log('PASS: startup, data registration, annotations and visible attribution'); + console.log( + 'PASS: startup, data registration, annotations and visible attribution', + ); const after = await page.evaluate(async () => { const application = window.__qaApplication; @@ -57,7 +72,8 @@ try { const first = application.destroy(); const samePromise = first === application.destroy(); await first.catch((error) => { - const describe = (failure) => failure.errors?.map(describe).join('; ') || failure.message; + const describe = (failure) => + failure.errors?.map(describe).join('; ') || failure.message; throw new Error(describe(error)); }); document.dispatchEvent(new Event('visibilitychange')); @@ -71,7 +87,10 @@ try { layers: app.dataManager.layers.size, annotations: app.annotations.count(), governor: app.getRenderGovernorDiagnostics(), - handlesRemoved: !window.__godsEyeView && !window.__gevVoiceCommands && !window.__gevAnnotations, + handlesRemoved: + !window.__godsEyeView && + !window.__gevVoiceCommands && + !window.__gevAnnotations, creditsRemoved: !document.querySelector('#cesium-credits'), welcomeHidden: !document.querySelector('#first-run-launcher.visible'), settingsRemoved: !document.querySelector('#key-setup'), @@ -89,7 +108,9 @@ try { assert.equal(after.welcomeHidden, true); assert.equal(after.settingsRemoved, true); assert.deepEqual(errors, []); - console.log('PASS: terminal teardown releases runtime owners without late browser errors'); + console.log( + 'PASS: terminal teardown releases runtime owners without late browser errors', + ); } finally { await browser.close(); } diff --git a/src/app/application.js b/src/app/application.js index 4622d07cf..b9c37a8d2 100644 --- a/src/app/application.js +++ b/src/app/application.js @@ -9,10 +9,21 @@ const STOP_ORDER = ['tools', 'controls', 'data', 'scene']; * @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function}} constructors * @returns {{start:Function,destroy:Function,subscribe:Function,getState:Function,getComponents:Function}} */ -export function createApplication({ createScene, createControls, createData, createTools }) { - const factories = { scene: createScene, controls: createControls, data: createData, tools: createTools }; +export function createApplication({ + createScene, + createControls, + createData, + createTools, +}) { + const factories = { + scene: createScene, + controls: createControls, + data: createData, + tools: createTools, + }; for (const phase of START_ORDER) { - if (typeof factories[phase] !== 'function') throw new TypeError(`Missing ${phase} constructor`); + if (typeof factories[phase] !== 'function') + throw new TypeError(`Missing ${phase} constructor`); } const controller = new AbortController(); const cleanups = Object.fromEntries(START_ORDER.map((phase) => [phase, []])); @@ -26,7 +37,11 @@ export function createApplication({ createScene, createControls, createData, cre function publish(status, phase = null) { state = Object.freeze({ status, phase }); for (const listener of [...listeners]) { - try { listener(state); } catch { console.error('Application state listener failed'); } + try { + listener(state); + } catch { + console.error('Application state listener failed'); + } } } @@ -35,11 +50,16 @@ export function createApplication({ createScene, createControls, createData, cre const errors = []; for (const phase of STOP_ORDER) { while (cleanups[phase].length) { - try { await cleanups[phase].pop()(); } catch (error) { errors.push(error); } + try { + await cleanups[phase].pop()(); + } catch (error) { + errors.push(error); + } } delete components[phase]; } - if (errors.length) throw new AggregateError(errors, 'Application cleanup failed'); + if (errors.length) + throw new AggregateError(errors, 'Application cleanup failed'); }); return cleanupPromise; } @@ -57,7 +77,9 @@ export function createApplication({ createScene, createControls, createData, cre signal: controller.signal, defer(dispose) { if (!acceptingCleanup || typeof dispose !== 'function') { - throw new TypeError('Register cleanup during component construction'); + throw new TypeError( + 'Register cleanup during component construction', + ); } cleanups[phase].push(dispose); }, @@ -73,8 +95,13 @@ export function createApplication({ createScene, createControls, createData, cre } catch (error) { controller.abort(); let failure = error; - try { await cleanup(); } catch (cleanupError) { - failure = new AggregateError([error, cleanupError], 'Application startup and cleanup failed'); + try { + await cleanup(); + } catch (cleanupError) { + failure = new AggregateError( + [error, cleanupError], + 'Application startup and cleanup failed', + ); } if (!destroyPromise) publish('failed'); throw failure; @@ -83,7 +110,8 @@ export function createApplication({ createScene, createControls, createData, cre return Object.freeze({ start() { - if (destroyPromise) return Promise.reject(new Error('Application has been destroyed')); + if (destroyPromise) + return Promise.reject(new Error('Application has been destroyed')); // Assign before notifying subscribers, including reentrant start/destroy. startPromise ||= Promise.resolve().then(initialize); return startPromise; @@ -109,10 +137,15 @@ export function createApplication({ createScene, createControls, createData, cre getState: () => state, getComponents: () => Object.freeze({ ...components }), subscribe(listener) { - if (typeof listener !== 'function') throw new TypeError('Expected a state listener'); + if (typeof listener !== 'function') + throw new TypeError('Expected a state listener'); if (state.status === 'destroyed') return () => {}; listeners.add(listener); - try { listener(state); } catch { console.error('Application state listener failed'); } + try { + listener(state); + } catch { + console.error('Application state listener failed'); + } return () => listeners.delete(listener); }, }); diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index f5f42c12e..441667a12 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -6,31 +6,43 @@ import { createApplication } from './application.js'; const phases = ['Scene', 'Controls', 'Data', 'Tools']; function fixture(overrides = {}) { const events = []; - const constructors = Object.fromEntries(phases.map((phase) => [ - `create${phase}`, - ({ defer }) => { - events.push(`start:${phase}`); - defer(() => events.push(`stop:${phase}`)); - return { name: phase }; - }, - ])); + const constructors = Object.fromEntries( + phases.map((phase) => [ + `create${phase}`, + ({ defer }) => { + events.push(`start:${phase}`); + defer(() => events.push(`stop:${phase}`)); + return { name: phase }; + }, + ]), + ); return { events, app: createApplication({ ...constructors, ...overrides }) }; } function deferred() { let resolve; - const promise = new Promise((done) => { resolve = done; }); + const promise = new Promise((done) => { + resolve = done; + }); return { promise, resolve }; } test('importing and constructing the package is inactive without browser globals', () => { - const result = spawnSync(process.execPath, ['--input-type=module', '-e', ` + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + ` globalThis.fetch = () => { throw new Error('unexpected request'); }; globalThis.setTimeout = () => { throw new Error('unexpected timer'); }; const { createApplication } = await import('gods-eye-view/application'); const fail = () => { throw new Error('unexpected construction'); }; const app = createApplication({ createScene: fail, createControls: fail, createData: fail, createTools: fail }); if (app.getState().status !== 'created') process.exit(1); - `], { cwd: new URL('../../', import.meta.url), encoding: 'utf8' }); + `, + ], + { cwd: new URL('../../', import.meta.url), encoding: 'utf8' }, + ); assert.equal(result.status, 0, result.stderr); }); @@ -39,13 +51,15 @@ test('constructors are validated before allocating anything', () => { }); test('startup shares one promise and teardown keeps data alive for controls', async () => { - const { app, events } = fixture({ createTools({ scene, controls, data, defer }) { - assert.equal(scene.name, 'Scene'); - assert.equal(controls.name, 'Controls'); - assert.equal(data.name, 'Data'); - defer(() => events.push('stop:Tools')); - return { name: 'Tools' }; - } }); + const { app, events } = fixture({ + createTools({ scene, controls, data, defer }) { + assert.equal(scene.name, 'Scene'); + assert.equal(controls.name, 'Controls'); + assert.equal(data.name, 'Data'); + defer(() => events.push('stop:Tools')); + return { name: 'Tools' }; + }, + }); const states = []; app.subscribe((state) => states.push(`${state.status}:${state.phase}`)); const started = app.start(); @@ -57,7 +71,12 @@ test('startup shares one promise and teardown keeps data alive for controls', as const stopped = app.destroy(); assert.equal(stopped, app.destroy()); await stopped; - assert.deepEqual(events.slice(-4), ['stop:Tools', 'stop:Controls', 'stop:Data', 'stop:Scene']); + assert.deepEqual(events.slice(-4), [ + 'stop:Tools', + 'stop:Controls', + 'stop:Data', + 'stop:Scene', + ]); assert.deepEqual(app.getComponents(), {}); assert.equal(states.at(-1), 'destroyed:null'); await assert.rejects(app.start(), /destroyed/); @@ -65,16 +84,24 @@ test('startup shares one promise and teardown keeps data alive for controls', as for (const phase of phases) { test(`failure during ${phase} releases partially acquired resources`, async () => { - const { app, events } = fixture({ [`create${phase}`]({ defer }) { - defer(() => events.push('partial:released')); - throw new Error('broken constructor'); - } }); + const { app, events } = fixture({ + [`create${phase}`]({ defer }) { + defer(() => events.push('partial:released')); + throw new Error('broken constructor'); + }, + }); await assert.rejects(app.start(), /broken constructor/); - assert.equal(events.filter((value) => value === 'partial:released').length, 1); + assert.equal( + events.filter((value) => value === 'partial:released').length, + 1, + ); assert.equal(app.getState().status, 'failed'); assert.deepEqual(app.getComponents(), {}); await app.destroy(); - assert.equal(events.filter((value) => value === 'partial:released').length, 1); + assert.equal( + events.filter((value) => value === 'partial:released').length, + 1, + ); }); } @@ -91,18 +118,22 @@ test('destroy aborts an in-flight constructor and cleans late acquisitions befor const gate = deferred(); const entered = deferred(); let observedSignal; - const { app, events } = fixture({ async createScene({ signal, defer }) { - observedSignal = signal; - entered.resolve(); - await gate.promise; - defer(() => events.push('late:released')); - return {}; - } }); + const { app, events } = fixture({ + async createScene({ signal, defer }) { + observedSignal = signal; + entered.resolve(); + await gate.promise; + defer(() => events.push('late:released')); + return {}; + }, + }); const started = app.start(); const failed = assert.rejects(started, { name: 'AbortError' }); await entered.promise; let settled = false; - const stopped = app.destroy().then(() => { settled = true; }); + const stopped = app.destroy().then(() => { + settled = true; + }); assert.equal(observedSignal.aborted, true); await Promise.resolve(); assert.equal(settled, false); @@ -113,24 +144,35 @@ test('destroy aborts an in-flight constructor and cleans late acquisitions befor }); test('cleanup errors are reported after all remaining resources are attempted', async () => { - const { app, events } = fixture({ createTools({ defer }) { - defer(() => events.push('tools:earlier')); - defer(() => { throw new Error('dispose failed'); }); - return {}; - } }); + const { app, events } = fixture({ + createTools({ defer }) { + defer(() => events.push('tools:earlier')); + defer(() => { + throw new Error('dispose failed'); + }); + return {}; + }, + }); await app.start(); await assert.rejects(app.destroy(), (error) => { assert.ok(error instanceof AggregateError); assert.equal(error.errors[0].message, 'dispose failed'); return true; }); - assert.deepEqual(events.slice(-4), ['tools:earlier', 'stop:Controls', 'stop:Data', 'stop:Scene']); + assert.deepEqual(events.slice(-4), [ + 'tools:earlier', + 'stop:Controls', + 'stop:Data', + 'stop:Scene', + ]); assert.equal(app.getState().status, 'failed'); }); test('subscribers can destroy during startup without allowing the next constructor', async () => { const { app, events } = fixture(); - app.subscribe((state) => { if (state.phase === 'controls') void app.destroy(); }); + app.subscribe((state) => { + if (state.phase === 'controls') void app.destroy(); + }); await assert.rejects(app.start(), { name: 'AbortError' }); await app.destroy(); assert.deepEqual(events, ['start:Scene', 'stop:Scene']); @@ -138,7 +180,9 @@ test('subscribers can destroy during startup without allowing the next construct test('destroy during ready notification rejects startup and tears everything down', async () => { const { app } = fixture(); - app.subscribe((state) => { if (state.status === 'ready') void app.destroy(); }); + app.subscribe((state) => { + if (state.status === 'ready') void app.destroy(); + }); await assert.rejects(app.start(), { name: 'AbortError' }); await app.destroy(); assert.equal(app.getState().status, 'destroyed'); @@ -146,7 +190,12 @@ test('destroy during ready notification rejects startup and tears everything dow test('cleanup registration closes when a constructor settles', async () => { let register; - const { app } = fixture({ createScene({ defer }) { register = defer; return {}; } }); + const { app } = fixture({ + createScene({ defer }) { + register = defer; + return {}; + }, + }); await app.start(); assert.throws(() => register(() => {}), /during component construction/); await app.destroy(); @@ -163,7 +212,8 @@ test('different application instances do not share lifecycle state', async () => }); test('the separate viewer export imports without constructing a browser viewer', async () => { - const { createApplicationViewer } = await import('gods-eye-view/application/viewer'); + const { createApplicationViewer } = + await import('gods-eye-view/application/viewer'); assert.equal(typeof createApplicationViewer, 'function'); assert.throws(() => createApplicationViewer({}), /containers are required/); }); diff --git a/src/app/mapLifecycle.test.mjs b/src/app/mapLifecycle.test.mjs index 09b6747df..ef015bf52 100644 --- a/src/app/mapLifecycle.test.mjs +++ b/src/app/mapLifecycle.test.mjs @@ -2,17 +2,28 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { MapStackController } from '../mapStackController.js'; import { - installRenderGovernor, uninstallRenderGovernor, - holdContinuousRender, governorRequestRender, getRenderGovernorDiagnostics, + installRenderGovernor, + uninstallRenderGovernor, + holdContinuousRender, + governorRequestRender, + getRenderGovernorDiagnostics, } from '../renderGovernor.js'; test('destroy invalidates a pending imagery provider before it can touch the viewer', async () => { let resolveProvider; let removed = 0; const changes = []; - const controller = new MapStackController({}, { onChange: (state) => changes.push(state.status) }); - controller._getImageryProvider = () => new Promise((resolve) => { resolveProvider = resolve; }); - controller._removeImageryErrorListener = () => { removed++; }; + const controller = new MapStackController( + {}, + { onChange: (state) => changes.push(state.status) }, + ); + controller._getImageryProvider = () => + new Promise((resolve) => { + resolveProvider = resolve; + }); + controller._removeImageryErrorListener = () => { + removed++; + }; const switching = controller.setStack('osm'); controller.destroy(); resolveProvider({ provider: {} }); @@ -26,7 +37,13 @@ test('destroy invalidates a pending imagery provider before it can touch the vie test('uninstall only releases the current viewer and makes later render requests inactive', () => { let renders = 0; - const viewer = { scene: { requestRender: () => { renders++; } } }; + const viewer = { + scene: { + requestRender: () => { + renders++; + }, + }, + }; installRenderGovernor(viewer); holdContinuousRender('fixture'); uninstallRenderGovernor({}); diff --git a/src/app/startupCamera.test.mjs b/src/app/startupCamera.test.mjs index 7d943b631..721f9964b 100644 --- a/src/app/startupCamera.test.mjs +++ b/src/app/startupCamera.test.mjs @@ -8,7 +8,15 @@ test('teardown before the initial camera delay prevents a late flight', (t) => { let cancelled = 0; const stop = flyToAustin({ isDestroyed: () => false, - camera: { setView() {}, flyTo() { flights++; }, cancelFlight() { cancelled++; } }, + camera: { + setView() {}, + flyTo() { + flights++; + }, + cancelFlight() { + cancelled++; + }, + }, }); stop(); t.mock.timers.tick(1000); diff --git a/src/app/viewer.js b/src/app/viewer.js index 6d9bceb1e..0b5f720d0 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -2,7 +2,8 @@ import * as Cesium from 'cesium'; /** Create the standard globe viewer in caller-owned, visible containers. */ export function createApplicationViewer({ container, creditContainer }) { - if (!container || !creditContainer) throw new TypeError('Viewer and credit containers are required'); + if (!container || !creditContainer) + throw new TypeError('Viewer and credit containers are required'); const viewer = new Cesium.Viewer(container, { timeline: false, animation: false, @@ -21,13 +22,13 @@ export function createApplicationViewer({ container, creditContainer }) { contextOptions: { webgl: { preserveDrawingBuffer: true } }, }); try { - viewer.targetFrameRate = 60; - viewer.scene.globe.show = false; - viewer.scene.skyAtmosphere.show = true; - viewer.scene.skyAtmosphere.atmosphereLightIntensity = 18; - viewer.scene.skyAtmosphere.saturationShift = -0.12; - viewer.scene.skyAtmosphere.brightnessShift = -0.08; - return viewer; + viewer.targetFrameRate = 60; + viewer.scene.globe.show = false; + viewer.scene.skyAtmosphere.show = true; + viewer.scene.skyAtmosphere.atmosphereLightIntensity = 18; + viewer.scene.skyAtmosphere.saturationShift = -0.12; + viewer.scene.skyAtmosphere.brightnessShift = -0.08; + return viewer; } catch (error) { viewer.destroy(); throw error; diff --git a/src/editions/local/application.js b/src/editions/local/application.js index 19180a7e0..157728eb0 100644 --- a/src/editions/local/application.js +++ b/src/editions/local/application.js @@ -8,15 +8,23 @@ import { createLocalTools } from './tools.js'; let constructed = false; /** Compose the standalone application once per page. Reload to start again. */ -export function createLocalApplication({ googleApiKey, cesiumToken, allowQaRegistration = false }) { - if (constructed) throw new Error('The standalone application already owns this page'); +export function createLocalApplication({ + googleApiKey, + cesiumToken, + allowQaRegistration = false, +}) { + if (constructed) + throw new Error('The standalone application already owns this page'); constructed = true; const loadingScreen = document.getElementById('loading-screen'); const loaderStatus = loadingScreen.querySelector('.loader-status'); return createApplication({ - createScene: (context) => createLocalScene({ ...context, googleApiKey, cesiumToken, loaderStatus }), - createControls: (context) => createLocalControls({ ...context, loaderStatus }), - createData: (context) => createLocalData({ ...context, allowQaRegistration }), + createScene: (context) => + createLocalScene({ ...context, googleApiKey, cesiumToken, loaderStatus }), + createControls: (context) => + createLocalControls({ ...context, loaderStatus }), + createData: (context) => + createLocalData({ ...context, allowQaRegistration }), createTools: (context) => createLocalTools({ ...context, loadingScreen }), }); } diff --git a/src/editions/local/controls.js b/src/editions/local/controls.js index 8f3c6a27e..cff66a8c2 100644 --- a/src/editions/local/controls.js +++ b/src/editions/local/controls.js @@ -3,26 +3,30 @@ import { flyToAustin } from '../../camera.js'; import { initCockpitCloudEffects } from '../../cockpitCloudEffects.js'; /** Construct the existing controls and camera presentation. */ -export function createLocalControls({ scene: { viewer, mapStackController }, loaderStatus, defer }) { -// Initialize the style manager (post-processing, HUD, locations, share links) -const styleManager = new StyleManager(viewer, { mapStackController }); +export function createLocalControls({ + scene: { viewer, mapStackController }, + loaderStatus, + defer, +}) { + // Initialize the style manager (post-processing, HUD, locations, share links) + const styleManager = new StyleManager(viewer, { mapStackController }); defer(() => styleManager.orbitController.stop()); defer(() => styleManager.hud.destroy()); defer(() => styleManager.dispose()); -// The previous multi-canvas weather compositor remains disabled. Cockpit -// clouds use a separate, capped low-resolution GPU pass that never attaches -// Cesium fog or post-process stages and is fully stopped in map mode. -const weatherEffects = null; -const cockpitCloudEffects = initCockpitCloudEffects(viewer); + // The previous multi-canvas weather compositor remains disabled. Cockpit + // clouds use a separate, capped low-resolution GPU pass that never attaches + // Cesium fog or post-process stages and is fully stopped in map mode. + const weatherEffects = null; + const cockpitCloudEffects = initCockpitCloudEffects(viewer); defer(() => cockpitCloudEffects?.destroy()); -// If no share link state, do default fly-to Austin -if (!styleManager.hasShareState) { - loaderStatus.textContent = 'Flying to Austin, TX...'; - defer(flyToAustin(viewer)); -} else { - loaderStatus.textContent = 'Restoring shared view...'; -} + // If no share link state, do default fly-to Austin + if (!styleManager.hasShareState) { + loaderStatus.textContent = 'Flying to Austin, TX...'; + defer(flyToAustin(viewer)); + } else { + loaderStatus.textContent = 'Restoring shared view...'; + } return { styleManager, weatherEffects, cockpitCloudEffects }; } diff --git a/src/editions/local/data.js b/src/editions/local/data.js index d63add8fc..2fe7b309f 100644 --- a/src/editions/local/data.js +++ b/src/editions/local/data.js @@ -15,52 +15,64 @@ import localDataLayers from '../../data/localLayers.js'; import { LAYER_STATE_REGISTRY } from '../../data/layerState.js'; /** Register the standalone layer catalog before allowing state restoration. */ -export function createLocalData({ scene: { viewer }, controls: { styleManager }, allowQaRegistration, defer }) { -// Initialize data layer manager -const dataManager = new DataLayerManager(viewer, { +export function createLocalData({ + scene: { viewer }, + controls: { styleManager }, allowQaRegistration, -}); -defer(async () => { - await dataManager.destroyAll(); - if (dataManager.layers.size) throw new Error(`Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`); -}); -dataManager.register(flightsLayer); -dataManager.register(militaryFlightsLayer); -dataManager.register(earthquakesLayer); -dataManager.register(satellitesLayer); -dataManager.register(rocketLaunchesLayer); -rocketLaunchesLayer.attachDataManager(dataManager); -dataManager.register(trafficLayer); -dataManager.register(cctvLayer); -dataManager.register(radioLayer); -dataManager.register(bikeshareLayer); -dataManager.register(aisLiveVesselsLayer); -dataManager.register(militaryInstallationsLayer); -dataManager.register(militaryAwarenessLayer); -militaryAwarenessLayer.attachDataManager(dataManager); -for (const layer of localDataLayers) { - dataManager.register(layer); -} -// Restoration starts only after the complete production registry is sealed. -dataManager.finalizeRegistrations(LAYER_STATE_REGISTRY); -if (allowQaRegistration) { - window.__gevQaRegisterLayer = (targetManager, layerModule) => { - if (targetManager !== dataManager) throw new Error('QA layer manager mismatch'); - return dataManager.registerForQa(layerModule); - }; - window.__gevQaUnregisterLayer = (targetManager, layerId) => { - if (targetManager !== dataManager) throw new Error('QA layer manager mismatch'); - return dataManager.unregisterForQa(layerId); - }; - const register = window.__gevQaRegisterLayer; - const unregister = window.__gevQaUnregisterLayer; - defer(() => { - if (window.__gevQaRegisterLayer === register) delete window.__gevQaRegisterLayer; - if (window.__gevQaUnregisterLayer === unregister) delete window.__gevQaUnregisterLayer; + defer, +}) { + // Initialize data layer manager + const dataManager = new DataLayerManager(viewer, { + allowQaRegistration, }); -} -dataManager.buildTogglePanel(document.getElementById('data-toggles')); -styleManager.attachDataManager(dataManager); + defer(async () => { + await dataManager.destroyAll(); + if (dataManager.layers.size) + throw new Error( + `Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`, + ); + }); + dataManager.register(flightsLayer); + dataManager.register(militaryFlightsLayer); + dataManager.register(earthquakesLayer); + dataManager.register(satellitesLayer); + dataManager.register(rocketLaunchesLayer); + rocketLaunchesLayer.attachDataManager(dataManager); + dataManager.register(trafficLayer); + dataManager.register(cctvLayer); + dataManager.register(radioLayer); + dataManager.register(bikeshareLayer); + dataManager.register(aisLiveVesselsLayer); + dataManager.register(militaryInstallationsLayer); + dataManager.register(militaryAwarenessLayer); + militaryAwarenessLayer.attachDataManager(dataManager); + for (const layer of localDataLayers) { + dataManager.register(layer); + } + // Restoration starts only after the complete production registry is sealed. + dataManager.finalizeRegistrations(LAYER_STATE_REGISTRY); + if (allowQaRegistration) { + window.__gevQaRegisterLayer = (targetManager, layerModule) => { + if (targetManager !== dataManager) + throw new Error('QA layer manager mismatch'); + return dataManager.registerForQa(layerModule); + }; + window.__gevQaUnregisterLayer = (targetManager, layerId) => { + if (targetManager !== dataManager) + throw new Error('QA layer manager mismatch'); + return dataManager.unregisterForQa(layerId); + }; + const register = window.__gevQaRegisterLayer; + const unregister = window.__gevQaUnregisterLayer; + defer(() => { + if (window.__gevQaRegisterLayer === register) + delete window.__gevQaRegisterLayer; + if (window.__gevQaUnregisterLayer === unregister) + delete window.__gevQaUnregisterLayer; + }); + } + dataManager.buildTogglePanel(document.getElementById('data-toggles')); + styleManager.attachDataManager(dataManager); return { dataManager }; } diff --git a/src/editions/local/scene.js b/src/editions/local/scene.js index e707dbf31..7f6fcd4b2 100644 --- a/src/editions/local/scene.js +++ b/src/editions/local/scene.js @@ -9,7 +9,13 @@ import { uninstallRenderGovernor } from '../../renderGovernor.js'; import { describeError } from './errors.js'; /** Construct the standalone globe using the caller's local configuration. */ -export async function createLocalScene({ googleApiKey, cesiumToken, loaderStatus, signal, defer }) { +export async function createLocalScene({ + googleApiKey, + cesiumToken, + loaderStatus, + signal, + defer, +}) { defer(initLogoGaze()); const previousKey = window.__GOOGLE_MAPS_API_KEY__; if (googleApiKey) { @@ -26,21 +32,25 @@ export async function createLocalScene({ googleApiKey, cesiumToken, loaderStatus creditContainer.id = 'cesium-credits'; document.body.appendChild(creditContainer); defer(() => creditContainer.remove()); - const viewer = createApplicationViewer({ container: 'cesiumContainer', creditContainer }); + const viewer = createApplicationViewer({ + container: 'cesiumContainer', + creditContainer, + }); defer(() => { uninstallRenderGovernor(viewer); if (!viewer.isDestroyed()) viewer.destroy(); }); registerDataCredits(viewer); configureCreditKeyboardAccess(document); -loaderStatus.textContent = googleApiKey || cesiumToken - ? 'Loading Google 3D Tiles...' - : 'Loading the keyless globe...'; -const photoreal = await loadPhotorealisticTileset(Cesium, { - googleApiKey, - cesiumToken, -}); -const tileset = photoreal.tileset; + loaderStatus.textContent = + googleApiKey || cesiumToken + ? 'Loading Google 3D Tiles...' + : 'Loading the keyless globe...'; + const photoreal = await loadPhotorealisticTileset(Cesium, { + googleApiKey, + cesiumToken, + }); + const tileset = photoreal.tileset; // A provider can finish after cancellation; retain ownership of its result. defer(() => { if (tileset && !tileset.isDestroyed()) { @@ -48,40 +58,47 @@ const tileset = photoreal.tileset; } }); signal.throwIfAborted(); -if (tileset) { - viewer.scene.primitives.add(tileset); - // NOTE: Cesium World Terrain intentionally disabled — conflicts with Google 3D Tiles at high zoom. - // Google Photorealistic 3D Tiles provide their own terrain/elevation. - viewer.scene.globe.show = false; - console.info(`[Init] Google 3D Tiles loaded via ${photoreal.route}.`); -} else { - if (photoreal.errors.length) { - const tileError = photoreal.errors.at(-1); - console.warn('[Init] Google 3D Tiles unavailable, using the keyless globe:', tileError); - const tileErrorDetail = describeError(tileError); - loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`; + if (tileset) { + viewer.scene.primitives.add(tileset); + // NOTE: Cesium World Terrain intentionally disabled — conflicts with Google 3D Tiles at high zoom. + // Google Photorealistic 3D Tiles provide their own terrain/elevation. + viewer.scene.globe.show = false; + console.info(`[Init] Google 3D Tiles loaded via ${photoreal.route}.`); + } else { + if (photoreal.errors.length) { + const tileError = photoreal.errors.at(-1); + console.warn( + '[Init] Google 3D Tiles unavailable, using the keyless globe:', + tileError, + ); + const tileErrorDetail = describeError(tileError); + loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`; + } + viewer.scene.globe.show = true; } - viewer.scene.globe.show = true; -} -loaderStatus.textContent = 'Initializing systems...'; + loaderStatus.textContent = 'Initializing systems...'; -const mapStackController = new MapStackController(viewer, { - googleTileset: tileset, - cesiumToken, - initialStack: tileset ? 'photoreal' : 'esri-imagery', - // Task 5 (height-datum fix): rebroadcast stack changes as a window - // CustomEvent so data layers (CCTV per-regime ground resolution) can - // react without coupling MapStackController to layer modules. Fires on - // 'switching'/'ready'/'error'; listeners derive the surface regime from - // live scene state, so intermediate emissions are harmless. - onChange: (state) => { - window.dispatchEvent(new CustomEvent('gev:map-stack-changed', { detail: state })); - }, - onError: (message) => console.warn('[MapStack]', message), -}); -defer(() => mapStackController.destroy()); -await mapStackController.setStack(tileset ? 'photoreal' : 'esri-imagery', { silent: true }); + const mapStackController = new MapStackController(viewer, { + googleTileset: tileset, + cesiumToken, + initialStack: tileset ? 'photoreal' : 'esri-imagery', + // Task 5 (height-datum fix): rebroadcast stack changes as a window + // CustomEvent so data layers (CCTV per-regime ground resolution) can + // react without coupling MapStackController to layer modules. Fires on + // 'switching'/'ready'/'error'; listeners derive the surface regime from + // live scene state, so intermediate emissions are harmless. + onChange: (state) => { + window.dispatchEvent( + new CustomEvent('gev:map-stack-changed', { detail: state }), + ); + }, + onError: (message) => console.warn('[MapStack]', message), + }); + defer(() => mapStackController.destroy()); + await mapStackController.setStack(tileset ? 'photoreal' : 'esri-imagery', { + silent: true, + }); signal.throwIfAborted(); return { viewer, tileset, mapStackController }; diff --git a/src/editions/local/startupChrome.js b/src/editions/local/startupChrome.js index 1ad994afe..4c4af724c 100644 --- a/src/editions/local/startupChrome.js +++ b/src/editions/local/startupChrome.js @@ -2,12 +2,19 @@ import { initFirstRunExperience } from '../../firstRunExperience.js'; import { initKeySetup } from '../../keySetup.js'; /** Reveal welcome controls only after restoration and the loading transition. */ -export function startLocalChrome({ loadingScreen, styleManager, dataManager, signal }) { +export function startLocalChrome({ + loadingScreen, + styleManager, + dataManager, + signal, +}) { let disposed = false; let firstRun; let revealTimer; let resolveDelay; - const minimumDelay = new Promise((resolve) => { resolveDelay = resolve; }); + const minimumDelay = new Promise((resolve) => { + resolveDelay = resolve; + }); const delayTimer = setTimeout(resolveDelay, 1000); const revealFirstRun = () => { if (disposed || signal.aborted || firstRun) return; @@ -16,16 +23,22 @@ export function startLocalChrome({ loadingScreen, styleManager, dataManager, sig loadingScreen.removeEventListener('transitionend', revealFirstRun); }; void Promise.all([styleManager.initialRestorePromise, minimumDelay]) - .catch(() => { /* Restoration reports its own outcome through the controls. */ }) + .catch(() => { + /* Restoration reports its own outcome through the controls. */ + }) .then(() => { if (disposed || signal.aborted) return; loadingScreen.classList.add('hidden'); - loadingScreen.addEventListener('transitionend', revealFirstRun, { once: true }); + loadingScreen.addEventListener('transitionend', revealFirstRun, { + once: true, + }); revealTimer = setTimeout(revealFirstRun, 900); }); const keySetup = initKeySetup({ signal }); // Own the pending initializer too; it must not reveal a dialog after abort. - void keySetup.catch(() => console.error('Provider settings initialization failed')); + void keySetup.catch(() => + console.error('Provider settings initialization failed'), + ); return async () => { disposed = true; clearTimeout(delayTimer); diff --git a/src/editions/local/startupChrome.test.mjs b/src/editions/local/startupChrome.test.mjs index 7d49258c4..50c094756 100644 --- a/src/editions/local/startupChrome.test.mjs +++ b/src/editions/local/startupChrome.test.mjs @@ -3,7 +3,10 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import vm from 'node:vm'; -const source = readFileSync(new URL('./startupChrome.js', import.meta.url), 'utf8') +const source = readFileSync( + new URL('./startupChrome.js', import.meta.url), + 'utf8', +) .replace(/^import .*;\n/gm, '') .replace('export function', 'function'); @@ -16,13 +19,21 @@ function fixture() { const controller = new AbortController(); const context = { console, - setTimeout(fn, delay) { const id = ++nextTimer; timers.set(id, { fn, delay }); return id; }, - clearTimeout(id) { timers.delete(id); }, + setTimeout(fn, delay) { + const id = ++nextTimer; + timers.set(id, { fn, delay }); + return id; + }, + clearTimeout(id) { + timers.delete(id); + }, initFirstRunExperience() { events.push('welcome'); return { destroy: () => events.push('welcome:destroy') }; }, - async initKeySetup() { return { destroy: () => events.push('settings:destroy') }; }, + async initKeySetup() { + return { destroy: () => events.push('settings:destroy') }; + }, }; vm.createContext(context); vm.runInContext(source, context); @@ -32,21 +43,34 @@ function fixture() { addEventListener: (type, listener) => listeners.set(type, listener), removeEventListener: (type) => listeners.delete(type), }, - styleManager: { initialRestorePromise: new Promise((resolve) => { restored = resolve; }) }, + styleManager: { + initialRestorePromise: new Promise((resolve) => { + restored = resolve; + }), + }, dataManager: {}, signal: controller.signal, }); return { - events, listeners, stop, restored, controller, + events, + listeners, + stop, + restored, + controller, fire(delay) { for (const [id, task] of timers) { - if (task.delay === delay) { timers.delete(id); task.fn(); } + if (task.delay === delay) { + timers.delete(id); + task.fn(); + } } }, timers, }; } -async function flush() { for (let i = 0; i < 12; i++) await Promise.resolve(); } +async function flush() { + for (let i = 0; i < 12; i++) await Promise.resolve(); +} test('welcome waits for restoration, minimum delay, and the cover transition', async () => { const f = fixture(); diff --git a/src/editions/local/tools.js b/src/editions/local/tools.js index 27bff1a20..e65647265 100644 --- a/src/editions/local/tools.js +++ b/src/editions/local/tools.js @@ -3,13 +3,23 @@ import { initAnnotations } from '../../annotations/index.js'; import { initGevVoiceCommands } from '../../voice/gevRealtime.js'; import { installScopeMask, destroyScopeMask } from '../../scopeMask.js'; import { - installRenderGovernor, getRenderGovernorDiagnostics, governorRequestRender, - holdContinuousRender, releaseContinuousRender, + installRenderGovernor, + getRenderGovernorDiagnostics, + governorRequestRender, + holdContinuousRender, + releaseContinuousRender, } from '../../renderGovernor.js'; import { startLocalChrome } from './startupChrome.js'; /** Attach scene tools, rendering listeners and the standalone debug handle. */ -export function createLocalTools({ scene, controls, data, loadingScreen, signal, defer }) { +export function createLocalTools({ + scene, + controls, + data, + loadingScreen, + signal, + defer, +}) { const { viewer, tileset, mapStackController } = scene; const { styleManager, weatherEffects, cockpitCloudEffects } = controls; const { dataManager } = data; @@ -21,68 +31,83 @@ export function createLocalTools({ scene, controls, data, loadingScreen, signal, annotations.destroy(); }); defer(startLocalChrome({ loadingScreen, styleManager, dataManager, signal })); -// Idle render governor: flips the scene into requestRenderMode whenever -// nothing animates per frame. Installed AFTER every module above has had -// its chance to register pre-install holds. (perf wave 2) -installRenderGovernor(viewer); + // Idle render governor: flips the scene into requestRenderMode whenever + // nothing animates per frame. Installed AFTER every module above has had + // its chance to register pre-install holds. (perf wave 2) + installRenderGovernor(viewer); -// Install the explicit scope mask used by the DISPLAY controls. -installScopeMask(viewer); -defer(() => destroyScopeMask()); + // Install the explicit scope mask used by the DISPLAY controls. + installScopeMask(viewer); + defer(() => destroyScopeMask()); -// The follow camera recomputes the tracked target's dead-reckon position -// every frame — tracking anything is a per-frame animation. (perf wave 2) -const removeTrackingListener = viewer.trackedEntityChanged.addEventListener(() => { - if (viewer.trackedEntity) holdContinuousRender('tracked-entity'); - else releaseContinuousRender('tracked-entity'); -}); + // The follow camera recomputes the tracked target's dead-reckon position + // every frame — tracking anything is a per-frame animation. (perf wave 2) + const removeTrackingListener = viewer.trackedEntityChanged.addEventListener( + () => { + if (viewer.trackedEntity) holdContinuousRender('tracked-entity'); + else releaseContinuousRender('tracked-entity'); + }, + ); -// Hidden-state suspension (perf wave 2): when the window/tab is hidden, -// stop the default render loop outright — a hidden canvas repaints for -// nobody, and browser rAF throttling still lets throttled frames burn -// GPU. Holder/data state is untouched, so return is seamless: restore -// the loop, refresh the one DOM surface we gated, render a frame. -const syncVisibilitySuspension = () => { - const hidden = document.hidden; - viewer.useDefaultRenderLoop = !hidden; - cockpitCloudEffects?.setSuspended?.(hidden); - if (!hidden) { - if (dataManager._panelRefreshPendingOnVisible) { - dataManager._panelRefreshPendingOnVisible = false; - dataManager._refreshTogglePanel(); + // Hidden-state suspension (perf wave 2): when the window/tab is hidden, + // stop the default render loop outright — a hidden canvas repaints for + // nobody, and browser rAF throttling still lets throttled frames burn + // GPU. Holder/data state is untouched, so return is seamless: restore + // the loop, refresh the one DOM surface we gated, render a frame. + const syncVisibilitySuspension = () => { + const hidden = document.hidden; + viewer.useDefaultRenderLoop = !hidden; + cockpitCloudEffects?.setSuspended?.(hidden); + if (!hidden) { + if (dataManager._panelRefreshPendingOnVisible) { + dataManager._panelRefreshPendingOnVisible = false; + dataManager._refreshTogglePanel(); + } + governorRequestRender('visibility-restore'); } - governorRequestRender('visibility-restore'); - } -}; -document.addEventListener('visibilitychange', syncVisibilitySuspension); -defer(() => document.removeEventListener('visibilitychange', syncVisibilitySuspension)); -defer(() => { removeTrackingListener(); releaseContinuousRender('tracked-entity'); }); -// Apply the CURRENT state too — bootstrap can complete while the tab is -// already hidden, and waiting for the next transition would leave the -// loop burning behind a hidden tab. (perf wave 2 fix) -syncVisibilitySuspension(); - -window.__godsEyeView = { - viewer, - styleManager, - tileset, - dataManager, - sceneDirector, - mapStackController, - annotations, - weatherEffects, - cockpitCloudEffects, - getRenderGovernorDiagnostics, - requestRender: governorRequestRender, -}; -const debug = window.__godsEyeView; -defer(() => { if (window.__godsEyeView === debug) delete window.__godsEyeView; }); -const voiceCommands = initGevVoiceCommands({ viewer, styleManager, dataManager, sceneDirector, annotations }); -defer(() => { - voiceCommands.stop({ removeUi: true }); - if (window.__gevVoiceCommands === voiceCommands) delete window.__gevVoiceCommands; -}); -debug.voiceCommands = voiceCommands; -return { sceneDirector, annotations, voiceCommands }; + }; + document.addEventListener('visibilitychange', syncVisibilitySuspension); + defer(() => + document.removeEventListener('visibilitychange', syncVisibilitySuspension), + ); + defer(() => { + removeTrackingListener(); + releaseContinuousRender('tracked-entity'); + }); + // Apply the CURRENT state too — bootstrap can complete while the tab is + // already hidden, and waiting for the next transition would leave the + // loop burning behind a hidden tab. (perf wave 2 fix) + syncVisibilitySuspension(); + window.__godsEyeView = { + viewer, + styleManager, + tileset, + dataManager, + sceneDirector, + mapStackController, + annotations, + weatherEffects, + cockpitCloudEffects, + getRenderGovernorDiagnostics, + requestRender: governorRequestRender, + }; + const debug = window.__godsEyeView; + defer(() => { + if (window.__godsEyeView === debug) delete window.__godsEyeView; + }); + const voiceCommands = initGevVoiceCommands({ + viewer, + styleManager, + dataManager, + sceneDirector, + annotations, + }); + defer(() => { + voiceCommands.stop({ removeUi: true }); + if (window.__gevVoiceCommands === voiceCommands) + delete window.__gevVoiceCommands; + }); + debug.voiceCommands = voiceCommands; + return { sceneDirector, annotations, voiceCommands }; }