diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d24586c..1ae798b75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Development +- Extract application lifecycle and viewer exports. Split standalone startup into + scene setup, controls, layer registration, tools and loading UI. Startup failure + and terminal shutdown release acquired resources and cancel delayed work. + - Adopt Prettier tooling contributed by RohanDaCoder (#227), with an explicit file scope, pinned formatter and Linux/Windows CI checks. Format the reusable infrastructure modules and their consumer tests. Package boundary checks keep diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md new file mode 100644 index 000000000..a4af4660b --- /dev/null +++ b/docs/APPLICATION.md @@ -0,0 +1,102 @@ +# Application construction + +`gods-eye-view/application` exports `createApplication`. Importing it does not +create a viewer, discover configuration, start requests, or attach browser +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 | + +Each constructor returns its component object, or a promise for that object. +Those objects are passed unchanged to later constructors. Configuration and +service instances belong in the caller's closures; the application does not +interpret provider names, environment variables, endpoints, or module paths. +There is no module discovery or automatic import mechanism. + +```js +import { createApplication } from 'gods-eye-view/application'; +import { createApplicationViewer } from 'gods-eye-view/application/viewer'; + +const app = createApplication({ + createScene({ defer }) { + const viewer = createApplicationViewer({ container, creditContainer }); + defer(() => viewer.destroy()); + return { viewer }; + }, + createControls, + createData, + createTools, +}); +const unsubscribe = app.subscribe(({ status, phase }) => { + // Update the caller's startup presentation. +}); +await app.start(); +// When the application is no longer needed: +unsubscribe(); +await app.destroy(); +``` + +The example's containers and three remaining constructors are supplied by the +consumer. The viewer helper preserves the standalone viewer's render settings; +it neither selects map sources nor reads keys. Supply a visible credit container +and retain all attribution required by the chosen sources. Cesium is external to +this export: the consuming build must provide the same instance used elsewhere +in its application. + +## Lifecycle and ownership + +Register `defer(cleanup)` immediately after acquiring each resource, before any +`await`. Cleanup registration remains open until that constructor settles. Each +callback may return a promise. Within a phase, callbacks run in reverse order. +Across phases, teardown runs **tools, controls, data, scene**: controls must cancel +restoration while their data manager and viewer are still alive. + +`start()` returns the same promise on repeated calls. `destroy()` is terminal and +also returns the same promise on repeated calls. It aborts the shared signal +immediately, waits for an in-flight constructor to settle, and runs all registered +cleanup callbacks. Constructors must forward the signal to cancellable operations +and check it after awaits. If a provider cannot cancel construction, register +cleanup for its late result before returning. Destruction waits for that result; +it does not pretend a pending resource has already been released. + +A constructor failure triggers the same cleanup before rejecting startup. +Cleanup failures do not stop the remaining callbacks; they are reported through +an `AggregateError`. Constructors own cleanup for resources created internally, +including anything allocated before their own construction throws. + +`getState()` returns a frozen `{ status, phase }` snapshot. Status is `created`, +`starting`, `ready`, `destroying`, `destroyed`, or `failed`. `phase` identifies the +constructor during startup. `subscribe(listener)` immediately reports the current +snapshot and returns an unsubscribe function. Install observers before `start()` +to receive every startup phase. Observer failures cannot interrupt startup or +teardown. `getComponents()` returns a frozen, shallow snapshot of the currently +constructed component objects; the component instances themselves remain mutable. + +`ready` means all constructors have returned. Background feed activity and the +standalone share-restoration result keep their existing separate contracts. + +## Standalone wiring + +`src/main.js` reads the existing browser configuration and starts +`src/editions/local/application.js`. That module selects the four implementations +in its directory. Scene setup, controls, layer registration, tools and loading +chrome have separate owners. The existing `window.__godsEyeView` debugging shape +is preserved while the app is running. + +The standalone controls and layer modules still contain page-scoped state. +Only one standalone application may be constructed per page. Shutdown releases +its runtime resources and debugging handles; reload the page to start again. +It is not an embeddable, removable HTML shell or a multiple-viewer implementation. +Consumers of the small application export supply their own component ownership; +the lifecycle controller itself has no shared instance state. + +Later component extractions can replace a constructor's internals without +changing the startup contract or moving standalone imports into the package. +Run the unit suite, package boundary gate, build, tracking and first-run browser +checks when changing this wiring. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 2d4df7ad5..3bf1ac1c0 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -22,9 +22,9 @@ excluded. The formatter validates every entry before writing any file. | `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` and `vite.config.js` | Standalone startup and local Node services | Local configuration | +| `src/main.js`, `src/editions/local/` and `vite.config.js` | Standalone startup and local Node services | Local configuration | -The three scoped package exports are browser source modules. Use their documented +The scoped package exports are browser source modules. Use their documented exports instead of importing standalone startup or reaching into internal files. The application owns the viewer, context store, overlay host and render scheduler; layers use the supplied callbacks. See [the infrastructure contract](INFRASTRUCTURE-LAYERS.md). @@ -44,7 +44,12 @@ component, add its ownership and consumer tests together. Node services must use separate entry points and their own checks when they become reusable; importing them into a browser component is not supported. No Node service is exported yet. -This setup establishes boundaries for the existing infrastructure component. -Application construction, UI panels and individual source adapters remain future -extractions; they should become smaller modules with explicit lifecycle owners -as their callers migrate. +`gods-eye-view/application` owns construction order, startup state, cancellation +and disposal of caller-supplied components. Its only owned module is +`src/app/application.js`. `gods-eye-view/application/viewer` separately owns the +standard Cesium viewer configuration in `src/app/viewer.js`; Cesium stays external. +Neither export imports standalone UI, layers, tools or configuration. See +[application construction](APPLICATION.md) for the contracts and current limits. + +UI panels and individual source adapters remain future extractions. They should +become smaller modules with explicit lifecycle owners as their callers migrate. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index cea6f4a53..4c09964f0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,19 @@ # God's Eye View Current State +## Application startup and shutdown + +The standalone entry now composes scene setup, controls, layer registration and +tools through the reusable application lifecycle. Map defaults, layer order, +share restoration, voice setup and the running debug handle retain their behavior. +The welcome card still waits for restoration and the loading-cover transition. + +Startup failure cleans up acquired resources. Explicit application destruction +aborts construction, cancels pending playback/annotations and delayed welcome UI, +then releases controls, layers and the viewer. Destruction is terminal; the +standalone page must be reloaded to start again. The exported lifecycle and viewer +helpers do not import the standalone entry or discover configuration. See +[application construction](APPLICATION.md). + ## Scoped formatting and package checks `npm run format` and `npm run format:check` operate on the explicit adopted-file diff --git a/package.json b/package.json index dcf0b4a77..377bf3b0f 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,8 @@ "exports": { "./infrastructure": "./src/data/infrastructure.js", "./infrastructure/geojson": "./src/data/localGeojsonCore.js", - "./infrastructure/lod": "./src/data/localGeojsonLod.js" + "./infrastructure/lod": "./src/data/localGeojsonLod.js", + "./application": "./src/app/application.js", + "./application/viewer": "./src/app/viewer.js" } } diff --git a/scripts/dev-fresh.sh b/scripts/dev-fresh.sh index ed4e1e108..7336e773d 100755 --- a/scripts/dev-fresh.sh +++ b/scripts/dev-fresh.sh @@ -226,8 +226,8 @@ if [[ ! -f "src/data/cctv.js" ]]; then exit 1 fi -if ! grep -q "dataManager.register(cctvLayer)" src/main.js; then - echo "error: CCTV layer not wired in src/main.js" +if ! grep -q "dataManager.register(cctvLayer)" src/editions/local/data.js; then + echo "error: CCTV layer not wired in src/editions/local/data.js" exit 1 fi diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 20fa4547b..c5b410813 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -10,6 +10,28 @@ "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] + }, + "application": { + "exports": [ + "./application" + ], + "modules": [ + "src/app/application.js" + ], + "external": [] + }, + "viewer": { + "exports": [ + "./application/viewer" + ], + "modules": [ + "src/app/viewer.js" + ], + "external": [ + "cesium" + ] } } diff --git a/scripts/qa-application.mjs b/scripts/qa-application.mjs new file mode 100644 index 000000000..ae173fda0 --- /dev/null +++ b/scripts/qa-application.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +/** Prove standalone startup and terminal resource ownership in a real browser. */ +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer'; + +const args = process.argv.slice(2); +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(), + 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())) { + 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 }); + const before = await page.evaluate(async () => { + 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; + window.__qaComponents = app; + await application.start(); + 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' }, + ]); + return { + status: application.getState().status, + enabled, + layers: app.dataManager.layers.size, + annotationCount: annotation.drawn, + viewerAlive: !app.viewer.isDestroyed(), + credits: Boolean(document.querySelector('#cesium-credits')), + }; + }); + assert.equal(before.status, 'ready'); + assert.equal(before.enabled, true); + assert.ok(before.layers >= 16); + 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'); + + const after = await page.evaluate(async () => { + const application = window.__qaApplication; + const app = window.__qaComponents; + const first = application.destroy(); + const samePromise = first === application.destroy(); + await first.catch((error) => { + const describe = (failure) => failure.errors?.map(describe).join('; ') || failure.message; + throw new Error(describe(error)); + }); + document.dispatchEvent(new Event('visibilitychange')); + window.dispatchEvent(new Event('resize')); + app.requestRender('after-destroy'); + await new Promise((resolve) => setTimeout(resolve, 1400)); + return { + status: application.getState().status, + samePromise, + viewerDestroyed: app.viewer.isDestroyed(), + layers: app.dataManager.layers.size, + annotations: app.annotations.count(), + governor: app.getRenderGovernorDiagnostics(), + handlesRemoved: !window.__godsEyeView && !window.__gevVoiceCommands && !window.__gevAnnotations, + creditsRemoved: !document.querySelector('#cesium-credits'), + welcomeHidden: !document.querySelector('#first-run-launcher.visible'), + settingsRemoved: !document.querySelector('#key-setup'), + }; + }); + assert.equal(after.status, 'destroyed'); + assert.equal(after.samePromise, true); + assert.equal(after.viewerDestroyed, true); + assert.equal(after.layers, 0); + assert.equal(after.annotations, 0); + assert.equal(after.governor.installed, false); + assert.deepEqual(after.governor.holds, []); + assert.equal(after.handlesRemoved, true); + assert.equal(after.creditsRemoved, true); + 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'); +} finally { + await browser.close(); +} diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index 200cdbac0..d17d6b238 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -101,6 +101,7 @@ export function createAnnotationEngine({ }) { /** @type {Map} live annotations keyed by id */ const annotations = new Map(); + let destroyed = false; let tickHandle = null; let assistFlightUntil = 0; // camera-assist debounce (see ensureMarksVisible) // Generation token + in-flight fetch controllers guard against clear / new-topic @@ -174,7 +175,7 @@ export function createAnnotationEngine({ } function ensureTicking() { - if (tickHandle != null) return; + if (destroyed || tickHandle != null) return; const tick = () => { const now = performance.now(); let changed = false; @@ -222,6 +223,7 @@ export function createAnnotationEngine({ * @returns {Promise<{ok, drawn, failed, ids, results}>} */ async function annotate(requests, opts = {}) { + if (destroyed) return { ok: false, drawn: 0, failed: 0, ids: [], results: [], error: 'destroyed' }; const list = Array.isArray(requests) ? requests : [requests]; if (opts.clearPrevious) clear(); // bumps generation + aborts older pending work @@ -865,6 +867,17 @@ export function createAnnotationEngine({ const engine = { annotate, clear, + destroy() { + if (destroyed) return; + destroyed = true; + try { clear(); } finally { + if (tickHandle != null) cancelAnimationFrame(tickHandle); + tickHandle = null; + outlineListeners.clear(); + releaseContinuousRender('annotations'); + renderer.destroy(); + } + }, fadeOutAll, count: () => annotations.size, list: () => Array.from(annotations.values()), @@ -875,7 +888,7 @@ export function createAnnotationEngine({ * upgrade task, AFTER the mark was mutated in place. Returns an unsubscribe fn. */ onOutlineEvent(listener) { - if (typeof listener !== 'function') return () => {}; + if (destroyed || typeof listener !== 'function') return () => {}; outlineListeners.add(listener); return () => outlineListeners.delete(listener); }, @@ -901,16 +914,22 @@ export function createAnnotationEngine({ async tour() { clear(); flyTo({ lon: -122.4486, lat: 37.7960, height: 520, heading: 0, pitch: -26, duration: 3 }); + if (destroyed) return { ok: false }; await wait(3200); await annotate([{ type: 'highlight', target: 'Palace of Fine Arts, San Francisco', label: 'Palace of Fine Arts', color: 'amber' }], { persist: true }); + if (destroyed) return { ok: false }; await wait(2600); await annotate([{ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina Green, San Francisco', label: 'next to the Marina', color: 'cyan' }], { persist: true }); + if (destroyed) return { ok: false }; await wait(2600); flyTo({ lon: -122.4545, lat: 37.7880, height: 1500, heading: 18, pitch: -32, duration: 3 }); + if (destroyed) return { ok: false }; await wait(3200); await annotate([{ type: 'area', target: 'Presidio of San Francisco', label: 'The Presidio — a former Army base', color: 'green', footprint: true }], { persist: true }); + if (destroyed) return { ok: false }; await wait(2800); await annotate([{ type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', label: 'ILM / Lucasfilm', color: 'red' }], { persist: true }); + if (destroyed) return { ok: false }; await wait(2600); await annotate([{ type: 'route', color: 'amber', label: 'Crissy Field shoreline', points: [ { target: 'Palace of Fine Arts, San Francisco' }, @@ -922,6 +941,7 @@ export function createAnnotationEngine({ }; function flyTo({ lon, lat, height, heading = 0, pitch = -30, duration = 2.5 }) { + if (destroyed) return; try { viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(lon, lat, height), diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 8d7a405a5..411ad796b 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -668,3 +668,44 @@ test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing assert.equal(retry.drawn, 1); assert.equal(live.size, 1, 'the recoloured mark redraws once, with no orphan underneath'); }); + +test('destroy releases the renderer and prevents subsequent annotation work', async (t) => { + installAnimationFrameStubs(t); + const { renderer, calls } = fakeRenderer(); + let destroyed = 0; + renderer.destroy = () => { destroyed++; }; + const engine = createAnnotationEngine({ viewer: {}, renderer }); + const drawn = await engine.annotate([{ type: 'pin', longitude: -97.74, latitude: 30.27 }]); + assert.equal(drawn.drawn, 1); + engine.destroy(); + const additions = calls.add; + const result = await engine.annotate([{ type: 'pin', target: 'Must not resolve' }]); + assert.equal(result.error, 'destroyed'); + assert.equal(calls.add, additions); + assert.equal(engine.count(), 0); + engine.destroy(); + assert.equal(destroyed, 1); + assert.ok(!getRenderGovernorDiagnostics().holds.includes('annotations')); +}); + +test('a late annotation resolver cannot redraw after destruction', async (t) => { + installAnimationFrameStubs(t); + const { renderer, calls } = fakeRenderer(); + renderer.destroy = () => {}; + let release; + let signal; + const engine = createAnnotationEngine({ + viewer: {}, renderer, + resolveTarget: (options) => { + signal = options.signal; + return new Promise((resolve) => { release = resolve; }); + }, + }); + const pending = engine.annotate([{ type: 'pin', target: 'Pending place' }]); + engine.destroy(); + assert.equal(signal.aborted, true); + release({ lon: -97.74, lat: 30.27 }); + await pending; + assert.equal(calls.add, 0); + assert.equal(engine.count(), 0); +}); diff --git a/src/app/application.js b/src/app/application.js new file mode 100644 index 000000000..4622d07cf --- /dev/null +++ b/src/app/application.js @@ -0,0 +1,119 @@ +const START_ORDER = ['scene', 'controls', 'data', 'tools']; +// Controls cancel restoration while the data manager and viewer still exist. +const STOP_ORDER = ['tools', 'controls', 'data', 'scene']; + +/** + * Own application startup and teardown without importing an application entry. + * Constructors receive earlier components, an AbortSignal and defer(cleanup). + * Register cleanup immediately after acquiring each resource, before any await. + * @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 }; + for (const phase of START_ORDER) { + if (typeof factories[phase] !== 'function') throw new TypeError(`Missing ${phase} constructor`); + } + const controller = new AbortController(); + const cleanups = Object.fromEntries(START_ORDER.map((phase) => [phase, []])); + const components = {}; + const listeners = new Set(); + let state = Object.freeze({ status: 'created', phase: null }); + let startPromise; + let destroyPromise; + let cleanupPromise; + + 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'); } + } + } + + function cleanup() { + cleanupPromise ||= Promise.resolve().then(async () => { + const errors = []; + for (const phase of STOP_ORDER) { + while (cleanups[phase].length) { + try { await cleanups[phase].pop()(); } catch (error) { errors.push(error); } + } + delete components[phase]; + } + if (errors.length) throw new AggregateError(errors, 'Application cleanup failed'); + }); + return cleanupPromise; + } + + async function initialize() { + try { + for (const phase of START_ORDER) { + controller.signal.throwIfAborted(); + publish('starting', phase); + controller.signal.throwIfAborted(); + let acceptingCleanup = true; + try { + components[phase] = await factories[phase]({ + ...components, + signal: controller.signal, + defer(dispose) { + if (!acceptingCleanup || typeof dispose !== 'function') { + throw new TypeError('Register cleanup during component construction'); + } + cleanups[phase].push(dispose); + }, + }); + } finally { + acceptingCleanup = false; + } + controller.signal.throwIfAborted(); + } + publish('ready'); + controller.signal.throwIfAborted(); + return Object.freeze({ ...components }); + } catch (error) { + controller.abort(); + let failure = error; + try { await cleanup(); } catch (cleanupError) { + failure = new AggregateError([error, cleanupError], 'Application startup and cleanup failed'); + } + if (!destroyPromise) publish('failed'); + throw failure; + } + } + + return Object.freeze({ + start() { + 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; + }, + destroy() { + if (destroyPromise) return destroyPromise; + destroyPromise = Promise.resolve().then(async () => { + try { + await startPromise?.catch(() => {}); + await cleanup(); + publish('destroyed'); + } catch (error) { + publish('failed'); + throw error; + } finally { + listeners.clear(); + } + }); + controller.abort(); + publish('destroying'); + return destroyPromise; + }, + getState: () => state, + getComponents: () => Object.freeze({ ...components }), + subscribe(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'); } + return () => listeners.delete(listener); + }, + }); +} diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs new file mode 100644 index 000000000..f5f42c12e --- /dev/null +++ b/src/app/application.test.mjs @@ -0,0 +1,169 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +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 }; + }, + ])); + return { events, app: createApplication({ ...constructors, ...overrides }) }; +} +function deferred() { + let resolve; + 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', ` + 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' }); + assert.equal(result.status, 0, result.stderr); +}); + +test('constructors are validated before allocating anything', () => { + assert.throws(() => createApplication({}), /Missing scene constructor/); +}); + +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 states = []; + app.subscribe((state) => states.push(`${state.status}:${state.phase}`)); + const started = app.start(); + assert.equal(started, app.start()); + const components = await started; + assert.ok(Object.isFrozen(components)); + assert.equal(components.tools.name, 'Tools'); + assert.equal(app.getState().status, 'ready'); + 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(app.getComponents(), {}); + assert.equal(states.at(-1), 'destroyed:null'); + await assert.rejects(app.start(), /destroyed/); +}); + +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'); + } }); + await assert.rejects(app.start(), /broken constructor/); + 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); + }); +} + +test('destroy before startup prevents every constructor', async () => { + const { app, events } = fixture(); + const started = app.start(); + const failed = assert.rejects(started, { name: 'AbortError' }); + await app.destroy(); + await failed; + assert.deepEqual(events, []); +}); + +test('destroy aborts an in-flight constructor and cleans late acquisitions before resolving', async () => { + 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 started = app.start(); + const failed = assert.rejects(started, { name: 'AbortError' }); + await entered.promise; + let settled = false; + const stopped = app.destroy().then(() => { settled = true; }); + assert.equal(observedSignal.aborted, true); + await Promise.resolve(); + assert.equal(settled, false); + gate.resolve(); + await stopped; + await failed; + assert.deepEqual(events, ['late:released']); +}); + +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 {}; + } }); + 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.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(); }); + await assert.rejects(app.start(), { name: 'AbortError' }); + await app.destroy(); + assert.deepEqual(events, ['start:Scene', 'stop:Scene']); +}); + +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(); }); + await assert.rejects(app.start(), { name: 'AbortError' }); + await app.destroy(); + assert.equal(app.getState().status, 'destroyed'); +}); + +test('cleanup registration closes when a constructor settles', async () => { + let register; + const { app } = fixture({ createScene({ defer }) { register = defer; return {}; } }); + await app.start(); + assert.throws(() => register(() => {}), /during component construction/); + await app.destroy(); +}); + +test('different application instances do not share lifecycle state', async () => { + const first = fixture(); + const second = fixture(); + await Promise.all([first.app.start(), second.app.start()]); + await first.app.destroy(); + assert.equal(second.app.getState().status, 'ready'); + assert.equal(second.events.length, 4); + await second.app.destroy(); +}); + +test('the separate viewer export imports without constructing a browser viewer', async () => { + 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 new file mode 100644 index 000000000..09b6747df --- /dev/null +++ b/src/app/mapLifecycle.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { MapStackController } from '../mapStackController.js'; +import { + 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 switching = controller.setStack('osm'); + controller.destroy(); + resolveProvider({ provider: {} }); + await switching; + assert.equal(removed, 1); + assert.deepEqual(changes, ['switching']); + await controller.setStack('osm'); + controller.destroy(); + assert.equal(removed, 1); +}); + +test('uninstall only releases the current viewer and makes later render requests inactive', () => { + let renders = 0; + const viewer = { scene: { requestRender: () => { renders++; } } }; + installRenderGovernor(viewer); + holdContinuousRender('fixture'); + uninstallRenderGovernor({}); + assert.equal(getRenderGovernorDiagnostics().installed, true); + uninstallRenderGovernor(viewer); + const before = renders; + governorRequestRender('late'); + assert.equal(renders, before); + assert.equal(getRenderGovernorDiagnostics().installed, false); + assert.deepEqual(getRenderGovernorDiagnostics().holds, []); +}); diff --git a/src/app/startupCamera.test.mjs b/src/app/startupCamera.test.mjs new file mode 100644 index 000000000..7d943b631 --- /dev/null +++ b/src/app/startupCamera.test.mjs @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { flyToAustin } from '../camera.js'; + +test('teardown before the initial camera delay prevents a late flight', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let flights = 0; + let cancelled = 0; + const stop = flyToAustin({ + isDestroyed: () => false, + camera: { setView() {}, flyTo() { flights++; }, cancelFlight() { cancelled++; } }, + }); + stop(); + t.mock.timers.tick(1000); + assert.equal(flights, 0); + assert.equal(cancelled, 1); +}); diff --git a/src/app/viewer.js b/src/app/viewer.js new file mode 100644 index 000000000..6d9bceb1e --- /dev/null +++ b/src/app/viewer.js @@ -0,0 +1,35 @@ +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'); + const viewer = new Cesium.Viewer(container, { + timeline: false, + animation: false, + baseLayerPicker: false, + geocoder: false, + homeButton: false, + sceneModePicker: false, + navigationHelpButton: false, + fullscreenButton: false, + vrButton: false, + selectionIndicator: false, + infoBox: false, + baseLayer: false, + creditContainer, + msaaSamples: 4, + 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; + } catch (error) { + viewer.destroy(); + throw error; + } +} diff --git a/src/camera.js b/src/camera.js index 0e12b66f1..3941c9d48 100644 --- a/src/camera.js +++ b/src/camera.js @@ -48,6 +48,7 @@ export function flyToPreset(viewer, presetName, duration = 3.0) { /** * Set camera to Austin on load with a cinematic fly-in. + * @returns {Function} Cancels the pending or active startup flight. */ export function flyToAustin(viewer) { // Start from a high altitude, then fly down @@ -61,7 +62,8 @@ export function flyToAustin(viewer) { }); // Cinematic fly-in after a brief pause - setTimeout(() => { + const timer = setTimeout(() => { + if (viewer.isDestroyed()) return; viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 600), orientation: { @@ -73,4 +75,8 @@ export function flyToAustin(viewer) { easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, }); }, 500); + return () => { + clearTimeout(timer); + if (!viewer.isDestroyed()) viewer.camera.cancelFlight(); + }; } diff --git a/src/data/traffic.js b/src/data/traffic.js index 484f599f2..b5fe47cbe 100644 --- a/src/data/traffic.js +++ b/src/data/traffic.js @@ -2318,7 +2318,7 @@ const trafficLayer = { viewer.camera.percentageChanged = _prevPercentageChanged; _prevPercentageChanged = null; } - _pointCollection.show = false; + if (_pointCollection) _pointCollection.show = false; }, /** diff --git a/src/data/traffic.test.mjs b/src/data/traffic.test.mjs index 52c6f13c1..f4cf94bd8 100644 --- a/src/data/traffic.test.mjs +++ b/src/data/traffic.test.mjs @@ -151,3 +151,11 @@ test('the shipped layer boots keyless-honest before any status check', () => { assert.ok(!LIVE_CLAIM.test(stats.loadingLabel), `boot label implies live data: ${stats.loadingLabel}`); assert.equal(layerFeedState(stats), 'fallback'); }); + +test('traffic can be destroyed before its first enable and destroyed repeatedly', async () => { + const { default: traffic } = await import('./traffic.js'); + const viewer = { camera: { changed: { removeEventListener() {} } } }; + assert.doesNotThrow(() => traffic.destroy(viewer)); + assert.doesNotThrow(() => traffic.destroy(viewer)); + assert.equal(traffic.getStats().count, 0); +}); diff --git a/src/devCctv.test.mjs b/src/devCctv.test.mjs index a333a4fe3..275c44680 100644 --- a/src/devCctv.test.mjs +++ b/src/devCctv.test.mjs @@ -20,7 +20,8 @@ async function launch(overrides = {}, dotenv = '') { for (const name of ['dev-cctv.sh', 'dev-fresh.sh', 'read-dotenv-value.mjs']) { await fs.copyFile(new URL(`../scripts/${name}`, import.meta.url), path.join(root, 'scripts', name)); } - await fs.copyFile(new URL('./main.js', import.meta.url), path.join(root, 'src', 'main.js')); + await fs.mkdir(path.join(root, 'src', 'editions', 'local'), { recursive: true }); + await fs.copyFile(new URL('./editions/local/data.js', import.meta.url), path.join(root, 'src', 'editions', 'local', 'data.js')); await fs.mkdir(path.join(root, 'node_modules')); await fs.symlink(fileURLToPath(new URL('../node_modules/vite', import.meta.url)), path.join(root, 'node_modules', 'vite'), 'dir'); await fs.writeFile(path.join(root, '.env'), dotenv); diff --git a/src/editions/local/application.js b/src/editions/local/application.js new file mode 100644 index 000000000..19180a7e0 --- /dev/null +++ b/src/editions/local/application.js @@ -0,0 +1,22 @@ +import { createApplication } from '../../app/application.js'; +import { createLocalScene } from './scene.js'; +import { createLocalControls } from './controls.js'; +import { createLocalData } from './data.js'; +import { createLocalTools } from './tools.js'; + +// The existing controls and layer catalog contain page-scoped state. +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'); + 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 }), + createTools: (context) => createLocalTools({ ...context, loadingScreen }), + }); +} diff --git a/src/editions/local/controls.js b/src/editions/local/controls.js new file mode 100644 index 000000000..8f3c6a27e --- /dev/null +++ b/src/editions/local/controls.js @@ -0,0 +1,28 @@ +import { StyleManager } from '../../ui.js'; +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 }); + 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); + 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...'; +} + + return { styleManager, weatherEffects, cockpitCloudEffects }; +} diff --git a/src/editions/local/data.js b/src/editions/local/data.js new file mode 100644 index 000000000..d63add8fc --- /dev/null +++ b/src/editions/local/data.js @@ -0,0 +1,66 @@ +import { DataLayerManager } from '../../data/manager.js'; +import flightsLayer from '../../data/flights.js'; +import militaryFlightsLayer from '../../data/militaryFlights.js'; +import earthquakesLayer from '../../data/earthquakes.js'; +import satellitesLayer from '../../data/satellites.js'; +import rocketLaunchesLayer from '../../data/rocketLaunches.js'; +import trafficLayer from '../../data/traffic.js'; +import cctvLayer from '../../data/cctv.js'; +import radioLayer from '../../data/radio.js'; +import bikeshareLayer from '../../data/bikeshare.js'; +import aisLiveVesselsLayer from '../../data/aisLiveVessels.js'; +import militaryInstallationsLayer from '../../data/militaryInstallations.js'; +import militaryAwarenessLayer from '../../data/militaryAwareness.js'; +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, { + 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; + }); +} +dataManager.buildTogglePanel(document.getElementById('data-toggles')); +styleManager.attachDataManager(dataManager); + + return { dataManager }; +} diff --git a/src/editions/local/errors.js b/src/editions/local/errors.js new file mode 100644 index 000000000..aebba3a3a --- /dev/null +++ b/src/editions/local/errors.js @@ -0,0 +1,25 @@ +/** + * Extract a human-readable error message from any thrown value. + * Handles Error objects, strings, and plain objects with message/error fields. + * @param {*} error — caught exception value + * @returns {string} best-effort error description + */ +export function describeError(error) { + if (!error) return 'Unknown initialization error'; + if (error instanceof Error) { + if (error.message && error.message.trim()) return error.message.trim(); + return error.name || 'Initialization error'; + } + if (typeof error === 'string' && error.trim()) return error.trim(); + if (typeof error === 'object') { + const maybeMessage = String(error.message || error.error || '').trim(); + if (maybeMessage) return maybeMessage; + try { + const serialized = JSON.stringify(error); + if (serialized && serialized !== '{}') return serialized; + } catch { + // ignore serialization error + } + } + return String(error); +} diff --git a/src/editions/local/scene.js b/src/editions/local/scene.js new file mode 100644 index 000000000..e707dbf31 --- /dev/null +++ b/src/editions/local/scene.js @@ -0,0 +1,88 @@ +import * as Cesium from 'cesium'; +import { createApplicationViewer } from '../../app/viewer.js'; +import { registerDataCredits } from '../../data/dataCredits.js'; +import { configureCreditKeyboardAccess } from '../../creditKeyboard.js'; +import { MapStackController } from '../../mapStackController.js'; +import { loadPhotorealisticTileset } from '../../mapStartup.js'; +import { initLogoGaze } from '../../logoGaze.js'; +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 }) { + defer(initLogoGaze()); + const previousKey = window.__GOOGLE_MAPS_API_KEY__; + if (googleApiKey) { + window.__GOOGLE_MAPS_API_KEY__ = googleApiKey; + defer(() => { + if (window.__GOOGLE_MAPS_API_KEY__ !== googleApiKey) return; + if (previousKey === undefined) delete window.__GOOGLE_MAPS_API_KEY__; + else window.__GOOGLE_MAPS_API_KEY__ = previousKey; + }); + } + loaderStatus.textContent = 'Configuring viewer...'; + // Provider attribution stays visible, including clean-view and recording. + const creditContainer = document.createElement('div'); + creditContainer.id = 'cesium-credits'; + document.body.appendChild(creditContainer); + defer(() => creditContainer.remove()); + 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; + // A provider can finish after cancellation; retain ownership of its result. + defer(() => { + if (tileset && !tileset.isDestroyed()) { + if (!viewer.scene.primitives.remove(tileset)) tileset.destroy(); + } + }); + 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...`; + } + viewer.scene.globe.show = true; +} + +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 }); + + signal.throwIfAborted(); + return { viewer, tileset, mapStackController }; +} diff --git a/src/editions/local/startupChrome.js b/src/editions/local/startupChrome.js new file mode 100644 index 000000000..1ad994afe --- /dev/null +++ b/src/editions/local/startupChrome.js @@ -0,0 +1,38 @@ +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 }) { + let disposed = false; + let firstRun; + let revealTimer; + let resolveDelay; + const minimumDelay = new Promise((resolve) => { resolveDelay = resolve; }); + const delayTimer = setTimeout(resolveDelay, 1000); + const revealFirstRun = () => { + if (disposed || signal.aborted || firstRun) return; + firstRun = initFirstRunExperience({ styleManager, dataManager }); + clearTimeout(revealTimer); + loadingScreen.removeEventListener('transitionend', revealFirstRun); + }; + void Promise.all([styleManager.initialRestorePromise, minimumDelay]) + .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 }); + 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')); + return async () => { + disposed = true; + clearTimeout(delayTimer); + clearTimeout(revealTimer); + resolveDelay(); + loadingScreen.removeEventListener('transitionend', revealFirstRun); + firstRun?.destroy(); + (await keySetup.catch(() => null))?.destroy(); + }; +} diff --git a/src/editions/local/startupChrome.test.mjs b/src/editions/local/startupChrome.test.mjs new file mode 100644 index 000000000..7d49258c4 --- /dev/null +++ b/src/editions/local/startupChrome.test.mjs @@ -0,0 +1,101 @@ +import test from 'node:test'; +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') + .replace(/^import .*;\n/gm, '') + .replace('export function', 'function'); + +function fixture() { + const timers = new Map(); + const listeners = new Map(); + const events = []; + let restored; + let nextTimer = 0; + 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); }, + initFirstRunExperience() { + events.push('welcome'); + return { destroy: () => events.push('welcome:destroy') }; + }, + async initKeySetup() { return { destroy: () => events.push('settings:destroy') }; }, + }; + vm.createContext(context); + vm.runInContext(source, context); + const stop = context.startLocalChrome({ + loadingScreen: { + classList: { add: (value) => events.push(value) }, + addEventListener: (type, listener) => listeners.set(type, listener), + removeEventListener: (type) => listeners.delete(type), + }, + styleManager: { initialRestorePromise: new Promise((resolve) => { restored = resolve; }) }, + dataManager: {}, + signal: controller.signal, + }); + return { + events, listeners, stop, restored, controller, + fire(delay) { + for (const [id, task] of timers) { + if (task.delay === delay) { timers.delete(id); task.fn(); } + } + }, + timers, + }; +} +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(); + f.fire(1000); + await flush(); + assert.deepEqual(f.events, []); + f.restored(); + await flush(); + assert.deepEqual(f.events, ['hidden']); + f.listeners.get('transitionend')(); + f.fire(900); + assert.deepEqual(f.events, ['hidden', 'welcome']); + await f.stop(); + assert.deepEqual(f.events.slice(-2), ['welcome:destroy', 'settings:destroy']); + assert.equal(f.timers.size, 0); +}); + +test('reduced motion uses the bounded fallback after the cover hides', async () => { + const f = fixture(); + f.restored(); + f.fire(1000); + await flush(); + f.fire(900); + assert.deepEqual(f.events, ['hidden', 'welcome']); + await f.stop(); +}); + +test('shutdown while restore is pending never reveals late welcome UI', async () => { + const f = fixture(); + f.controller.abort(); + await f.stop(); + f.restored(); + await flush(); + f.fire(1000); + f.fire(900); + assert.deepEqual(f.events, ['settings:destroy']); + assert.equal(f.listeners.size, 0); + assert.equal(f.timers.size, 0); +}); + +test('shutdown during the cover transition cancels the listener and fallback', async () => { + const f = fixture(); + f.restored(); + f.fire(1000); + await flush(); + const transition = f.listeners.get('transitionend'); + f.controller.abort(); + await f.stop(); + transition(); + f.fire(900); + assert.deepEqual(f.events, ['hidden', 'settings:destroy']); +}); diff --git a/src/editions/local/tools.js b/src/editions/local/tools.js new file mode 100644 index 000000000..27bff1a20 --- /dev/null +++ b/src/editions/local/tools.js @@ -0,0 +1,88 @@ +import { SceneDirector } from '../../scenes/director.js'; +import { initAnnotations } from '../../annotations/index.js'; +import { initGevVoiceCommands } from '../../voice/gevRealtime.js'; +import { installScopeMask, destroyScopeMask } from '../../scopeMask.js'; +import { + 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 }) { + const { viewer, tileset, mapStackController } = scene; + const { styleManager, weatherEffects, cockpitCloudEffects } = controls; + const { dataManager } = data; + const sceneDirector = new SceneDirector(viewer, styleManager, dataManager); + defer(() => sceneDirector.destroy()); + const annotations = initAnnotations({ viewer, tileset }); + defer(() => { + if (window.__gevAnnotations === annotations) delete window.__gevAnnotations; + 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); + +// 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'); +}); + +// 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'); + } +}; +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 }; + +} diff --git a/src/firstRunExperience.js b/src/firstRunExperience.js index 09790dd29..a664a70bc 100644 --- a/src/firstRunExperience.js +++ b/src/firstRunExperience.js @@ -643,5 +643,13 @@ export function initFirstRunExperience({ } syncToExclusiveSurfaces(); - return { dismiss, isTopmost }; + // Teardown is not a user dismissal and must not change the show preference. + const destroy = () => { + closing = true; + documentRef.removeEventListener('keydown', onKeyDown, true); + globalThis.removeEventListener?.('resize', onViewportResize); + surfaceObserver?.disconnect(); + root.remove(); + }; + return { dismiss, isTopmost, destroy }; } diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 393f57b26..a2f7b1c28 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -546,7 +546,7 @@ test('the decision table is written down where the next editor will read it', () test('markup, startup ordering and accessibility remain pinned', () => { const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); - const main = fs.readFileSync(new URL('./main.js', import.meta.url), 'utf8'); + const startup = fs.readFileSync(new URL('./editions/local/startupChrome.js', import.meta.url), 'utf8'); const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); assert.match(html, /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/); @@ -577,9 +577,8 @@ test('markup, startup ordering and accessibility remain pinned', () => { assert.doesNotMatch(html, /data-first-run-choice="infrastructure"/, 'the removed tile must leave no markup behind'); - const startup = main.slice(main.indexOf('void Promise.all(['), main.indexOf('// Expose for debugging')); assert.match(startup, /styleManager\.initialRestorePromise/); - assert.ok(startup.indexOf("loadingScreen.classList.add('hidden')") < startup.indexOf('initFirstRunExperience')); + assert.ok(startup.indexOf("loadingScreen.classList.add('hidden')") < startup.indexOf("loadingScreen.addEventListener('transitionend', revealFirstRun")); assert.match(startup, /initFirstRunExperience\(\{ styleManager, dataManager \}\)/); assert.match(css, /body\.ui-clean-view #first-run-launcher/); diff --git a/src/keySetup.js b/src/keySetup.js index 7d053ccb8..82bb44c81 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -142,23 +142,37 @@ function buildRow(documentRef, key) { * Wire the chip + dialog. Fire-and-forget from main.js; resolves to null when * the surface has no business existing (prod build, LAN visitor, no markup). */ -export async function initKeySetup({ documentRef = globalThis.document, fetchImpl } = {}) { +export async function initKeySetup({ documentRef = globalThis.document, fetchImpl, signal } = {}) { const chip = documentRef?.getElementById?.('key-setup-chip'); const root = documentRef?.getElementById?.('key-setup'); if (!chip || !root || root.dataset.initialized === 'true') return null; root.dataset.initialized = 'true'; + const lifetime = new AbortController(); + let disposed = false; + let disposeControls = () => {}; + const destroy = () => { + if (disposed) return; + disposed = true; + lifetime.abort(); + signal?.removeEventListener('abort', destroy); + disposeControls(); + chip.remove(); + root.remove(); + }; + if (signal?.aborted) { destroy(); return null; } + signal?.addEventListener('abort', destroy, { once: true }); const doFetch = fetchImpl || globalThis.fetch?.bind(globalThis); let status = null; try { - const response = await doFetch('/api/setup/status', { cache: 'no-store' }); + const response = await doFetch('/api/setup/status', { cache: 'no-store', signal: lifetime.signal }); if (!response.ok) throw new Error(String(response.status)); status = await response.json(); + if (disposed) return null; } catch { // Prod build or non-loopback visitor: the surface cannot function, so it // does not exist. (The README covers .env for headless/self-host setups.) - chip.remove(); - root.remove(); + destroy(); return null; } @@ -173,6 +187,7 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp let previouslyFocused = null; const render = (nextStatus) => { + if (disposed) return; status = nextStatus; chipLabel.textContent = keySetupChipLabel(status); // Fully powered is the owner's clean screen: the chip retires. The dialog @@ -223,7 +238,7 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp }; const openDialog = () => { - if (open) return; + if (disposed || open) return; open = true; previouslyFocused = documentRef.activeElement; root.hidden = false; @@ -256,7 +271,7 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp : 'your local .env'); const submitUpdates = async (updates, doneVerb) => { - if (busy) return; + if (disposed || busy) return; const googleWasUnset = !status?.keys?.find((key) => key.id === 'google-maps')?.set; busy = true; applyButton?.setAttribute('aria-disabled', 'true'); @@ -264,10 +279,12 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp try { const response = await doFetch('/api/setup/keys', { method: 'POST', + signal: lifetime.signal, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), }); const payload = await response.json().catch(() => ({})); + if (disposed) return; if (!response.ok || !payload.ok) { say(payload.error || `Save failed (${response.status}).`); return; @@ -286,7 +303,7 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp strip(); // The live share writer may re-serialize the still-OSM stack before // the restart's reload lands, so strip again at the door. - globalThis.addEventListener?.('pagehide', strip, { once: true }); + globalThis.addEventListener?.('pagehide', strip, { once: true, signal: lifetime.signal }); } say(`${doneVerb} ${storeLabel()}. Restarting — this page reloads itself.`); } catch (error) { @@ -298,7 +315,7 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp }; const onApply = async () => { - if (busy) return; + if (disposed || busy) return; const inputs = [...root.querySelectorAll('input[data-env-var]')]; const updates = collectKeyUpdates( inputs.map((input) => ({ envVar: input.dataset.envVar, value: input.value })), @@ -316,7 +333,7 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp // Remove buttons are rendered per row; delegate so re-renders stay wired. rowsHost?.addEventListener('click', (event) => { const button = event.target?.closest?.('[data-key-setup-remove]'); - if (!button || busy) return; + if (disposed || !button || busy) return; let envVars = []; try { envVars = JSON.parse(button.dataset.keySetupRemove || '[]'); @@ -346,5 +363,12 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp // An unparsable location never blocks init. } - return { open: openDialog, close, render }; + disposeControls = () => { + open = false; + documentRef.removeEventListener('keydown', onKeyDown, true); + chip.removeEventListener('click', openDialog); + closeButton?.removeEventListener('click', close); + applyButton?.removeEventListener('click', onApply); + }; + return { open: openDialog, close, render, destroy }; } diff --git a/src/keySetup.test.mjs b/src/keySetup.test.mjs index 8628f9f96..2918e6f5c 100644 --- a/src/keySetup.test.mjs +++ b/src/keySetup.test.mjs @@ -39,3 +39,26 @@ test('the first Google key strips ONLY the keyless OSM basemap from the share ha assert.equal(stripKeylessBasemapFromHash(''), null); assert.equal(stripKeylessBasemapFromHash(undefined), null); }); + +test('aborting pending setup removes its surface and ignores a late response', async () => { + const { initKeySetup } = await import('./keySetup.js'); + const removed = []; + const chip = { remove: () => removed.push('chip') }; + const root = { dataset: {}, remove: () => removed.push('root') }; + let resolveResponse; + let requestSignal; + const controller = new AbortController(); + const pending = initKeySetup({ + documentRef: { getElementById: (id) => id === 'key-setup-chip' ? chip : root }, + signal: controller.signal, + fetchImpl: (_url, { signal }) => { + requestSignal = signal; + return new Promise((resolve) => { resolveResponse = resolve; }); + }, + }); + controller.abort(); + assert.equal(requestSignal.aborted, true); + assert.deepEqual(removed, ['chip', 'root']); + resolveResponse({ ok: true, json: async () => ({ keys: [] }) }); + assert.equal(await pending, null); +}); diff --git a/src/main.js b/src/main.js index 0146a7f4c..de28f2cd7 100644 --- a/src/main.js +++ b/src/main.js @@ -1,340 +1,17 @@ -import * as Cesium from 'cesium'; -import { StyleManager } from './ui.js'; -import { flyToAustin } from './camera.js'; -import { DataLayerManager } from './data/manager.js'; -import flightsLayer from './data/flights.js'; -import militaryFlightsLayer from './data/militaryFlights.js'; -import earthquakesLayer from './data/earthquakes.js'; -import satellitesLayer from './data/satellites.js'; -import rocketLaunchesLayer from './data/rocketLaunches.js'; -import trafficLayer from './data/traffic.js'; -import cctvLayer from './data/cctv.js'; -import radioLayer from './data/radio.js'; -import bikeshareLayer from './data/bikeshare.js'; -import aisLiveVesselsLayer from './data/aisLiveVessels.js'; -import militaryInstallationsLayer from './data/militaryInstallations.js'; -import militaryAwarenessLayer from './data/militaryAwareness.js'; -import localDataLayers from './data/localLayers.js'; -import { LAYER_STATE_REGISTRY } from './data/layerState.js'; -import { registerDataCredits } from './data/dataCredits.js'; -import { configureCreditKeyboardAccess } from './creditKeyboard.js'; -import { SceneDirector } from './scenes/director.js'; -import { initGevVoiceCommands } from './voice/gevRealtime.js'; -import { MapStackController } from './mapStackController.js'; -import { initAnnotations } from './annotations/index.js'; -import { initLogoGaze } from './logoGaze.js'; -import { initCockpitCloudEffects } from './cockpitCloudEffects.js'; -import { - installRenderGovernor, - getRenderGovernorDiagnostics, - governorRequestRender, - holdContinuousRender, - releaseContinuousRender, -} from './renderGovernor.js'; -import { installScopeMask } from './scopeMask.js'; -import { initFirstRunExperience } from './firstRunExperience.js'; -import { initKeySetup } from './keySetup.js'; -import { loadPhotorealisticTileset } from './mapStartup.js'; +import { createLocalApplication } from './editions/local/application.js'; +import { describeError } from './editions/local/errors.js'; -initLogoGaze(); +const application = createLocalApplication({ + googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY, + cesiumToken: import.meta.env.CESIUM_ION_TOKEN, + allowQaRegistration: import.meta.env.DEV, +}); -/** - * Extract a human-readable error message from any thrown value. - * Handles Error objects, strings, and plain objects with message/error fields. - * @param {*} error — caught exception value - * @returns {string} best-effort error description - */ -function describeError(error) { - if (!error) return 'Unknown initialization error'; - if (error instanceof Error) { - if (error.message && error.message.trim()) return error.message.trim(); - return error.name || 'Initialization error'; - } - if (typeof error === 'string' && error.trim()) return error.trim(); - if (typeof error === 'object') { - const maybeMessage = String(error.message || error.error || '').trim(); - if (maybeMessage) return maybeMessage; - try { - const serialized = JSON.stringify(error); - if (serialized && serialized !== '{}') return serialized; - } catch { - // ignore serialization error - } - } - return String(error); -} +application.start().catch((error) => { + console.error("God's Eye View initialization failed:", error); + const loaderStatus = document.querySelector('#loading-screen .loader-status'); + loaderStatus.textContent = `Error: ${describeError(error)}`; + loaderStatus.style.color = '#ff4444'; +}); -/** - * GOD'S EYE VIEW — Main Entry Point - * Initializes CesiumJS with Google Photorealistic 3D Tiles, - * style system, intelligence HUD, location presets, and share links. - */ -async function init() { - const loadingScreen = document.getElementById('loading-screen'); - const loaderStatus = loadingScreen.querySelector('.loader-status'); - - try { - loaderStatus.textContent = 'Configuring viewer...'; - - // A direct Google key provides Google 3D plus GEV place search. Cesium ion - // can host the same 3D tiles and also powers Bing/world-terrain stacks. - const cesiumToken = import.meta.env.CESIUM_ION_TOKEN; - const googleApiKey = import.meta.env.GOOGLE_MAPS_API_KEY; - if (googleApiKey) window.__GOOGLE_MAPS_API_KEY__ = googleApiKey; - - // Create the Cesium viewer with minimal chrome - const viewer = new Cesium.Viewer('cesiumContainer', { - timeline: false, - animation: false, - baseLayerPicker: false, - geocoder: false, - homeButton: false, - sceneModePicker: false, - navigationHelpButton: false, - fullscreenButton: false, - vrButton: false, - selectionIndicator: false, - infoBox: false, - baseLayer: false, - // Visible attribution container — Google Maps / 3D Tiles credits are - // required by Google's Terms of Service, so they must be shown (styled - // subtly via #cesium-credits). The credit line stays visible in - // clean-view AND recording modes too (ToS requires attribution while the - // content is displayed — those are the exact modes used to record - // demos), including the "Data attribution" link that opens the per-layer - // license popover. - creditContainer: (() => { - const el = document.createElement('div'); - el.id = 'cesium-credits'; - document.body.appendChild(el); - return el; - })(), - msaaSamples: 4, - contextOptions: { - webgl: { - preserveDrawingBuffer: true, - }, - }, - }); - - // Cap the default render loop at 60 fps. Cesium's loop otherwise runs at - // the display's refresh rate — 120 Hz on ProMotion panels — doubling GPU - // and CPU burn for zero visual benefit in a map app whose animation - // cadences (poll interpolation, trail fades, style crossfades) are all - // designed against wall-clock time, not frame count. Measured on the - // 2026-08-05 perf investigation as a strict halving of idle burn on - // 120 Hz hardware; a no-op on 60 Hz displays. (perf item 2) - viewer.targetFrameRate = 60; - - // Register per-layer data attribution into the "Data attribution" popover. - // Required by each source's license (ODbL, CC BY-NC-SA, NASA FIRMS, etc.); - // strings are verbatim from DATA_SOURCES.md. Static + always-present in the - // expandable bottom-left credit lightbox (showOnScreen=false), so they never - // clutter the on-globe line. See docs/pre-ship-audit-2026-07-01.md H11. - registerDataCredits(viewer); - configureCreditKeyboardAccess(document); - - // Hide Cesium's default globe — Google Photorealistic 3D Tiles provide their own - // globe at all LODs (street level → orbital). The default globe's 2D imagery - // clips through 3D tile buildings at close range. - viewer.scene.globe.show = false; - - // Keep a sky behind Google 3D Tiles, but soften Cesium's high-intensity - // default atmosphere. With the globe hidden its bright limb otherwise - // reads as a hard cyan seam where distant photoreal tiles meet the sky. - viewer.scene.skyAtmosphere.show = true; - viewer.scene.skyAtmosphere.atmosphereLightIntensity = 18; - viewer.scene.skyAtmosphere.saturationShift = -0.12; - viewer.scene.skyAtmosphere.brightnessShift = -0.08; - - loaderStatus.textContent = googleApiKey || cesiumToken - ? 'Loading Google 3D Tiles...' - : 'Loading the keyless globe...'; - const photoreal = await loadPhotorealisticTileset(Cesium, { - googleApiKey, - cesiumToken, - }); - const tileset = photoreal.tileset; - 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; - } - - 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), - }); - await mapStackController.setStack(tileset ? 'photoreal' : 'esri-imagery', { silent: true }); - - // Initialize the style manager (post-processing, HUD, locations, share links) - const styleManager = new StyleManager(viewer, { mapStackController }); - // 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); - - // If no share link state, do default fly-to Austin - if (!styleManager.hasShareState) { - loaderStatus.textContent = 'Flying to Austin, TX...'; - flyToAustin(viewer); - } else { - loaderStatus.textContent = 'Restoring shared view...'; - } - - // Initialize data layer manager - const dataManager = new DataLayerManager(viewer, { - allowQaRegistration: import.meta.env.DEV, - }); - 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 (import.meta.env.DEV) { - 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); - }; - } - dataManager.buildTogglePanel(document.getElementById('data-toggles')); - styleManager.attachDataManager(dataManager); - - // Initialize deterministic scene playback for social clip capture - const sceneDirector = new SceneDirector(viewer, styleManager, dataManager); - - // Initialize the voice "whiteboard" annotation engine (world-space renderer) - const annotations = initAnnotations({ viewer, tileset }); - - // Keep startup chrome truthful: a share is not restored until camera, - // visual/map/panel lanes, and every requested layer have terminated. - void Promise.all([ - styleManager.initialRestorePromise, - new Promise((resolve) => setTimeout(resolve, 1000)), - ]).finally(() => { - loadingScreen.classList.add('hidden'); - // Reveal only after the loading cover has yielded. transitionend can be - // absent under reduced motion, so a bounded fallback makes this reliable. - let firstRunRevealed = false; - const revealFirstRun = () => { - if (firstRunRevealed) return; - firstRunRevealed = true; - // dataManager is passed explicitly: the globe missions enable bundled - // keyless layers through it, and reaching for styleManager._dataManager - // would make a private field part of this feature's contract. - initFirstRunExperience({ styleManager, dataManager }); - }; - loadingScreen.addEventListener('transitionend', revealFirstRun, { once: true }); - setTimeout(revealFirstRun, 900); - }); - - // Provider Settings (the POWER UP chip + dialog). Fire-and-forget: the - // module removes its own surface when the dev-server endpoint is absent - // (prod builds, non-local visitors), so this costs prod exactly nothing. - void initKeySetup(); - - // Expose for debugging - // 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); - - // The explicit scope mask replaces the emergent six-pass artifact — - // see src/scopeMask.js. Installed before the UI so the DISPLAY-rail - // toggle finds it live. - installScopeMask(viewer); - - // The follow camera recomputes the tracked target's dead-reckon position - // every frame — tracking anything is a per-frame animation. (perf wave 2) - 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(); - } - governorRequestRender('visibility-restore'); - } - }; - document.addEventListener('visibilitychange', syncVisibilitySuspension); - // 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, - }; - window.__godsEyeView.voiceCommands = initGevVoiceCommands({ viewer, styleManager, dataManager, sceneDirector, annotations }); - - } catch (error) { - console.error("God's Eye View initialization failed:", error); - loaderStatus.textContent = `Error: ${describeError(error)}`; - loaderStatus.style.color = '#ff4444'; - } -} - -init(); +export { application }; diff --git a/src/mapStackController.js b/src/mapStackController.js index 4da79c91d..e215e1650 100644 --- a/src/mapStackController.js +++ b/src/mapStackController.js @@ -200,7 +200,20 @@ export class MapStackController { return true; } + /** Invalidate pending switches before the viewer is destroyed. */ + destroy() { + if (this._destroyed) return; + this._destroyed = true; + this._switchGen++; + this._isSwitching = false; + this._removeImageryErrorListener?.(); + this._removeImageryErrorListener = null; + this._onChange = null; + this._onError = null; + } + async setStack(id, { silent = false } = {}) { + if (this._destroyed) return this.getState(); const stack = this.getStack(id) || this.getStack('photoreal'); if (!stack) return null; diff --git a/src/renderGovernor.js b/src/renderGovernor.js index 5a7b565a1..5b452da84 100644 --- a/src/renderGovernor.js +++ b/src/renderGovernor.js @@ -128,6 +128,15 @@ export function getRenderGovernorDiagnostics() { }; } +/** Release the installed viewer after its animation owners have stopped. */ +export function uninstallRenderGovernor(viewer) { + if (_viewer !== viewer) return; + _viewer = null; + _installed = false; + _holds.clear(); + _recentRequests.length = 0; +} + /** Test seam: reset module state between unit tests. */ export function _resetRenderGovernorForTest() { _viewer = null; diff --git a/src/scenes/director.js b/src/scenes/director.js index 2a8905e41..4109c307f 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -296,6 +296,9 @@ export class SceneDirector { * @param {Object} dataManager - Manages data layer enable/disable and per-layer params */ constructor(viewer, styleManager, dataManager) { + this._destroyed = false; + this._pendingWork = new Set(); + this._uiRemovers = []; this.viewer = viewer; this.styleManager = styleManager; this.dataManager = dataManager; @@ -346,6 +349,40 @@ export class SceneDirector { this._initUI(); } + _listen(target, type, listener) { + if (!target) return; + target.addEventListener(type, listener); + this._uiRemovers.push(() => target.removeEventListener(type, listener)); + } + + _trackWork(promise) { + this._pendingWork ||= new Set(); + this._pendingWork.add(promise); + const release = () => this._pendingWork.delete(promise); + promise.then(release, release); + return promise; + } + + /** Stop playback and pending loads before releasing their viewer. */ + destroy() { + if (this._destroyPromise) return this._destroyPromise; + this._destroyed = true; + this._destroyPromise = Promise.resolve().then(async () => { + this.stopScene('Stopped'); + this._loadAbort?.abort(); + this._loadGeneration++; + this.viewer.camera.cancelFlight(); + for (const remove of this._uiRemovers || []) remove(); + this._uiRemovers = []; + if (this._sceneShotList) this._sceneShotList.textContent = ''; + clearTimeout(this._storageToastTimer); + await Promise.allSettled(this._pendingWork || []); + clearInterval(this._progressTimer); + document.removeEventListener('keydown', this._onKeyDown); + }); + return this._destroyPromise; + } + /** * Load and normalize the project from localStorage. * Returns the default recipe-based project on missing or corrupt data. @@ -402,46 +439,46 @@ export class SceneDirector { this._renderSceneSelect(); this._renderShotList(); - this._sceneSelect.addEventListener('change', () => { + this._listen(this._sceneSelect, 'change', () => { this._selectedSceneId = this._sceneSelect.value; const scene = this._getSelectedScene(); this._selectedShotId = scene?.shots[0]?.id || null; this._renderShotList(); }); - this._sceneNewBtn?.addEventListener('click', () => this._createScene()); - this._sceneDeleteBtn?.addEventListener('click', () => this._deleteSelectedScene()); - this._sceneCaptureBtn?.addEventListener('click', () => this.captureShot()); - this._sceneUpdateShotBtn?.addEventListener('click', () => this.updateSelectedShot()); + this._listen(this._sceneNewBtn, 'click', () => this._createScene()); + this._listen(this._sceneDeleteBtn, 'click', () => this._deleteSelectedScene()); + this._listen(this._sceneCaptureBtn, 'click', () => this.captureShot()); + this._listen(this._sceneUpdateShotBtn, 'click', () => this.updateSelectedShot()); - this._sceneStartBtn?.addEventListener('click', () => { + this._listen(this._sceneStartBtn, 'click', () => { this.startScene(this._selectedSceneId); }); - this._sceneStopBtn?.addEventListener('click', () => { + this._listen(this._sceneStopBtn, 'click', () => { this.stopScene('Stopped'); }); - this._sceneNextBtn?.addEventListener('click', () => { + this._listen(this._sceneNextBtn, 'click', () => { this.runNextScene(); }); - this._sceneExportBtn?.addEventListener('click', () => { + this._listen(this._sceneExportBtn, 'click', () => { this.exportProject(); }); - this._sceneImportBtn?.addEventListener('click', () => { + this._listen(this._sceneImportBtn, 'click', () => { this._sceneImportFile?.click(); }); - this._sceneImportFile?.addEventListener('change', async () => { + this._listen(this._sceneImportFile, 'change', async () => { const file = this._sceneImportFile?.files?.[0]; if (!file) return; await this.importProjectFile(file); this._sceneImportFile.value = ''; }); - this._sceneDownloadBtn?.addEventListener('click', () => { + this._listen(this._sceneDownloadBtn, 'click', () => { this.downloadLastRunMetadata(); }); @@ -719,7 +756,12 @@ export class SceneDirector { * @param {Object} [options] * @param {number} [options.flyDuration=2.2] - Camera flight duration in seconds */ - async loadShot(sceneId, shotId, { flyDuration = 2.2 } = {}) { + loadShot(sceneId, shotId, options) { + if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' }); + return this._trackWork(this._loadShot(sceneId, shotId, options)); + } + + async _loadShot(sceneId, shotId, { flyDuration = 2.2 } = {}) { if (this._running) return; const { scene, shot } = this._getShot(sceneId, shotId); if (!scene || !shot) return; @@ -875,7 +917,12 @@ export class SceneDirector { * round-robining through the whole project (voice playback uses this). * @returns {Promise<{started: boolean, reason?: string, shots?: number}>} */ - async startScene(sceneId, { single = false } = {}) { + startScene(sceneId, options) { + if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' }); + return this._trackWork(this._startScene(sceneId, options)); + } + + async _startScene(sceneId, { single = false } = {}) { if (this._running) return { started: false, reason: 'already-running' }; const queue = this._buildPlaybackQueue(sceneId || this._selectedSceneId || this._project.scenes[0]?.id, { single }); @@ -1007,7 +1054,7 @@ export class SceneDirector { * it without starting a full run. Used for manual step-through navigation. */ async runNextScene() { - if (this._running) return; + if (this._destroyed || this._running) return; const queue = this._buildPlaybackQueue(this._selectedSceneId || this._project.scenes[0]?.id); if (!queue.length) return; @@ -1069,6 +1116,7 @@ export class SceneDirector { async importProjectFile(file) { try { const text = await file.text(); + if (this._destroyed) return; const parsed = JSON.parse(text); this._project = normalizeProject(parsed); this._selectedSceneId = this._project.scenes[0]?.id || null; @@ -1227,9 +1275,11 @@ export class SceneDirector { await new Promise((resolve) => { // Guard against double-resolve from both callback and timeout let done = false; + let timer; const finish = () => { if (done) return; done = true; + clearTimeout(timer); resolve(); }; @@ -1247,7 +1297,7 @@ export class SceneDirector { }); // Safety timeout in case Cesium callbacks fail to fire - setTimeout(finish, (duration + 0.6) * 1000); + if (!done) timer = setTimeout(finish, (duration + 0.6) * 1000); }); } diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index 3c1232ba9..e7e35ff38 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -623,3 +623,25 @@ test('a scene run supersedes a LOAD still suspended on its visual await', async restore(); } }); + +test('destroy drains cancelled LOAD work before its viewer can be discarded', async (t) => { + const restore = installSceneRuntime(); + t.after(restore); + let finishVisual; + const dataManager = fakeDataManager(); + const viewer = { camera: { cancelFlight() {} } }; + const director = new SceneDirector(viewer, { + applyVisualState: () => new Promise((resolve) => { finishVisual = resolve; }), + }, dataManager); + const loading = director.loadShot('scene-1', 'shot-a'); + let destroyed = false; + const stopping = director.destroy().then(() => { destroyed = true; }); + await Promise.resolve(); + assert.equal(destroyed, false); + finishVisual(); + await Promise.all([loading, stopping]); + assert.equal(destroyed, true); + assert.equal(dataManager.setEnabledCalls.length, 0); + assert.equal((await director.startScene('scene-1')).reason, 'destroyed'); + await director.destroy(); +});