Merge pull request #319: application lifecycle and standalone startup

Preserve separate application extraction and mechanical-formatting commits.
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-11 17:14:11 -07:00
commit 493acfc6aa
37 files changed
+1606 -388

No files matched your search

+4
View File
@@ -15,6 +15,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
### Development ### 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 - Adopt Prettier tooling contributed by RohanDaCoder (#227), with an explicit
file scope, pinned formatter and Linux/Windows CI checks. Format the reusable file scope, pinned formatter and Linux/Windows CI checks. Format the reusable
infrastructure modules and their consumer tests. Package boundary checks keep infrastructure modules and their consumer tests. Package boundary checks keep
+102
View File
@@ -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.
+17 -12
View File
@@ -16,15 +16,15 @@ excluded. The formatter validates every entry before writing any file.
## Current component ownership ## Current component ownership
| Surface | Owns | Receives from its caller | | Surface | Owns | Receives from its caller |
| -------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | | --------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- |
| `gods-eye-view/infrastructure` | Datacenter/dam definitions and fresh layer construction | Context, overlay and render operations | | `gods-eye-view/infrastructure` | Datacenter/dam definitions and fresh layer construction | Context, overlay and render operations |
| `gods-eye-view/infrastructure/geojson` | Data loading, Cesium entities, selection handling and resource cleanup | A viewer and those same operations | | `gods-eye-view/infrastructure/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 | | `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/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. exports instead of importing standalone startup or reaching into internal files.
The application owns the viewer, context store, overlay host and render scheduler; The application owns the viewer, context store, overlay host and render scheduler;
layers use the supplied callbacks. See [the infrastructure contract](INFRASTRUCTURE-LAYERS.md). 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 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. them into a browser component is not supported. No Node service is exported yet.
This setup establishes boundaries for the existing infrastructure component. `gods-eye-view/application` owns construction order, startup state, cancellation
Application construction, UI panels and individual source adapters remain future and disposal of caller-supplied components. Its only owned module is
extractions; they should become smaller modules with explicit lifecycle owners `src/app/application.js`. `gods-eye-view/application/viewer` separately owns the
as their callers migrate. 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.
+14
View File
@@ -1,5 +1,19 @@
# God's Eye View Current State # 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 ## Scoped formatting and package checks
`npm run format` and `npm run format:check` operate on the explicit adopted-file `npm run format` and `npm run format:check` operate on the explicit adopted-file
+3 -1
View File
@@ -64,6 +64,8 @@
"exports": { "exports": {
"./infrastructure": "./src/data/infrastructure.js", "./infrastructure": "./src/data/infrastructure.js",
"./infrastructure/geojson": "./src/data/localGeojsonCore.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"
} }
} }
+2 -2
View File
@@ -226,8 +226,8 @@ if [[ ! -f "src/data/cctv.js" ]]; then
exit 1 exit 1
fi fi
if ! grep -q "dataManager.register(cctvLayer)" src/main.js; then if ! grep -q "dataManager.register(cctvLayer)" src/editions/local/data.js; then
echo "error: CCTV layer not wired in src/main.js" echo "error: CCTV layer not wired in src/editions/local/data.js"
exit 1 exit 1
fi fi
+17 -1
View File
@@ -11,5 +11,21 @@
"src/data/infrastructure.test.mjs", "src/data/infrastructure.test.mjs",
"src/data/localGeojsonCore.js", "src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js", "src/data/localGeojsonLod.js",
"docs/CODE-BOUNDARIES.md" "docs/CODE-BOUNDARIES.md",
"src/main.js",
"docs/APPLICATION.md",
"scripts/qa-application.mjs",
"src/app/application.js",
"src/app/application.test.mjs",
"src/app/mapLifecycle.test.mjs",
"src/app/startupCamera.test.mjs",
"src/app/viewer.js",
"src/editions/local/application.js",
"src/editions/local/controls.js",
"src/editions/local/data.js",
"src/editions/local/errors.js",
"src/editions/local/scene.js",
"src/editions/local/startupChrome.js",
"src/editions/local/startupChrome.test.mjs",
"src/editions/local/tools.js"
] ]
+10
View File
@@ -11,5 +11,15 @@
"src/data/localGeojsonLod.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"]
} }
} }
+116
View File
@@ -0,0 +1,116 @@
#!/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();
}
+22 -2
View File
@@ -101,6 +101,7 @@ export function createAnnotationEngine({
}) { }) {
/** @type {Map<string, object>} live annotations keyed by id */ /** @type {Map<string, object>} live annotations keyed by id */
const annotations = new Map(); const annotations = new Map();
let destroyed = false;
let tickHandle = null; let tickHandle = null;
let assistFlightUntil = 0; // camera-assist debounce (see ensureMarksVisible) let assistFlightUntil = 0; // camera-assist debounce (see ensureMarksVisible)
// Generation token + in-flight fetch controllers guard against clear / new-topic // Generation token + in-flight fetch controllers guard against clear / new-topic
@@ -174,7 +175,7 @@ export function createAnnotationEngine({
} }
function ensureTicking() { function ensureTicking() {
if (tickHandle != null) return; if (destroyed || tickHandle != null) return;
const tick = () => { const tick = () => {
const now = performance.now(); const now = performance.now();
let changed = false; let changed = false;
@@ -222,6 +223,7 @@ export function createAnnotationEngine({
* @returns {Promise<{ok, drawn, failed, ids, results}>} * @returns {Promise<{ok, drawn, failed, ids, results}>}
*/ */
async function annotate(requests, opts = {}) { 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]; const list = Array.isArray(requests) ? requests : [requests];
if (opts.clearPrevious) clear(); // bumps generation + aborts older pending work if (opts.clearPrevious) clear(); // bumps generation + aborts older pending work
@@ -865,6 +867,17 @@ export function createAnnotationEngine({
const engine = { const engine = {
annotate, annotate,
clear, clear,
destroy() {
if (destroyed) return;
destroyed = true;
try { clear(); } finally {
if (tickHandle != null) cancelAnimationFrame(tickHandle);
tickHandle = null;
outlineListeners.clear();
releaseContinuousRender('annotations');
renderer.destroy();
}
},
fadeOutAll, fadeOutAll,
count: () => annotations.size, count: () => annotations.size,
list: () => Array.from(annotations.values()), 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. * upgrade task, AFTER the mark was mutated in place. Returns an unsubscribe fn.
*/ */
onOutlineEvent(listener) { onOutlineEvent(listener) {
if (typeof listener !== 'function') return () => {}; if (destroyed || typeof listener !== 'function') return () => {};
outlineListeners.add(listener); outlineListeners.add(listener);
return () => outlineListeners.delete(listener); return () => outlineListeners.delete(listener);
}, },
@@ -901,16 +914,22 @@ export function createAnnotationEngine({
async tour() { async tour() {
clear(); clear();
flyTo({ lon: -122.4486, lat: 37.7960, height: 520, heading: 0, pitch: -26, duration: 3 }); flyTo({ lon: -122.4486, lat: 37.7960, height: 520, heading: 0, pitch: -26, duration: 3 });
if (destroyed) return { ok: false };
await wait(3200); await wait(3200);
await annotate([{ type: 'highlight', target: 'Palace of Fine Arts, San Francisco', label: 'Palace of Fine Arts', color: 'amber' }], { persist: true }); 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 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 }); 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); await wait(2600);
flyTo({ lon: -122.4545, lat: 37.7880, height: 1500, heading: 18, pitch: -32, duration: 3 }); flyTo({ lon: -122.4545, lat: 37.7880, height: 1500, heading: 18, pitch: -32, duration: 3 });
if (destroyed) return { ok: false };
await wait(3200); 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 }); 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 wait(2800);
await annotate([{ type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', label: 'ILM / Lucasfilm', color: 'red' }], { persist: true }); 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 wait(2600);
await annotate([{ type: 'route', color: 'amber', label: 'Crissy Field shoreline', points: [ await annotate([{ type: 'route', color: 'amber', label: 'Crissy Field shoreline', points: [
{ target: 'Palace of Fine Arts, San Francisco' }, { 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 }) { function flyTo({ lon, lat, height, heading = 0, pitch = -30, duration = 2.5 }) {
if (destroyed) return;
try { try {
viewer.camera.flyTo({ viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(lon, lat, height), destination: Cesium.Cartesian3.fromDegrees(lon, lat, height),
+41
View File
@@ -668,3 +668,44 @@ test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing
assert.equal(retry.drawn, 1); assert.equal(retry.drawn, 1);
assert.equal(live.size, 1, 'the recoloured mark redraws once, with no orphan underneath'); 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);
});
+152
View File
@@ -0,0 +1,152 @@
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);
},
});
}
+219
View File
@@ -0,0 +1,219 @@
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/);
});
+57
View File
@@ -0,0 +1,57 @@
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, []);
});
+25
View File
@@ -0,0 +1,25 @@
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);
});
+36
View File
@@ -0,0 +1,36 @@
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
View File
@@ -48,6 +48,7 @@ export function flyToPreset(viewer, presetName, duration = 3.0) {
/** /**
* Set camera to Austin on load with a cinematic fly-in. * Set camera to Austin on load with a cinematic fly-in.
* @returns {Function} Cancels the pending or active startup flight.
*/ */
export function flyToAustin(viewer) { export function flyToAustin(viewer) {
// Start from a high altitude, then fly down // Start from a high altitude, then fly down
@@ -61,7 +62,8 @@ export function flyToAustin(viewer) {
}); });
// Cinematic fly-in after a brief pause // Cinematic fly-in after a brief pause
setTimeout(() => { const timer = setTimeout(() => {
if (viewer.isDestroyed()) return;
viewer.camera.flyTo({ viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 600), destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 600),
orientation: { orientation: {
@@ -73,4 +75,8 @@ export function flyToAustin(viewer) {
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
}); });
}, 500); }, 500);
return () => {
clearTimeout(timer);
if (!viewer.isDestroyed()) viewer.camera.cancelFlight();
};
} }
+1 -1
View File
@@ -2318,7 +2318,7 @@ const trafficLayer = {
viewer.camera.percentageChanged = _prevPercentageChanged; viewer.camera.percentageChanged = _prevPercentageChanged;
_prevPercentageChanged = null; _prevPercentageChanged = null;
} }
_pointCollection.show = false; if (_pointCollection) _pointCollection.show = false;
}, },
/** /**
+8
View File
@@ -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.ok(!LIVE_CLAIM.test(stats.loadingLabel), `boot label implies live data: ${stats.loadingLabel}`);
assert.equal(layerFeedState(stats), 'fallback'); 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);
});
+2 -1
View File
@@ -20,7 +20,8 @@ async function launch(overrides = {}, dotenv = '') {
for (const name of ['dev-cctv.sh', 'dev-fresh.sh', 'read-dotenv-value.mjs']) { 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(`../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.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.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); await fs.writeFile(path.join(root, '.env'), dotenv);
+30
View File
@@ -0,0 +1,30 @@
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 }),
});
}
+32
View File
@@ -0,0 +1,32 @@
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 };
}
+78
View File
@@ -0,0 +1,78 @@
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 };
}
+25
View File
@@ -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);
}
+105
View File
@@ -0,0 +1,105 @@
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 };
}
+51
View File
@@ -0,0 +1,51 @@
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();
};
}
+125
View File
@@ -0,0 +1,125 @@
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']);
});
+113
View File
@@ -0,0 +1,113 @@
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 };
}
+9 -1
View File
@@ -643,5 +643,13 @@ export function initFirstRunExperience({
} }
syncToExclusiveSurfaces(); 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 };
} }
+2 -3
View File
@@ -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', () => { test('markup, startup ordering and accessibility remain pinned', () => {
const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); 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'); 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/); 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"/, assert.doesNotMatch(html, /data-first-run-choice="infrastructure"/,
'the removed tile must leave no markup behind'); '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.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(startup, /initFirstRunExperience\(\{ styleManager, dataManager \}\)/);
assert.match(css, /body\.ui-clean-view #first-run-launcher/); assert.match(css, /body\.ui-clean-view #first-run-launcher/);
+34 -10
View File
@@ -142,23 +142,37 @@ function buildRow(documentRef, key) {
* Wire the chip + dialog. Fire-and-forget from main.js; resolves to null when * 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). * 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 chip = documentRef?.getElementById?.('key-setup-chip');
const root = documentRef?.getElementById?.('key-setup'); const root = documentRef?.getElementById?.('key-setup');
if (!chip || !root || root.dataset.initialized === 'true') return null; if (!chip || !root || root.dataset.initialized === 'true') return null;
root.dataset.initialized = 'true'; 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); const doFetch = fetchImpl || globalThis.fetch?.bind(globalThis);
let status = null; let status = null;
try { 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)); if (!response.ok) throw new Error(String(response.status));
status = await response.json(); status = await response.json();
if (disposed) return null;
} catch { } catch {
// Prod build or non-loopback visitor: the surface cannot function, so it // Prod build or non-loopback visitor: the surface cannot function, so it
// does not exist. (The README covers .env for headless/self-host setups.) // does not exist. (The README covers .env for headless/self-host setups.)
chip.remove(); destroy();
root.remove();
return null; return null;
} }
@@ -173,6 +187,7 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
let previouslyFocused = null; let previouslyFocused = null;
const render = (nextStatus) => { const render = (nextStatus) => {
if (disposed) return;
status = nextStatus; status = nextStatus;
chipLabel.textContent = keySetupChipLabel(status); chipLabel.textContent = keySetupChipLabel(status);
// Fully powered is the owner's clean screen: the chip retires. The dialog // 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 = () => { const openDialog = () => {
if (open) return; if (disposed || open) return;
open = true; open = true;
previouslyFocused = documentRef.activeElement; previouslyFocused = documentRef.activeElement;
root.hidden = false; root.hidden = false;
@@ -256,7 +271,7 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
: 'your local .env'); : 'your local .env');
const submitUpdates = async (updates, doneVerb) => { const submitUpdates = async (updates, doneVerb) => {
if (busy) return; if (disposed || busy) return;
const googleWasUnset = !status?.keys?.find((key) => key.id === 'google-maps')?.set; const googleWasUnset = !status?.keys?.find((key) => key.id === 'google-maps')?.set;
busy = true; busy = true;
applyButton?.setAttribute('aria-disabled', 'true'); applyButton?.setAttribute('aria-disabled', 'true');
@@ -264,10 +279,12 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
try { try {
const response = await doFetch('/api/setup/keys', { const response = await doFetch('/api/setup/keys', {
method: 'POST', method: 'POST',
signal: lifetime.signal,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates), body: JSON.stringify(updates),
}); });
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
if (disposed) return;
if (!response.ok || !payload.ok) { if (!response.ok || !payload.ok) {
say(payload.error || `Save failed (${response.status}).`); say(payload.error || `Save failed (${response.status}).`);
return; return;
@@ -286,7 +303,7 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
strip(); strip();
// The live share writer may re-serialize the still-OSM stack before // The live share writer may re-serialize the still-OSM stack before
// the restart's reload lands, so strip again at the door. // 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.`); say(`${doneVerb} ${storeLabel()}. Restarting — this page reloads itself.`);
} catch (error) { } catch (error) {
@@ -298,7 +315,7 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
}; };
const onApply = async () => { const onApply = async () => {
if (busy) return; if (disposed || busy) return;
const inputs = [...root.querySelectorAll('input[data-env-var]')]; const inputs = [...root.querySelectorAll('input[data-env-var]')];
const updates = collectKeyUpdates( const updates = collectKeyUpdates(
inputs.map((input) => ({ envVar: input.dataset.envVar, value: input.value })), 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. // Remove buttons are rendered per row; delegate so re-renders stay wired.
rowsHost?.addEventListener('click', (event) => { rowsHost?.addEventListener('click', (event) => {
const button = event.target?.closest?.('[data-key-setup-remove]'); const button = event.target?.closest?.('[data-key-setup-remove]');
if (!button || busy) return; if (disposed || !button || busy) return;
let envVars = []; let envVars = [];
try { try {
envVars = JSON.parse(button.dataset.keySetupRemove || '[]'); envVars = JSON.parse(button.dataset.keySetupRemove || '[]');
@@ -346,5 +363,12 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
// An unparsable location never blocks init. // 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 };
} }
+23
View File
@@ -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(''), null);
assert.equal(stripKeylessBasemapFromHash(undefined), 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
View File
@@ -1,340 +1,17 @@
import * as Cesium from 'cesium'; import { createLocalApplication } from './editions/local/application.js';
import { StyleManager } from './ui.js'; import { describeError } from './editions/local/errors.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';
initLogoGaze(); const application = createLocalApplication({
googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY,
cesiumToken: import.meta.env.CESIUM_ION_TOKEN,
allowQaRegistration: import.meta.env.DEV,
});
/** application.start().catch((error) => {
* Extract a human-readable error message from any thrown value. console.error("God's Eye View initialization failed:", error);
* Handles Error objects, strings, and plain objects with message/error fields. const loaderStatus = document.querySelector('#loading-screen .loader-status');
* @param {*} error — caught exception value loaderStatus.textContent = `Error: ${describeError(error)}`;
* @returns {string} best-effort error description loaderStatus.style.color = '#ff4444';
*/ });
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);
}
/** export { application };
* 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();
+13
View File
@@ -200,7 +200,20 @@ export class MapStackController {
return true; 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 } = {}) { async setStack(id, { silent = false } = {}) {
if (this._destroyed) return this.getState();
const stack = this.getStack(id) || this.getStack('photoreal'); const stack = this.getStack(id) || this.getStack('photoreal');
if (!stack) return null; if (!stack) return null;
+9
View File
@@ -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. */ /** Test seam: reset module state between unit tests. */
export function _resetRenderGovernorForTest() { export function _resetRenderGovernorForTest() {
_viewer = null; _viewer = null;
+66 -16
View File
@@ -296,6 +296,9 @@ export class SceneDirector {
* @param {Object} dataManager - Manages data layer enable/disable and per-layer params * @param {Object} dataManager - Manages data layer enable/disable and per-layer params
*/ */
constructor(viewer, styleManager, dataManager) { constructor(viewer, styleManager, dataManager) {
this._destroyed = false;
this._pendingWork = new Set();
this._uiRemovers = [];
this.viewer = viewer; this.viewer = viewer;
this.styleManager = styleManager; this.styleManager = styleManager;
this.dataManager = dataManager; this.dataManager = dataManager;
@@ -346,6 +349,40 @@ export class SceneDirector {
this._initUI(); 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. * Load and normalize the project from localStorage.
* Returns the default recipe-based project on missing or corrupt data. * Returns the default recipe-based project on missing or corrupt data.
@@ -402,46 +439,46 @@ export class SceneDirector {
this._renderSceneSelect(); this._renderSceneSelect();
this._renderShotList(); this._renderShotList();
this._sceneSelect.addEventListener('change', () => { this._listen(this._sceneSelect, 'change', () => {
this._selectedSceneId = this._sceneSelect.value; this._selectedSceneId = this._sceneSelect.value;
const scene = this._getSelectedScene(); const scene = this._getSelectedScene();
this._selectedShotId = scene?.shots[0]?.id || null; this._selectedShotId = scene?.shots[0]?.id || null;
this._renderShotList(); this._renderShotList();
}); });
this._sceneNewBtn?.addEventListener('click', () => this._createScene()); this._listen(this._sceneNewBtn, 'click', () => this._createScene());
this._sceneDeleteBtn?.addEventListener('click', () => this._deleteSelectedScene()); this._listen(this._sceneDeleteBtn, 'click', () => this._deleteSelectedScene());
this._sceneCaptureBtn?.addEventListener('click', () => this.captureShot()); this._listen(this._sceneCaptureBtn, 'click', () => this.captureShot());
this._sceneUpdateShotBtn?.addEventListener('click', () => this.updateSelectedShot()); this._listen(this._sceneUpdateShotBtn, 'click', () => this.updateSelectedShot());
this._sceneStartBtn?.addEventListener('click', () => { this._listen(this._sceneStartBtn, 'click', () => {
this.startScene(this._selectedSceneId); this.startScene(this._selectedSceneId);
}); });
this._sceneStopBtn?.addEventListener('click', () => { this._listen(this._sceneStopBtn, 'click', () => {
this.stopScene('Stopped'); this.stopScene('Stopped');
}); });
this._sceneNextBtn?.addEventListener('click', () => { this._listen(this._sceneNextBtn, 'click', () => {
this.runNextScene(); this.runNextScene();
}); });
this._sceneExportBtn?.addEventListener('click', () => { this._listen(this._sceneExportBtn, 'click', () => {
this.exportProject(); this.exportProject();
}); });
this._sceneImportBtn?.addEventListener('click', () => { this._listen(this._sceneImportBtn, 'click', () => {
this._sceneImportFile?.click(); this._sceneImportFile?.click();
}); });
this._sceneImportFile?.addEventListener('change', async () => { this._listen(this._sceneImportFile, 'change', async () => {
const file = this._sceneImportFile?.files?.[0]; const file = this._sceneImportFile?.files?.[0];
if (!file) return; if (!file) return;
await this.importProjectFile(file); await this.importProjectFile(file);
this._sceneImportFile.value = ''; this._sceneImportFile.value = '';
}); });
this._sceneDownloadBtn?.addEventListener('click', () => { this._listen(this._sceneDownloadBtn, 'click', () => {
this.downloadLastRunMetadata(); this.downloadLastRunMetadata();
}); });
@@ -719,7 +756,12 @@ export class SceneDirector {
* @param {Object} [options] * @param {Object} [options]
* @param {number} [options.flyDuration=2.2] - Camera flight duration in seconds * @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; if (this._running) return;
const { scene, shot } = this._getShot(sceneId, shotId); const { scene, shot } = this._getShot(sceneId, shotId);
if (!scene || !shot) return; if (!scene || !shot) return;
@@ -875,7 +917,12 @@ export class SceneDirector {
* round-robining through the whole project (voice playback uses this). * round-robining through the whole project (voice playback uses this).
* @returns {Promise<{started: boolean, reason?: string, shots?: number}>} * @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' }; if (this._running) return { started: false, reason: 'already-running' };
const queue = this._buildPlaybackQueue(sceneId || this._selectedSceneId || this._project.scenes[0]?.id, { single }); 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. * it without starting a full run. Used for manual step-through navigation.
*/ */
async runNextScene() { async runNextScene() {
if (this._running) return; if (this._destroyed || this._running) return;
const queue = this._buildPlaybackQueue(this._selectedSceneId || this._project.scenes[0]?.id); const queue = this._buildPlaybackQueue(this._selectedSceneId || this._project.scenes[0]?.id);
if (!queue.length) return; if (!queue.length) return;
@@ -1069,6 +1116,7 @@ export class SceneDirector {
async importProjectFile(file) { async importProjectFile(file) {
try { try {
const text = await file.text(); const text = await file.text();
if (this._destroyed) return;
const parsed = JSON.parse(text); const parsed = JSON.parse(text);
this._project = normalizeProject(parsed); this._project = normalizeProject(parsed);
this._selectedSceneId = this._project.scenes[0]?.id || null; this._selectedSceneId = this._project.scenes[0]?.id || null;
@@ -1227,9 +1275,11 @@ export class SceneDirector {
await new Promise((resolve) => { await new Promise((resolve) => {
// Guard against double-resolve from both callback and timeout // Guard against double-resolve from both callback and timeout
let done = false; let done = false;
let timer;
const finish = () => { const finish = () => {
if (done) return; if (done) return;
done = true; done = true;
clearTimeout(timer);
resolve(); resolve();
}; };
@@ -1247,7 +1297,7 @@ export class SceneDirector {
}); });
// Safety timeout in case Cesium callbacks fail to fire // Safety timeout in case Cesium callbacks fail to fire
setTimeout(finish, (duration + 0.6) * 1000); if (!done) timer = setTimeout(finish, (duration + 0.6) * 1000);
}); });
} }
+22
View File
@@ -623,3 +623,25 @@ test('a scene run supersedes a LOAD still suspended on its visual await', async
restore(); 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();
});