refactor: extract application construction and lifecycle

This commit is contained in:
Sameh Khamis committed 2026-09-11 17:02:54 -07:00
1 parent 092f28e301
commit 4d37760314
36 files changed
+1363 -382

No files matched your search

+22 -2
View File
@@ -101,6 +101,7 @@ export function createAnnotationEngine({
}) {
/** @type {Map<string, object>} live annotations keyed by id */
const annotations = new Map();
let destroyed = false;
let tickHandle = null;
let assistFlightUntil = 0; // camera-assist debounce (see ensureMarksVisible)
// Generation token + in-flight fetch controllers guard against clear / new-topic
@@ -174,7 +175,7 @@ export function createAnnotationEngine({
}
function ensureTicking() {
if (tickHandle != null) return;
if (destroyed || tickHandle != null) return;
const tick = () => {
const now = performance.now();
let changed = false;
@@ -222,6 +223,7 @@ export function createAnnotationEngine({
* @returns {Promise<{ok, drawn, failed, ids, results}>}
*/
async function annotate(requests, opts = {}) {
if (destroyed) return { ok: false, drawn: 0, failed: 0, ids: [], results: [], error: 'destroyed' };
const list = Array.isArray(requests) ? requests : [requests];
if (opts.clearPrevious) clear(); // bumps generation + aborts older pending work
@@ -865,6 +867,17 @@ export function createAnnotationEngine({
const engine = {
annotate,
clear,
destroy() {
if (destroyed) return;
destroyed = true;
try { clear(); } finally {
if (tickHandle != null) cancelAnimationFrame(tickHandle);
tickHandle = null;
outlineListeners.clear();
releaseContinuousRender('annotations');
renderer.destroy();
}
},
fadeOutAll,
count: () => annotations.size,
list: () => Array.from(annotations.values()),
@@ -875,7 +888,7 @@ export function createAnnotationEngine({
* upgrade task, AFTER the mark was mutated in place. Returns an unsubscribe fn.
*/
onOutlineEvent(listener) {
if (typeof listener !== 'function') return () => {};
if (destroyed || typeof listener !== 'function') return () => {};
outlineListeners.add(listener);
return () => outlineListeners.delete(listener);
},
@@ -901,16 +914,22 @@ export function createAnnotationEngine({
async tour() {
clear();
flyTo({ lon: -122.4486, lat: 37.7960, height: 520, heading: 0, pitch: -26, duration: 3 });
if (destroyed) return { ok: false };
await wait(3200);
await annotate([{ type: 'highlight', target: 'Palace of Fine Arts, San Francisco', label: 'Palace of Fine Arts', color: 'amber' }], { persist: true });
if (destroyed) return { ok: false };
await wait(2600);
await annotate([{ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina Green, San Francisco', label: 'next to the Marina', color: 'cyan' }], { persist: true });
if (destroyed) return { ok: false };
await wait(2600);
flyTo({ lon: -122.4545, lat: 37.7880, height: 1500, heading: 18, pitch: -32, duration: 3 });
if (destroyed) return { ok: false };
await wait(3200);
await annotate([{ type: 'area', target: 'Presidio of San Francisco', label: 'The Presidio — a former Army base', color: 'green', footprint: true }], { persist: true });
if (destroyed) return { ok: false };
await wait(2800);
await annotate([{ type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', label: 'ILM / Lucasfilm', color: 'red' }], { persist: true });
if (destroyed) return { ok: false };
await wait(2600);
await annotate([{ type: 'route', color: 'amber', label: 'Crissy Field shoreline', points: [
{ target: 'Palace of Fine Arts, San Francisco' },
@@ -922,6 +941,7 @@ export function createAnnotationEngine({
};
function flyTo({ lon, lat, height, heading = 0, pitch = -30, duration = 2.5 }) {
if (destroyed) return;
try {
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(lon, lat, height),
+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(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);
});
+119
View File
@@ -0,0 +1,119 @@
const START_ORDER = ['scene', 'controls', 'data', 'tools'];
// Controls cancel restoration while the data manager and viewer still exist.
const STOP_ORDER = ['tools', 'controls', 'data', 'scene'];
/**
* Own application startup and teardown without importing an application entry.
* Constructors receive earlier components, an AbortSignal and defer(cleanup).
* Register cleanup immediately after acquiring each resource, before any await.
* @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function}} constructors
* @returns {{start:Function,destroy:Function,subscribe:Function,getState:Function,getComponents:Function}}
*/
export function createApplication({ createScene, createControls, createData, createTools }) {
const factories = { scene: createScene, controls: createControls, data: createData, tools: createTools };
for (const phase of START_ORDER) {
if (typeof factories[phase] !== 'function') throw new TypeError(`Missing ${phase} constructor`);
}
const controller = new AbortController();
const cleanups = Object.fromEntries(START_ORDER.map((phase) => [phase, []]));
const components = {};
const listeners = new Set();
let state = Object.freeze({ status: 'created', phase: null });
let startPromise;
let destroyPromise;
let cleanupPromise;
function publish(status, phase = null) {
state = Object.freeze({ status, phase });
for (const listener of [...listeners]) {
try { listener(state); } catch { console.error('Application state listener failed'); }
}
}
function cleanup() {
cleanupPromise ||= Promise.resolve().then(async () => {
const errors = [];
for (const phase of STOP_ORDER) {
while (cleanups[phase].length) {
try { await cleanups[phase].pop()(); } catch (error) { errors.push(error); }
}
delete components[phase];
}
if (errors.length) throw new AggregateError(errors, 'Application cleanup failed');
});
return cleanupPromise;
}
async function initialize() {
try {
for (const phase of START_ORDER) {
controller.signal.throwIfAborted();
publish('starting', phase);
controller.signal.throwIfAborted();
let acceptingCleanup = true;
try {
components[phase] = await factories[phase]({
...components,
signal: controller.signal,
defer(dispose) {
if (!acceptingCleanup || typeof dispose !== 'function') {
throw new TypeError('Register cleanup during component construction');
}
cleanups[phase].push(dispose);
},
});
} finally {
acceptingCleanup = false;
}
controller.signal.throwIfAborted();
}
publish('ready');
controller.signal.throwIfAborted();
return Object.freeze({ ...components });
} catch (error) {
controller.abort();
let failure = error;
try { await cleanup(); } catch (cleanupError) {
failure = new AggregateError([error, cleanupError], 'Application startup and cleanup failed');
}
if (!destroyPromise) publish('failed');
throw failure;
}
}
return Object.freeze({
start() {
if (destroyPromise) return Promise.reject(new Error('Application has been destroyed'));
// Assign before notifying subscribers, including reentrant start/destroy.
startPromise ||= Promise.resolve().then(initialize);
return startPromise;
},
destroy() {
if (destroyPromise) return destroyPromise;
destroyPromise = Promise.resolve().then(async () => {
try {
await startPromise?.catch(() => {});
await cleanup();
publish('destroyed');
} catch (error) {
publish('failed');
throw error;
} finally {
listeners.clear();
}
});
controller.abort();
publish('destroying');
return destroyPromise;
},
getState: () => state,
getComponents: () => Object.freeze({ ...components }),
subscribe(listener) {
if (typeof listener !== 'function') throw new TypeError('Expected a state listener');
if (state.status === 'destroyed') return () => {};
listeners.add(listener);
try { listener(state); } catch { console.error('Application state listener failed'); }
return () => listeners.delete(listener);
},
});
}
+169
View File
@@ -0,0 +1,169 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createApplication } from './application.js';
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
function fixture(overrides = {}) {
const events = [];
const constructors = Object.fromEntries(phases.map((phase) => [
`create${phase}`,
({ defer }) => {
events.push(`start:${phase}`);
defer(() => events.push(`stop:${phase}`));
return { name: phase };
},
]));
return { events, app: createApplication({ ...constructors, ...overrides }) };
}
function deferred() {
let resolve;
const promise = new Promise((done) => { resolve = done; });
return { promise, resolve };
}
test('importing and constructing the package is inactive without browser globals', () => {
const result = spawnSync(process.execPath, ['--input-type=module', '-e', `
globalThis.fetch = () => { throw new Error('unexpected request'); };
globalThis.setTimeout = () => { throw new Error('unexpected timer'); };
const { createApplication } = await import('gods-eye-view/application');
const fail = () => { throw new Error('unexpected construction'); };
const app = createApplication({ createScene: fail, createControls: fail, createData: fail, createTools: fail });
if (app.getState().status !== 'created') process.exit(1);
`], { cwd: new URL('../../', import.meta.url), encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
});
test('constructors are validated before allocating anything', () => {
assert.throws(() => createApplication({}), /Missing scene constructor/);
});
test('startup shares one promise and teardown keeps data alive for controls', async () => {
const { app, events } = fixture({ createTools({ scene, controls, data, defer }) {
assert.equal(scene.name, 'Scene');
assert.equal(controls.name, 'Controls');
assert.equal(data.name, 'Data');
defer(() => events.push('stop:Tools'));
return { name: 'Tools' };
} });
const states = [];
app.subscribe((state) => states.push(`${state.status}:${state.phase}`));
const started = app.start();
assert.equal(started, app.start());
const components = await started;
assert.ok(Object.isFrozen(components));
assert.equal(components.tools.name, 'Tools');
assert.equal(app.getState().status, 'ready');
const stopped = app.destroy();
assert.equal(stopped, app.destroy());
await stopped;
assert.deepEqual(events.slice(-4), ['stop:Tools', 'stop:Controls', 'stop:Data', 'stop:Scene']);
assert.deepEqual(app.getComponents(), {});
assert.equal(states.at(-1), 'destroyed:null');
await assert.rejects(app.start(), /destroyed/);
});
for (const phase of phases) {
test(`failure during ${phase} releases partially acquired resources`, async () => {
const { app, events } = fixture({ [`create${phase}`]({ defer }) {
defer(() => events.push('partial:released'));
throw new Error('broken constructor');
} });
await assert.rejects(app.start(), /broken constructor/);
assert.equal(events.filter((value) => value === 'partial:released').length, 1);
assert.equal(app.getState().status, 'failed');
assert.deepEqual(app.getComponents(), {});
await app.destroy();
assert.equal(events.filter((value) => value === 'partial:released').length, 1);
});
}
test('destroy before startup prevents every constructor', async () => {
const { app, events } = fixture();
const started = app.start();
const failed = assert.rejects(started, { name: 'AbortError' });
await app.destroy();
await failed;
assert.deepEqual(events, []);
});
test('destroy aborts an in-flight constructor and cleans late acquisitions before resolving', async () => {
const gate = deferred();
const entered = deferred();
let observedSignal;
const { app, events } = fixture({ async createScene({ signal, defer }) {
observedSignal = signal;
entered.resolve();
await gate.promise;
defer(() => events.push('late:released'));
return {};
} });
const started = app.start();
const failed = assert.rejects(started, { name: 'AbortError' });
await entered.promise;
let settled = false;
const stopped = app.destroy().then(() => { settled = true; });
assert.equal(observedSignal.aborted, true);
await Promise.resolve();
assert.equal(settled, false);
gate.resolve();
await stopped;
await failed;
assert.deepEqual(events, ['late:released']);
});
test('cleanup errors are reported after all remaining resources are attempted', async () => {
const { app, events } = fixture({ createTools({ defer }) {
defer(() => events.push('tools:earlier'));
defer(() => { throw new Error('dispose failed'); });
return {};
} });
await app.start();
await assert.rejects(app.destroy(), (error) => {
assert.ok(error instanceof AggregateError);
assert.equal(error.errors[0].message, 'dispose failed');
return true;
});
assert.deepEqual(events.slice(-4), ['tools:earlier', 'stop:Controls', 'stop:Data', 'stop:Scene']);
assert.equal(app.getState().status, 'failed');
});
test('subscribers can destroy during startup without allowing the next constructor', async () => {
const { app, events } = fixture();
app.subscribe((state) => { if (state.phase === 'controls') void app.destroy(); });
await assert.rejects(app.start(), { name: 'AbortError' });
await app.destroy();
assert.deepEqual(events, ['start:Scene', 'stop:Scene']);
});
test('destroy during ready notification rejects startup and tears everything down', async () => {
const { app } = fixture();
app.subscribe((state) => { if (state.status === 'ready') void app.destroy(); });
await assert.rejects(app.start(), { name: 'AbortError' });
await app.destroy();
assert.equal(app.getState().status, 'destroyed');
});
test('cleanup registration closes when a constructor settles', async () => {
let register;
const { app } = fixture({ createScene({ defer }) { register = defer; return {}; } });
await app.start();
assert.throws(() => register(() => {}), /during component construction/);
await app.destroy();
});
test('different application instances do not share lifecycle state', async () => {
const first = fixture();
const second = fixture();
await Promise.all([first.app.start(), second.app.start()]);
await first.app.destroy();
assert.equal(second.app.getState().status, 'ready');
assert.equal(second.events.length, 4);
await second.app.destroy();
});
test('the separate viewer export imports without constructing a browser viewer', async () => {
const { createApplicationViewer } = await import('gods-eye-view/application/viewer');
assert.equal(typeof createApplicationViewer, 'function');
assert.throws(() => createApplicationViewer({}), /containers are required/);
});
+40
View File
@@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { MapStackController } from '../mapStackController.js';
import {
installRenderGovernor, uninstallRenderGovernor,
holdContinuousRender, governorRequestRender, getRenderGovernorDiagnostics,
} from '../renderGovernor.js';
test('destroy invalidates a pending imagery provider before it can touch the viewer', async () => {
let resolveProvider;
let removed = 0;
const changes = [];
const controller = new MapStackController({}, { onChange: (state) => changes.push(state.status) });
controller._getImageryProvider = () => new Promise((resolve) => { resolveProvider = resolve; });
controller._removeImageryErrorListener = () => { removed++; };
const switching = controller.setStack('osm');
controller.destroy();
resolveProvider({ provider: {} });
await switching;
assert.equal(removed, 1);
assert.deepEqual(changes, ['switching']);
await controller.setStack('osm');
controller.destroy();
assert.equal(removed, 1);
});
test('uninstall only releases the current viewer and makes later render requests inactive', () => {
let renders = 0;
const viewer = { scene: { requestRender: () => { renders++; } } };
installRenderGovernor(viewer);
holdContinuousRender('fixture');
uninstallRenderGovernor({});
assert.equal(getRenderGovernorDiagnostics().installed, true);
uninstallRenderGovernor(viewer);
const before = renders;
governorRequestRender('late');
assert.equal(renders, before);
assert.equal(getRenderGovernorDiagnostics().installed, false);
assert.deepEqual(getRenderGovernorDiagnostics().holds, []);
});
+17
View File
@@ -0,0 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { flyToAustin } from '../camera.js';
test('teardown before the initial camera delay prevents a late flight', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let flights = 0;
let cancelled = 0;
const stop = flyToAustin({
isDestroyed: () => false,
camera: { setView() {}, flyTo() { flights++; }, cancelFlight() { cancelled++; } },
});
stop();
t.mock.timers.tick(1000);
assert.equal(flights, 0);
assert.equal(cancelled, 1);
});
+35
View File
@@ -0,0 +1,35 @@
import * as Cesium from 'cesium';
/** Create the standard globe viewer in caller-owned, visible containers. */
export function createApplicationViewer({ container, creditContainer }) {
if (!container || !creditContainer) throw new TypeError('Viewer and credit containers are required');
const viewer = new Cesium.Viewer(container, {
timeline: false,
animation: false,
baseLayerPicker: false,
geocoder: false,
homeButton: false,
sceneModePicker: false,
navigationHelpButton: false,
fullscreenButton: false,
vrButton: false,
selectionIndicator: false,
infoBox: false,
baseLayer: false,
creditContainer,
msaaSamples: 4,
contextOptions: { webgl: { preserveDrawingBuffer: true } },
});
try {
viewer.targetFrameRate = 60;
viewer.scene.globe.show = false;
viewer.scene.skyAtmosphere.show = true;
viewer.scene.skyAtmosphere.atmosphereLightIntensity = 18;
viewer.scene.skyAtmosphere.saturationShift = -0.12;
viewer.scene.skyAtmosphere.brightnessShift = -0.08;
return viewer;
} catch (error) {
viewer.destroy();
throw error;
}
}
+7 -1
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.
* @returns {Function} Cancels the pending or active startup flight.
*/
export function flyToAustin(viewer) {
// Start from a high altitude, then fly down
@@ -61,7 +62,8 @@ export function flyToAustin(viewer) {
});
// Cinematic fly-in after a brief pause
setTimeout(() => {
const timer = setTimeout(() => {
if (viewer.isDestroyed()) return;
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 600),
orientation: {
@@ -73,4 +75,8 @@ export function flyToAustin(viewer) {
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
});
}, 500);
return () => {
clearTimeout(timer);
if (!viewer.isDestroyed()) viewer.camera.cancelFlight();
};
}
+1 -1
View File
@@ -2318,7 +2318,7 @@ const trafficLayer = {
viewer.camera.percentageChanged = _prevPercentageChanged;
_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.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']) {
await fs.copyFile(new URL(`../scripts/${name}`, import.meta.url), path.join(root, 'scripts', name));
}
await fs.copyFile(new URL('./main.js', import.meta.url), path.join(root, 'src', 'main.js'));
await fs.mkdir(path.join(root, 'src', 'editions', 'local'), { recursive: true });
await fs.copyFile(new URL('./editions/local/data.js', import.meta.url), path.join(root, 'src', 'editions', 'local', 'data.js'));
await fs.mkdir(path.join(root, 'node_modules'));
await fs.symlink(fileURLToPath(new URL('../node_modules/vite', import.meta.url)), path.join(root, 'node_modules', 'vite'), 'dir');
await fs.writeFile(path.join(root, '.env'), dotenv);
+22
View File
@@ -0,0 +1,22 @@
import { createApplication } from '../../app/application.js';
import { createLocalScene } from './scene.js';
import { createLocalControls } from './controls.js';
import { createLocalData } from './data.js';
import { createLocalTools } from './tools.js';
// The existing controls and layer catalog contain page-scoped state.
let constructed = false;
/** Compose the standalone application once per page. Reload to start again. */
export function createLocalApplication({ googleApiKey, cesiumToken, allowQaRegistration = false }) {
if (constructed) throw new Error('The standalone application already owns this page');
constructed = true;
const loadingScreen = document.getElementById('loading-screen');
const loaderStatus = loadingScreen.querySelector('.loader-status');
return createApplication({
createScene: (context) => createLocalScene({ ...context, googleApiKey, cesiumToken, loaderStatus }),
createControls: (context) => createLocalControls({ ...context, loaderStatus }),
createData: (context) => createLocalData({ ...context, allowQaRegistration }),
createTools: (context) => createLocalTools({ ...context, loadingScreen }),
});
}
+28
View File
@@ -0,0 +1,28 @@
import { StyleManager } from '../../ui.js';
import { flyToAustin } from '../../camera.js';
import { initCockpitCloudEffects } from '../../cockpitCloudEffects.js';
/** Construct the existing controls and camera presentation. */
export function createLocalControls({ scene: { viewer, mapStackController }, loaderStatus, defer }) {
// Initialize the style manager (post-processing, HUD, locations, share links)
const styleManager = new StyleManager(viewer, { mapStackController });
defer(() => styleManager.orbitController.stop());
defer(() => styleManager.hud.destroy());
defer(() => styleManager.dispose());
// The previous multi-canvas weather compositor remains disabled. Cockpit
// clouds use a separate, capped low-resolution GPU pass that never attaches
// Cesium fog or post-process stages and is fully stopped in map mode.
const weatherEffects = null;
const cockpitCloudEffects = initCockpitCloudEffects(viewer);
defer(() => cockpitCloudEffects?.destroy());
// If no share link state, do default fly-to Austin
if (!styleManager.hasShareState) {
loaderStatus.textContent = 'Flying to Austin, TX...';
defer(flyToAustin(viewer));
} else {
loaderStatus.textContent = 'Restoring shared view...';
}
return { styleManager, weatherEffects, cockpitCloudEffects };
}
+66
View File
@@ -0,0 +1,66 @@
import { DataLayerManager } from '../../data/manager.js';
import flightsLayer from '../../data/flights.js';
import militaryFlightsLayer from '../../data/militaryFlights.js';
import earthquakesLayer from '../../data/earthquakes.js';
import satellitesLayer from '../../data/satellites.js';
import rocketLaunchesLayer from '../../data/rocketLaunches.js';
import trafficLayer from '../../data/traffic.js';
import cctvLayer from '../../data/cctv.js';
import radioLayer from '../../data/radio.js';
import bikeshareLayer from '../../data/bikeshare.js';
import aisLiveVesselsLayer from '../../data/aisLiveVessels.js';
import militaryInstallationsLayer from '../../data/militaryInstallations.js';
import militaryAwarenessLayer from '../../data/militaryAwareness.js';
import localDataLayers from '../../data/localLayers.js';
import { LAYER_STATE_REGISTRY } from '../../data/layerState.js';
/** Register the standalone layer catalog before allowing state restoration. */
export function createLocalData({ scene: { viewer }, controls: { styleManager }, allowQaRegistration, defer }) {
// Initialize data layer manager
const dataManager = new DataLayerManager(viewer, {
allowQaRegistration,
});
defer(async () => {
await dataManager.destroyAll();
if (dataManager.layers.size) throw new Error(`Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`);
});
dataManager.register(flightsLayer);
dataManager.register(militaryFlightsLayer);
dataManager.register(earthquakesLayer);
dataManager.register(satellitesLayer);
dataManager.register(rocketLaunchesLayer);
rocketLaunchesLayer.attachDataManager(dataManager);
dataManager.register(trafficLayer);
dataManager.register(cctvLayer);
dataManager.register(radioLayer);
dataManager.register(bikeshareLayer);
dataManager.register(aisLiveVesselsLayer);
dataManager.register(militaryInstallationsLayer);
dataManager.register(militaryAwarenessLayer);
militaryAwarenessLayer.attachDataManager(dataManager);
for (const layer of localDataLayers) {
dataManager.register(layer);
}
// Restoration starts only after the complete production registry is sealed.
dataManager.finalizeRegistrations(LAYER_STATE_REGISTRY);
if (allowQaRegistration) {
window.__gevQaRegisterLayer = (targetManager, layerModule) => {
if (targetManager !== dataManager) throw new Error('QA layer manager mismatch');
return dataManager.registerForQa(layerModule);
};
window.__gevQaUnregisterLayer = (targetManager, layerId) => {
if (targetManager !== dataManager) throw new Error('QA layer manager mismatch');
return dataManager.unregisterForQa(layerId);
};
const register = window.__gevQaRegisterLayer;
const unregister = window.__gevQaUnregisterLayer;
defer(() => {
if (window.__gevQaRegisterLayer === register) delete window.__gevQaRegisterLayer;
if (window.__gevQaUnregisterLayer === unregister) delete window.__gevQaUnregisterLayer;
});
}
dataManager.buildTogglePanel(document.getElementById('data-toggles'));
styleManager.attachDataManager(dataManager);
return { dataManager };
}
+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);
}
+88
View File
@@ -0,0 +1,88 @@
import * as Cesium from 'cesium';
import { createApplicationViewer } from '../../app/viewer.js';
import { registerDataCredits } from '../../data/dataCredits.js';
import { configureCreditKeyboardAccess } from '../../creditKeyboard.js';
import { MapStackController } from '../../mapStackController.js';
import { loadPhotorealisticTileset } from '../../mapStartup.js';
import { initLogoGaze } from '../../logoGaze.js';
import { uninstallRenderGovernor } from '../../renderGovernor.js';
import { describeError } from './errors.js';
/** Construct the standalone globe using the caller's local configuration. */
export async function createLocalScene({ googleApiKey, cesiumToken, loaderStatus, signal, defer }) {
defer(initLogoGaze());
const previousKey = window.__GOOGLE_MAPS_API_KEY__;
if (googleApiKey) {
window.__GOOGLE_MAPS_API_KEY__ = googleApiKey;
defer(() => {
if (window.__GOOGLE_MAPS_API_KEY__ !== googleApiKey) return;
if (previousKey === undefined) delete window.__GOOGLE_MAPS_API_KEY__;
else window.__GOOGLE_MAPS_API_KEY__ = previousKey;
});
}
loaderStatus.textContent = 'Configuring viewer...';
// Provider attribution stays visible, including clean-view and recording.
const creditContainer = document.createElement('div');
creditContainer.id = 'cesium-credits';
document.body.appendChild(creditContainer);
defer(() => creditContainer.remove());
const viewer = createApplicationViewer({ container: 'cesiumContainer', creditContainer });
defer(() => {
uninstallRenderGovernor(viewer);
if (!viewer.isDestroyed()) viewer.destroy();
});
registerDataCredits(viewer);
configureCreditKeyboardAccess(document);
loaderStatus.textContent = googleApiKey || cesiumToken
? 'Loading Google 3D Tiles...'
: 'Loading the keyless globe...';
const photoreal = await loadPhotorealisticTileset(Cesium, {
googleApiKey,
cesiumToken,
});
const tileset = photoreal.tileset;
// A provider can finish after cancellation; retain ownership of its result.
defer(() => {
if (tileset && !tileset.isDestroyed()) {
if (!viewer.scene.primitives.remove(tileset)) tileset.destroy();
}
});
signal.throwIfAborted();
if (tileset) {
viewer.scene.primitives.add(tileset);
// NOTE: Cesium World Terrain intentionally disabled — conflicts with Google 3D Tiles at high zoom.
// Google Photorealistic 3D Tiles provide their own terrain/elevation.
viewer.scene.globe.show = false;
console.info(`[Init] Google 3D Tiles loaded via ${photoreal.route}.`);
} else {
if (photoreal.errors.length) {
const tileError = photoreal.errors.at(-1);
console.warn('[Init] Google 3D Tiles unavailable, using the keyless globe:', tileError);
const tileErrorDetail = describeError(tileError);
loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`;
}
viewer.scene.globe.show = true;
}
loaderStatus.textContent = 'Initializing systems...';
const mapStackController = new MapStackController(viewer, {
googleTileset: tileset,
cesiumToken,
initialStack: tileset ? 'photoreal' : 'esri-imagery',
// Task 5 (height-datum fix): rebroadcast stack changes as a window
// CustomEvent so data layers (CCTV per-regime ground resolution) can
// react without coupling MapStackController to layer modules. Fires on
// 'switching'/'ready'/'error'; listeners derive the surface regime from
// live scene state, so intermediate emissions are harmless.
onChange: (state) => {
window.dispatchEvent(new CustomEvent('gev:map-stack-changed', { detail: state }));
},
onError: (message) => console.warn('[MapStack]', message),
});
defer(() => mapStackController.destroy());
await mapStackController.setStack(tileset ? 'photoreal' : 'esri-imagery', { silent: true });
signal.throwIfAborted();
return { viewer, tileset, mapStackController };
}
+38
View File
@@ -0,0 +1,38 @@
import { initFirstRunExperience } from '../../firstRunExperience.js';
import { initKeySetup } from '../../keySetup.js';
/** Reveal welcome controls only after restoration and the loading transition. */
export function startLocalChrome({ loadingScreen, styleManager, dataManager, signal }) {
let disposed = false;
let firstRun;
let revealTimer;
let resolveDelay;
const minimumDelay = new Promise((resolve) => { resolveDelay = resolve; });
const delayTimer = setTimeout(resolveDelay, 1000);
const revealFirstRun = () => {
if (disposed || signal.aborted || firstRun) return;
firstRun = initFirstRunExperience({ styleManager, dataManager });
clearTimeout(revealTimer);
loadingScreen.removeEventListener('transitionend', revealFirstRun);
};
void Promise.all([styleManager.initialRestorePromise, minimumDelay])
.catch(() => { /* Restoration reports its own outcome through the controls. */ })
.then(() => {
if (disposed || signal.aborted) return;
loadingScreen.classList.add('hidden');
loadingScreen.addEventListener('transitionend', revealFirstRun, { once: true });
revealTimer = setTimeout(revealFirstRun, 900);
});
const keySetup = initKeySetup({ signal });
// Own the pending initializer too; it must not reveal a dialog after abort.
void keySetup.catch(() => console.error('Provider settings initialization failed'));
return async () => {
disposed = true;
clearTimeout(delayTimer);
clearTimeout(revealTimer);
resolveDelay();
loadingScreen.removeEventListener('transitionend', revealFirstRun);
firstRun?.destroy();
(await keySetup.catch(() => null))?.destroy();
};
}
+101
View File
@@ -0,0 +1,101 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const source = readFileSync(new URL('./startupChrome.js', import.meta.url), 'utf8')
.replace(/^import .*;\n/gm, '')
.replace('export function', 'function');
function fixture() {
const timers = new Map();
const listeners = new Map();
const events = [];
let restored;
let nextTimer = 0;
const controller = new AbortController();
const context = {
console,
setTimeout(fn, delay) { const id = ++nextTimer; timers.set(id, { fn, delay }); return id; },
clearTimeout(id) { timers.delete(id); },
initFirstRunExperience() {
events.push('welcome');
return { destroy: () => events.push('welcome:destroy') };
},
async initKeySetup() { return { destroy: () => events.push('settings:destroy') }; },
};
vm.createContext(context);
vm.runInContext(source, context);
const stop = context.startLocalChrome({
loadingScreen: {
classList: { add: (value) => events.push(value) },
addEventListener: (type, listener) => listeners.set(type, listener),
removeEventListener: (type) => listeners.delete(type),
},
styleManager: { initialRestorePromise: new Promise((resolve) => { restored = resolve; }) },
dataManager: {},
signal: controller.signal,
});
return {
events, listeners, stop, restored, controller,
fire(delay) {
for (const [id, task] of timers) {
if (task.delay === delay) { timers.delete(id); task.fn(); }
}
},
timers,
};
}
async function flush() { for (let i = 0; i < 12; i++) await Promise.resolve(); }
test('welcome waits for restoration, minimum delay, and the cover transition', async () => {
const f = fixture();
f.fire(1000);
await flush();
assert.deepEqual(f.events, []);
f.restored();
await flush();
assert.deepEqual(f.events, ['hidden']);
f.listeners.get('transitionend')();
f.fire(900);
assert.deepEqual(f.events, ['hidden', 'welcome']);
await f.stop();
assert.deepEqual(f.events.slice(-2), ['welcome:destroy', 'settings:destroy']);
assert.equal(f.timers.size, 0);
});
test('reduced motion uses the bounded fallback after the cover hides', async () => {
const f = fixture();
f.restored();
f.fire(1000);
await flush();
f.fire(900);
assert.deepEqual(f.events, ['hidden', 'welcome']);
await f.stop();
});
test('shutdown while restore is pending never reveals late welcome UI', async () => {
const f = fixture();
f.controller.abort();
await f.stop();
f.restored();
await flush();
f.fire(1000);
f.fire(900);
assert.deepEqual(f.events, ['settings:destroy']);
assert.equal(f.listeners.size, 0);
assert.equal(f.timers.size, 0);
});
test('shutdown during the cover transition cancels the listener and fallback', async () => {
const f = fixture();
f.restored();
f.fire(1000);
await flush();
const transition = f.listeners.get('transitionend');
f.controller.abort();
await f.stop();
transition();
f.fire(900);
assert.deepEqual(f.events, ['hidden', 'settings:destroy']);
});
+88
View File
@@ -0,0 +1,88 @@
import { SceneDirector } from '../../scenes/director.js';
import { initAnnotations } from '../../annotations/index.js';
import { initGevVoiceCommands } from '../../voice/gevRealtime.js';
import { installScopeMask, destroyScopeMask } from '../../scopeMask.js';
import {
installRenderGovernor, getRenderGovernorDiagnostics, governorRequestRender,
holdContinuousRender, releaseContinuousRender,
} from '../../renderGovernor.js';
import { startLocalChrome } from './startupChrome.js';
/** Attach scene tools, rendering listeners and the standalone debug handle. */
export function createLocalTools({ scene, controls, data, loadingScreen, signal, defer }) {
const { viewer, tileset, mapStackController } = scene;
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
const { dataManager } = data;
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager);
defer(() => sceneDirector.destroy());
const annotations = initAnnotations({ viewer, tileset });
defer(() => {
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
annotations.destroy();
});
defer(startLocalChrome({ loadingScreen, styleManager, dataManager, signal }));
// Idle render governor: flips the scene into requestRenderMode whenever
// nothing animates per frame. Installed AFTER every module above has had
// its chance to register pre-install holds. (perf wave 2)
installRenderGovernor(viewer);
// Install the explicit scope mask used by the DISPLAY controls.
installScopeMask(viewer);
defer(() => destroyScopeMask());
// The follow camera recomputes the tracked target's dead-reckon position
// every frame — tracking anything is a per-frame animation. (perf wave 2)
const removeTrackingListener = viewer.trackedEntityChanged.addEventListener(() => {
if (viewer.trackedEntity) holdContinuousRender('tracked-entity');
else releaseContinuousRender('tracked-entity');
});
// Hidden-state suspension (perf wave 2): when the window/tab is hidden,
// stop the default render loop outright — a hidden canvas repaints for
// nobody, and browser rAF throttling still lets throttled frames burn
// GPU. Holder/data state is untouched, so return is seamless: restore
// the loop, refresh the one DOM surface we gated, render a frame.
const syncVisibilitySuspension = () => {
const hidden = document.hidden;
viewer.useDefaultRenderLoop = !hidden;
cockpitCloudEffects?.setSuspended?.(hidden);
if (!hidden) {
if (dataManager._panelRefreshPendingOnVisible) {
dataManager._panelRefreshPendingOnVisible = false;
dataManager._refreshTogglePanel();
}
governorRequestRender('visibility-restore');
}
};
document.addEventListener('visibilitychange', syncVisibilitySuspension);
defer(() => document.removeEventListener('visibilitychange', syncVisibilitySuspension));
defer(() => { removeTrackingListener(); releaseContinuousRender('tracked-entity'); });
// Apply the CURRENT state too — bootstrap can complete while the tab is
// already hidden, and waiting for the next transition would leave the
// loop burning behind a hidden tab. (perf wave 2 fix)
syncVisibilitySuspension();
window.__godsEyeView = {
viewer,
styleManager,
tileset,
dataManager,
sceneDirector,
mapStackController,
annotations,
weatherEffects,
cockpitCloudEffects,
getRenderGovernorDiagnostics,
requestRender: governorRequestRender,
};
const debug = window.__godsEyeView;
defer(() => { if (window.__godsEyeView === debug) delete window.__godsEyeView; });
const voiceCommands = initGevVoiceCommands({ viewer, styleManager, dataManager, sceneDirector, annotations });
defer(() => {
voiceCommands.stop({ removeUi: true });
if (window.__gevVoiceCommands === voiceCommands) delete window.__gevVoiceCommands;
});
debug.voiceCommands = voiceCommands;
return { sceneDirector, annotations, voiceCommands };
}
+9 -1
View File
@@ -643,5 +643,13 @@ export function initFirstRunExperience({
}
syncToExclusiveSurfaces();
return { dismiss, isTopmost };
// Teardown is not a user dismissal and must not change the show preference.
const destroy = () => {
closing = true;
documentRef.removeEventListener('keydown', onKeyDown, true);
globalThis.removeEventListener?.('resize', onViewportResize);
surfaceObserver?.disconnect();
root.remove();
};
return { dismiss, isTopmost, destroy };
}
+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', () => {
const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const main = fs.readFileSync(new URL('./main.js', import.meta.url), 'utf8');
const startup = fs.readFileSync(new URL('./editions/local/startupChrome.js', import.meta.url), 'utf8');
const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8');
assert.match(html, /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/);
@@ -577,9 +577,8 @@ test('markup, startup ordering and accessibility remain pinned', () => {
assert.doesNotMatch(html, /data-first-run-choice="infrastructure"/,
'the removed tile must leave no markup behind');
const startup = main.slice(main.indexOf('void Promise.all(['), main.indexOf('// Expose for debugging'));
assert.match(startup, /styleManager\.initialRestorePromise/);
assert.ok(startup.indexOf("loadingScreen.classList.add('hidden')") < startup.indexOf('initFirstRunExperience'));
assert.ok(startup.indexOf("loadingScreen.classList.add('hidden')") < startup.indexOf("loadingScreen.addEventListener('transitionend', revealFirstRun"));
assert.match(startup, /initFirstRunExperience\(\{ styleManager, dataManager \}\)/);
assert.match(css, /body\.ui-clean-view #first-run-launcher/);
+34 -10
View File
@@ -142,23 +142,37 @@ function buildRow(documentRef, key) {
* Wire the chip + dialog. Fire-and-forget from main.js; resolves to null when
* the surface has no business existing (prod build, LAN visitor, no markup).
*/
export async function initKeySetup({ documentRef = globalThis.document, fetchImpl } = {}) {
export async function initKeySetup({ documentRef = globalThis.document, fetchImpl, signal } = {}) {
const chip = documentRef?.getElementById?.('key-setup-chip');
const root = documentRef?.getElementById?.('key-setup');
if (!chip || !root || root.dataset.initialized === 'true') return null;
root.dataset.initialized = 'true';
const lifetime = new AbortController();
let disposed = false;
let disposeControls = () => {};
const destroy = () => {
if (disposed) return;
disposed = true;
lifetime.abort();
signal?.removeEventListener('abort', destroy);
disposeControls();
chip.remove();
root.remove();
};
if (signal?.aborted) { destroy(); return null; }
signal?.addEventListener('abort', destroy, { once: true });
const doFetch = fetchImpl || globalThis.fetch?.bind(globalThis);
let status = null;
try {
const response = await doFetch('/api/setup/status', { cache: 'no-store' });
const response = await doFetch('/api/setup/status', { cache: 'no-store', signal: lifetime.signal });
if (!response.ok) throw new Error(String(response.status));
status = await response.json();
if (disposed) return null;
} catch {
// Prod build or non-loopback visitor: the surface cannot function, so it
// does not exist. (The README covers .env for headless/self-host setups.)
chip.remove();
root.remove();
destroy();
return null;
}
@@ -173,6 +187,7 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
let previouslyFocused = null;
const render = (nextStatus) => {
if (disposed) return;
status = nextStatus;
chipLabel.textContent = keySetupChipLabel(status);
// Fully powered is the owner's clean screen: the chip retires. The dialog
@@ -223,7 +238,7 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
};
const openDialog = () => {
if (open) return;
if (disposed || open) return;
open = true;
previouslyFocused = documentRef.activeElement;
root.hidden = false;
@@ -256,7 +271,7 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
: 'your local .env');
const submitUpdates = async (updates, doneVerb) => {
if (busy) return;
if (disposed || busy) return;
const googleWasUnset = !status?.keys?.find((key) => key.id === 'google-maps')?.set;
busy = true;
applyButton?.setAttribute('aria-disabled', 'true');
@@ -264,10 +279,12 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
try {
const response = await doFetch('/api/setup/keys', {
method: 'POST',
signal: lifetime.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
const payload = await response.json().catch(() => ({}));
if (disposed) return;
if (!response.ok || !payload.ok) {
say(payload.error || `Save failed (${response.status}).`);
return;
@@ -286,7 +303,7 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
strip();
// The live share writer may re-serialize the still-OSM stack before
// the restart's reload lands, so strip again at the door.
globalThis.addEventListener?.('pagehide', strip, { once: true });
globalThis.addEventListener?.('pagehide', strip, { once: true, signal: lifetime.signal });
}
say(`${doneVerb} ${storeLabel()}. Restarting — this page reloads itself.`);
} catch (error) {
@@ -298,7 +315,7 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
};
const onApply = async () => {
if (busy) return;
if (disposed || busy) return;
const inputs = [...root.querySelectorAll('input[data-env-var]')];
const updates = collectKeyUpdates(
inputs.map((input) => ({ envVar: input.dataset.envVar, value: input.value })),
@@ -316,7 +333,7 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
// Remove buttons are rendered per row; delegate so re-renders stay wired.
rowsHost?.addEventListener('click', (event) => {
const button = event.target?.closest?.('[data-key-setup-remove]');
if (!button || busy) return;
if (disposed || !button || busy) return;
let envVars = [];
try {
envVars = JSON.parse(button.dataset.keySetupRemove || '[]');
@@ -346,5 +363,12 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
// An unparsable location never blocks init.
}
return { open: openDialog, close, render };
disposeControls = () => {
open = false;
documentRef.removeEventListener('keydown', onKeyDown, true);
chip.removeEventListener('click', openDialog);
closeButton?.removeEventListener('click', close);
applyButton?.removeEventListener('click', onApply);
};
return { open: openDialog, close, render, destroy };
}
+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(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 { StyleManager } from './ui.js';
import { flyToAustin } from './camera.js';
import { DataLayerManager } from './data/manager.js';
import flightsLayer from './data/flights.js';
import militaryFlightsLayer from './data/militaryFlights.js';
import earthquakesLayer from './data/earthquakes.js';
import satellitesLayer from './data/satellites.js';
import rocketLaunchesLayer from './data/rocketLaunches.js';
import trafficLayer from './data/traffic.js';
import cctvLayer from './data/cctv.js';
import radioLayer from './data/radio.js';
import bikeshareLayer from './data/bikeshare.js';
import aisLiveVesselsLayer from './data/aisLiveVessels.js';
import militaryInstallationsLayer from './data/militaryInstallations.js';
import militaryAwarenessLayer from './data/militaryAwareness.js';
import localDataLayers from './data/localLayers.js';
import { LAYER_STATE_REGISTRY } from './data/layerState.js';
import { registerDataCredits } from './data/dataCredits.js';
import { configureCreditKeyboardAccess } from './creditKeyboard.js';
import { SceneDirector } from './scenes/director.js';
import { initGevVoiceCommands } from './voice/gevRealtime.js';
import { MapStackController } from './mapStackController.js';
import { initAnnotations } from './annotations/index.js';
import { initLogoGaze } from './logoGaze.js';
import { initCockpitCloudEffects } from './cockpitCloudEffects.js';
import {
installRenderGovernor,
getRenderGovernorDiagnostics,
governorRequestRender,
holdContinuousRender,
releaseContinuousRender,
} from './renderGovernor.js';
import { installScopeMask } from './scopeMask.js';
import { initFirstRunExperience } from './firstRunExperience.js';
import { initKeySetup } from './keySetup.js';
import { loadPhotorealisticTileset } from './mapStartup.js';
import { createLocalApplication } from './editions/local/application.js';
import { describeError } from './editions/local/errors.js';
initLogoGaze();
const application = createLocalApplication({
googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY,
cesiumToken: import.meta.env.CESIUM_ION_TOKEN,
allowQaRegistration: import.meta.env.DEV,
});
/**
* Extract a human-readable error message from any thrown value.
* Handles Error objects, strings, and plain objects with message/error fields.
* @param {*} error — caught exception value
* @returns {string} best-effort error description
*/
function describeError(error) {
if (!error) return 'Unknown initialization error';
if (error instanceof Error) {
if (error.message && error.message.trim()) return error.message.trim();
return error.name || 'Initialization error';
}
if (typeof error === 'string' && error.trim()) return error.trim();
if (typeof error === 'object') {
const maybeMessage = String(error.message || error.error || '').trim();
if (maybeMessage) return maybeMessage;
try {
const serialized = JSON.stringify(error);
if (serialized && serialized !== '{}') return serialized;
} catch {
// ignore serialization error
}
}
return String(error);
}
application.start().catch((error) => {
console.error("God's Eye View initialization failed:", error);
const loaderStatus = document.querySelector('#loading-screen .loader-status');
loaderStatus.textContent = `Error: ${describeError(error)}`;
loaderStatus.style.color = '#ff4444';
});
/**
* GOD'S EYE VIEW — Main Entry Point
* Initializes CesiumJS with Google Photorealistic 3D Tiles,
* style system, intelligence HUD, location presets, and share links.
*/
async function init() {
const loadingScreen = document.getElementById('loading-screen');
const loaderStatus = loadingScreen.querySelector('.loader-status');
try {
loaderStatus.textContent = 'Configuring viewer...';
// A direct Google key provides Google 3D plus GEV place search. Cesium ion
// can host the same 3D tiles and also powers Bing/world-terrain stacks.
const cesiumToken = import.meta.env.CESIUM_ION_TOKEN;
const googleApiKey = import.meta.env.GOOGLE_MAPS_API_KEY;
if (googleApiKey) window.__GOOGLE_MAPS_API_KEY__ = googleApiKey;
// Create the Cesium viewer with minimal chrome
const viewer = new Cesium.Viewer('cesiumContainer', {
timeline: false,
animation: false,
baseLayerPicker: false,
geocoder: false,
homeButton: false,
sceneModePicker: false,
navigationHelpButton: false,
fullscreenButton: false,
vrButton: false,
selectionIndicator: false,
infoBox: false,
baseLayer: false,
// Visible attribution container — Google Maps / 3D Tiles credits are
// required by Google's Terms of Service, so they must be shown (styled
// subtly via #cesium-credits). The credit line stays visible in
// clean-view AND recording modes too (ToS requires attribution while the
// content is displayed — those are the exact modes used to record
// demos), including the "Data attribution" link that opens the per-layer
// license popover.
creditContainer: (() => {
const el = document.createElement('div');
el.id = 'cesium-credits';
document.body.appendChild(el);
return el;
})(),
msaaSamples: 4,
contextOptions: {
webgl: {
preserveDrawingBuffer: true,
},
},
});
// Cap the default render loop at 60 fps. Cesium's loop otherwise runs at
// the display's refresh rate — 120 Hz on ProMotion panels — doubling GPU
// and CPU burn for zero visual benefit in a map app whose animation
// cadences (poll interpolation, trail fades, style crossfades) are all
// designed against wall-clock time, not frame count. Measured on the
// 2026-08-05 perf investigation as a strict halving of idle burn on
// 120 Hz hardware; a no-op on 60 Hz displays. (perf item 2)
viewer.targetFrameRate = 60;
// Register per-layer data attribution into the "Data attribution" popover.
// Required by each source's license (ODbL, CC BY-NC-SA, NASA FIRMS, etc.);
// strings are verbatim from DATA_SOURCES.md. Static + always-present in the
// expandable bottom-left credit lightbox (showOnScreen=false), so they never
// clutter the on-globe line. See docs/pre-ship-audit-2026-07-01.md H11.
registerDataCredits(viewer);
configureCreditKeyboardAccess(document);
// Hide Cesium's default globe — Google Photorealistic 3D Tiles provide their own
// globe at all LODs (street level → orbital). The default globe's 2D imagery
// clips through 3D tile buildings at close range.
viewer.scene.globe.show = false;
// Keep a sky behind Google 3D Tiles, but soften Cesium's high-intensity
// default atmosphere. With the globe hidden its bright limb otherwise
// reads as a hard cyan seam where distant photoreal tiles meet the sky.
viewer.scene.skyAtmosphere.show = true;
viewer.scene.skyAtmosphere.atmosphereLightIntensity = 18;
viewer.scene.skyAtmosphere.saturationShift = -0.12;
viewer.scene.skyAtmosphere.brightnessShift = -0.08;
loaderStatus.textContent = googleApiKey || cesiumToken
? 'Loading Google 3D Tiles...'
: 'Loading the keyless globe...';
const photoreal = await loadPhotorealisticTileset(Cesium, {
googleApiKey,
cesiumToken,
});
const tileset = photoreal.tileset;
if (tileset) {
viewer.scene.primitives.add(tileset);
// NOTE: Cesium World Terrain intentionally disabled — conflicts with Google 3D Tiles at high zoom.
// Google Photorealistic 3D Tiles provide their own terrain/elevation.
viewer.scene.globe.show = false;
console.info(`[Init] Google 3D Tiles loaded via ${photoreal.route}.`);
} else {
if (photoreal.errors.length) {
const tileError = photoreal.errors.at(-1);
console.warn('[Init] Google 3D Tiles unavailable, using the keyless globe:', tileError);
const tileErrorDetail = describeError(tileError);
loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`;
}
viewer.scene.globe.show = true;
}
loaderStatus.textContent = 'Initializing systems...';
const mapStackController = new MapStackController(viewer, {
googleTileset: tileset,
cesiumToken,
initialStack: tileset ? 'photoreal' : 'esri-imagery',
// Task 5 (height-datum fix): rebroadcast stack changes as a window
// CustomEvent so data layers (CCTV per-regime ground resolution) can
// react without coupling MapStackController to layer modules. Fires on
// 'switching'/'ready'/'error'; listeners derive the surface regime from
// live scene state, so intermediate emissions are harmless.
onChange: (state) => {
window.dispatchEvent(new CustomEvent('gev:map-stack-changed', { detail: state }));
},
onError: (message) => console.warn('[MapStack]', message),
});
await mapStackController.setStack(tileset ? 'photoreal' : 'esri-imagery', { silent: true });
// Initialize the style manager (post-processing, HUD, locations, share links)
const styleManager = new StyleManager(viewer, { mapStackController });
// The previous multi-canvas weather compositor remains disabled. Cockpit
// clouds use a separate, capped low-resolution GPU pass that never attaches
// Cesium fog or post-process stages and is fully stopped in map mode.
const weatherEffects = null;
const cockpitCloudEffects = initCockpitCloudEffects(viewer);
// If no share link state, do default fly-to Austin
if (!styleManager.hasShareState) {
loaderStatus.textContent = 'Flying to Austin, TX...';
flyToAustin(viewer);
} else {
loaderStatus.textContent = 'Restoring shared view...';
}
// Initialize data layer manager
const dataManager = new DataLayerManager(viewer, {
allowQaRegistration: import.meta.env.DEV,
});
dataManager.register(flightsLayer);
dataManager.register(militaryFlightsLayer);
dataManager.register(earthquakesLayer);
dataManager.register(satellitesLayer);
dataManager.register(rocketLaunchesLayer);
rocketLaunchesLayer.attachDataManager(dataManager);
dataManager.register(trafficLayer);
dataManager.register(cctvLayer);
dataManager.register(radioLayer);
dataManager.register(bikeshareLayer);
dataManager.register(aisLiveVesselsLayer);
dataManager.register(militaryInstallationsLayer);
dataManager.register(militaryAwarenessLayer);
militaryAwarenessLayer.attachDataManager(dataManager);
for (const layer of localDataLayers) {
dataManager.register(layer);
}
// Restoration starts only after the complete production registry is sealed.
dataManager.finalizeRegistrations(LAYER_STATE_REGISTRY);
if (import.meta.env.DEV) {
window.__gevQaRegisterLayer = (targetManager, layerModule) => {
if (targetManager !== dataManager) throw new Error('QA layer manager mismatch');
return dataManager.registerForQa(layerModule);
};
window.__gevQaUnregisterLayer = (targetManager, layerId) => {
if (targetManager !== dataManager) throw new Error('QA layer manager mismatch');
return dataManager.unregisterForQa(layerId);
};
}
dataManager.buildTogglePanel(document.getElementById('data-toggles'));
styleManager.attachDataManager(dataManager);
// Initialize deterministic scene playback for social clip capture
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager);
// Initialize the voice "whiteboard" annotation engine (world-space renderer)
const annotations = initAnnotations({ viewer, tileset });
// Keep startup chrome truthful: a share is not restored until camera,
// visual/map/panel lanes, and every requested layer have terminated.
void Promise.all([
styleManager.initialRestorePromise,
new Promise((resolve) => setTimeout(resolve, 1000)),
]).finally(() => {
loadingScreen.classList.add('hidden');
// Reveal only after the loading cover has yielded. transitionend can be
// absent under reduced motion, so a bounded fallback makes this reliable.
let firstRunRevealed = false;
const revealFirstRun = () => {
if (firstRunRevealed) return;
firstRunRevealed = true;
// dataManager is passed explicitly: the globe missions enable bundled
// keyless layers through it, and reaching for styleManager._dataManager
// would make a private field part of this feature's contract.
initFirstRunExperience({ styleManager, dataManager });
};
loadingScreen.addEventListener('transitionend', revealFirstRun, { once: true });
setTimeout(revealFirstRun, 900);
});
// Provider Settings (the POWER UP chip + dialog). Fire-and-forget: the
// module removes its own surface when the dev-server endpoint is absent
// (prod builds, non-local visitors), so this costs prod exactly nothing.
void initKeySetup();
// Expose for debugging
// Idle render governor: flips the scene into requestRenderMode whenever
// nothing animates per frame. Installed AFTER every module above has had
// its chance to register pre-install holds. (perf wave 2)
installRenderGovernor(viewer);
// The explicit scope mask replaces the emergent six-pass artifact —
// see src/scopeMask.js. Installed before the UI so the DISPLAY-rail
// toggle finds it live.
installScopeMask(viewer);
// The follow camera recomputes the tracked target's dead-reckon position
// every frame — tracking anything is a per-frame animation. (perf wave 2)
viewer.trackedEntityChanged.addEventListener(() => {
if (viewer.trackedEntity) holdContinuousRender('tracked-entity');
else releaseContinuousRender('tracked-entity');
});
// Hidden-state suspension (perf wave 2): when the window/tab is hidden,
// stop the default render loop outright — a hidden canvas repaints for
// nobody, and browser rAF throttling still lets throttled frames burn
// GPU. Holder/data state is untouched, so return is seamless: restore
// the loop, refresh the one DOM surface we gated, render a frame.
const syncVisibilitySuspension = () => {
const hidden = document.hidden;
viewer.useDefaultRenderLoop = !hidden;
cockpitCloudEffects?.setSuspended?.(hidden);
if (!hidden) {
if (dataManager._panelRefreshPendingOnVisible) {
dataManager._panelRefreshPendingOnVisible = false;
dataManager._refreshTogglePanel();
}
governorRequestRender('visibility-restore');
}
};
document.addEventListener('visibilitychange', syncVisibilitySuspension);
// Apply the CURRENT state too — bootstrap can complete while the tab is
// already hidden, and waiting for the next transition would leave the
// loop burning behind a hidden tab. (perf wave 2 fix)
syncVisibilitySuspension();
window.__godsEyeView = {
viewer,
styleManager,
tileset,
dataManager,
sceneDirector,
mapStackController,
annotations,
weatherEffects,
cockpitCloudEffects,
getRenderGovernorDiagnostics,
requestRender: governorRequestRender,
};
window.__godsEyeView.voiceCommands = initGevVoiceCommands({ viewer, styleManager, dataManager, sceneDirector, annotations });
} catch (error) {
console.error("God's Eye View initialization failed:", error);
loaderStatus.textContent = `Error: ${describeError(error)}`;
loaderStatus.style.color = '#ff4444';
}
}
init();
export { application };
+13
View File
@@ -200,7 +200,20 @@ export class MapStackController {
return true;
}
/** Invalidate pending switches before the viewer is destroyed. */
destroy() {
if (this._destroyed) return;
this._destroyed = true;
this._switchGen++;
this._isSwitching = false;
this._removeImageryErrorListener?.();
this._removeImageryErrorListener = null;
this._onChange = null;
this._onError = null;
}
async setStack(id, { silent = false } = {}) {
if (this._destroyed) return this.getState();
const stack = this.getStack(id) || this.getStack('photoreal');
if (!stack) return null;
+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. */
export function _resetRenderGovernorForTest() {
_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
*/
constructor(viewer, styleManager, dataManager) {
this._destroyed = false;
this._pendingWork = new Set();
this._uiRemovers = [];
this.viewer = viewer;
this.styleManager = styleManager;
this.dataManager = dataManager;
@@ -346,6 +349,40 @@ export class SceneDirector {
this._initUI();
}
_listen(target, type, listener) {
if (!target) return;
target.addEventListener(type, listener);
this._uiRemovers.push(() => target.removeEventListener(type, listener));
}
_trackWork(promise) {
this._pendingWork ||= new Set();
this._pendingWork.add(promise);
const release = () => this._pendingWork.delete(promise);
promise.then(release, release);
return promise;
}
/** Stop playback and pending loads before releasing their viewer. */
destroy() {
if (this._destroyPromise) return this._destroyPromise;
this._destroyed = true;
this._destroyPromise = Promise.resolve().then(async () => {
this.stopScene('Stopped');
this._loadAbort?.abort();
this._loadGeneration++;
this.viewer.camera.cancelFlight();
for (const remove of this._uiRemovers || []) remove();
this._uiRemovers = [];
if (this._sceneShotList) this._sceneShotList.textContent = '';
clearTimeout(this._storageToastTimer);
await Promise.allSettled(this._pendingWork || []);
clearInterval(this._progressTimer);
document.removeEventListener('keydown', this._onKeyDown);
});
return this._destroyPromise;
}
/**
* Load and normalize the project from localStorage.
* Returns the default recipe-based project on missing or corrupt data.
@@ -402,46 +439,46 @@ export class SceneDirector {
this._renderSceneSelect();
this._renderShotList();
this._sceneSelect.addEventListener('change', () => {
this._listen(this._sceneSelect, 'change', () => {
this._selectedSceneId = this._sceneSelect.value;
const scene = this._getSelectedScene();
this._selectedShotId = scene?.shots[0]?.id || null;
this._renderShotList();
});
this._sceneNewBtn?.addEventListener('click', () => this._createScene());
this._sceneDeleteBtn?.addEventListener('click', () => this._deleteSelectedScene());
this._sceneCaptureBtn?.addEventListener('click', () => this.captureShot());
this._sceneUpdateShotBtn?.addEventListener('click', () => this.updateSelectedShot());
this._listen(this._sceneNewBtn, 'click', () => this._createScene());
this._listen(this._sceneDeleteBtn, 'click', () => this._deleteSelectedScene());
this._listen(this._sceneCaptureBtn, 'click', () => this.captureShot());
this._listen(this._sceneUpdateShotBtn, 'click', () => this.updateSelectedShot());
this._sceneStartBtn?.addEventListener('click', () => {
this._listen(this._sceneStartBtn, 'click', () => {
this.startScene(this._selectedSceneId);
});
this._sceneStopBtn?.addEventListener('click', () => {
this._listen(this._sceneStopBtn, 'click', () => {
this.stopScene('Stopped');
});
this._sceneNextBtn?.addEventListener('click', () => {
this._listen(this._sceneNextBtn, 'click', () => {
this.runNextScene();
});
this._sceneExportBtn?.addEventListener('click', () => {
this._listen(this._sceneExportBtn, 'click', () => {
this.exportProject();
});
this._sceneImportBtn?.addEventListener('click', () => {
this._listen(this._sceneImportBtn, 'click', () => {
this._sceneImportFile?.click();
});
this._sceneImportFile?.addEventListener('change', async () => {
this._listen(this._sceneImportFile, 'change', async () => {
const file = this._sceneImportFile?.files?.[0];
if (!file) return;
await this.importProjectFile(file);
this._sceneImportFile.value = '';
});
this._sceneDownloadBtn?.addEventListener('click', () => {
this._listen(this._sceneDownloadBtn, 'click', () => {
this.downloadLastRunMetadata();
});
@@ -719,7 +756,12 @@ export class SceneDirector {
* @param {Object} [options]
* @param {number} [options.flyDuration=2.2] - Camera flight duration in seconds
*/
async loadShot(sceneId, shotId, { flyDuration = 2.2 } = {}) {
loadShot(sceneId, shotId, options) {
if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' });
return this._trackWork(this._loadShot(sceneId, shotId, options));
}
async _loadShot(sceneId, shotId, { flyDuration = 2.2 } = {}) {
if (this._running) return;
const { scene, shot } = this._getShot(sceneId, shotId);
if (!scene || !shot) return;
@@ -875,7 +917,12 @@ export class SceneDirector {
* round-robining through the whole project (voice playback uses this).
* @returns {Promise<{started: boolean, reason?: string, shots?: number}>}
*/
async startScene(sceneId, { single = false } = {}) {
startScene(sceneId, options) {
if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' });
return this._trackWork(this._startScene(sceneId, options));
}
async _startScene(sceneId, { single = false } = {}) {
if (this._running) return { started: false, reason: 'already-running' };
const queue = this._buildPlaybackQueue(sceneId || this._selectedSceneId || this._project.scenes[0]?.id, { single });
@@ -1007,7 +1054,7 @@ export class SceneDirector {
* it without starting a full run. Used for manual step-through navigation.
*/
async runNextScene() {
if (this._running) return;
if (this._destroyed || this._running) return;
const queue = this._buildPlaybackQueue(this._selectedSceneId || this._project.scenes[0]?.id);
if (!queue.length) return;
@@ -1069,6 +1116,7 @@ export class SceneDirector {
async importProjectFile(file) {
try {
const text = await file.text();
if (this._destroyed) return;
const parsed = JSON.parse(text);
this._project = normalizeProject(parsed);
this._selectedSceneId = this._project.scenes[0]?.id || null;
@@ -1227,9 +1275,11 @@ export class SceneDirector {
await new Promise((resolve) => {
// Guard against double-resolve from both callback and timeout
let done = false;
let timer;
const finish = () => {
if (done) return;
done = true;
clearTimeout(timer);
resolve();
};
@@ -1247,7 +1297,7 @@ export class SceneDirector {
});
// Safety timeout in case Cesium callbacks fail to fire
setTimeout(finish, (duration + 0.6) * 1000);
if (!done) timer = setTimeout(finish, (duration + 0.6) * 1000);
});
}
+22
View File
@@ -623,3 +623,25 @@ test('a scene run supersedes a LOAD still suspended on its visual await', async
restore();
}
});
test('destroy drains cancelled LOAD work before its viewer can be discarded', async (t) => {
const restore = installSceneRuntime();
t.after(restore);
let finishVisual;
const dataManager = fakeDataManager();
const viewer = { camera: { cancelFlight() {} } };
const director = new SceneDirector(viewer, {
applyVisualState: () => new Promise((resolve) => { finishVisual = resolve; }),
}, dataManager);
const loading = director.loadShot('scene-1', 'shot-a');
let destroyed = false;
const stopping = director.destroy().then(() => { destroyed = true; });
await Promise.resolve();
assert.equal(destroyed, false);
finishVisual();
await Promise.all([loading, stopping]);
assert.equal(destroyed, true);
assert.equal(dataManager.setEnabledCalls.length, 0);
assert.equal((await director.startScene('scene-1')).reason, 'destroyed');
await director.destroy();
});