mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
refactor: extract application construction and lifecycle
This commit is contained in:
1 parent
092f28e301
commit
4d37760314
36 files changed
+1363
-382
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
+11
-6
@@ -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.
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -101,6 +101,7 @@ export function createAnnotationEngine({
|
||||
}) {
|
||||
/** @type {Map<string, object>} 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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -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();
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -2318,7 +2318,7 @@ const trafficLayer = {
|
||||
viewer.camera.percentageChanged = _prevPercentageChanged;
|
||||
_prevPercentageChanged = null;
|
||||
}
|
||||
_pointCollection.show = false;
|
||||
if (_pointCollection) _pointCollection.show = false;
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
Vendored
+28
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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/);
|
||||
|
||||
+34
-10
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
+14
-337
@@ -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 };
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+66
-16
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user