mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge pull request #319: application lifecycle and standalone startup
Preserve separate application extraction and mechanical-formatting commits.
This commit is contained in:
37 files changed
+1606
-388
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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, []);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
Vendored
+32
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Extract a human-readable error message from any thrown value.
|
||||||
|
* Handles Error objects, strings, and plain objects with message/error fields.
|
||||||
|
* @param {*} error — caught exception value
|
||||||
|
* @returns {string} best-effort error description
|
||||||
|
*/
|
||||||
|
export function describeError(error) {
|
||||||
|
if (!error) return 'Unknown initialization error';
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message && error.message.trim()) return error.message.trim();
|
||||||
|
return error.name || 'Initialization error';
|
||||||
|
}
|
||||||
|
if (typeof error === 'string' && error.trim()) return error.trim();
|
||||||
|
if (typeof error === 'object') {
|
||||||
|
const maybeMessage = String(error.message || error.error || '').trim();
|
||||||
|
if (maybeMessage) return maybeMessage;
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify(error);
|
||||||
|
if (serialized && serialized !== '{}') return serialized;
|
||||||
|
} catch {
|
||||||
|
// ignore serialization error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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();
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user