mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
refactor: separate application shell state and lifecycle owners
This commit is contained in:
1 parent
81b82da056
commit
bfefa875e1
20 files changed
+3510
-2481
No files matched your search
@@ -1,5 +1,9 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
- Separate application-shell responsibilities and state ownership while preserving
|
||||||
|
the layer, scene and voice API. Revoke pending globe-reset callbacks on disposal
|
||||||
|
and detach old Directions services when replacing a data manager.
|
||||||
|
|
||||||
- Extract a portable Director shot runner and connect existing scene playback to
|
- Extract a portable Director shot runner and connect existing scene playback to
|
||||||
it. Preserve authored content, project files, camera/layer behavior and source
|
it. Preserve authored content, project files, camera/layer behavior and source
|
||||||
attribution; document the planned timeline, scene-file and data-pack boundaries.
|
attribution; document the planned timeline, scene-file and data-pack boundaries.
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# God's Eye View Current State
|
# God's Eye View Current State
|
||||||
|
|
||||||
|
The application shell composes focused state owners for navigation, destination
|
||||||
|
lookup/orbit, Cockpit, visual settings, panel layout, aircraft display and layer
|
||||||
|
bindings. Keyboard/display subscriptions have a separate lifetime; existing
|
||||||
|
layer, scene and voice methods remain available through a delegation facade.
|
||||||
|
Disposal settles pending globe resets and removes camera-entry listeners before
|
||||||
|
asynchronous restoration; replacing a manager detaches its old Directions
|
||||||
|
services. See [UI ownership](UI-OWNERSHIP.md).
|
||||||
|
|
||||||
Director's ordered shot runner is exported independently of rendering, UI,
|
Director's ordered shot runner is exported independently of rendering, UI,
|
||||||
storage and scene content. The existing scene controller supplies an adapter for
|
storage and scene content. The existing scene controller supplies an adapter for
|
||||||
visual/layer state, camera travel, media holds and release. Playback cancellation,
|
visual/layer state, camera travel, media holds and release. Playback cancellation,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Application UI ownership
|
||||||
|
|
||||||
|
`src/ui/applicationShell.js` constructs controls, connects their explicit inputs
|
||||||
|
and coordinates shutdown. `shellFacade.js` preserves the methods used by layers,
|
||||||
|
scene playback and voice. It delegates; new behavior belongs with its owner.
|
||||||
|
|
||||||
|
| Owner | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `navigationController.js` | Navigation generation, tracking release and camera authority |
|
||||||
|
| `locationNavigation.js` | Destination lookup/selection, orbit, world jumps and globe-reset completion |
|
||||||
|
| `cockpitCoordinator.js` | Cockpit entry/rollback, readouts and the single Display portal |
|
||||||
|
| `visualSettings.js` | Effects, detection overrides, visual restoration and render-loop lifetime |
|
||||||
|
| `panelChrome.js` | Disclosure, docking, layout and Cockpit panel restoration |
|
||||||
|
| `aircraftDisplay.js` | Shared commercial/military 3D display preferences and controls |
|
||||||
|
| `layerBindings.js` | Data-manager attachment, selected-tracker adoption and camera-entry listeners |
|
||||||
|
| `displayBindings.js` | Keyboard/display subscriptions and frame-rate monitor lifetime |
|
||||||
|
| `shareRestoration.js` | Initial share/layer restoration and cancellation |
|
||||||
|
| `shellFeedback.js` | Loading feedback, notices and toast lifetime |
|
||||||
|
|
||||||
|
Owners receive named services, elements, operations and readers. They do not
|
||||||
|
receive the application shell itself. Readers resolve a replaceable collaborator
|
||||||
|
at use time; operations cross an ownership boundary without copying its state.
|
||||||
|
The facade exposes existing properties as delegates rather than duplicate state.
|
||||||
|
|
||||||
|
Shutdown has two phases. First revoke input, deferred navigation, camera-entry
|
||||||
|
listeners, lookups, timers and rendering work. Then await Context restoration,
|
||||||
|
release sensor overrides while the manager is still attached, detach layer
|
||||||
|
services and destroy scene resources. Late callbacks cannot acquire the camera.
|
||||||
|
Manager replacement also releases subscriptions and the previous Directions
|
||||||
|
module even if the replacement does not provide a Directions layer.
|
||||||
|
|
||||||
|
Source-contract tests follow both sides of delegated wiring. Lifecycle tests
|
||||||
|
exercise pending reset cancellation, stale camera callbacks, replacement and
|
||||||
|
idempotent teardown. Browser acceptance covers the composed application, including
|
||||||
|
share restoration, scene commands, navigation, Cockpit, display and voice.
|
||||||
@@ -504,6 +504,8 @@
|
|||||||
"src/styles/surveillance.js",
|
"src/styles/surveillance.js",
|
||||||
"src/styles/thermal.js",
|
"src/styles/thermal.js",
|
||||||
"src/ui/applicationShell.js",
|
"src/ui/applicationShell.js",
|
||||||
|
"src/ui/locationNavigation.js",
|
||||||
|
"src/ui/cockpitCoordinator.js",
|
||||||
"src/ui/cameraOrientationControls.js",
|
"src/ui/cameraOrientationControls.js",
|
||||||
"src/ui/applicationShortcuts.js",
|
"src/ui/applicationShortcuts.js",
|
||||||
"src/ui/cctv.js",
|
"src/ui/cctv.js",
|
||||||
@@ -574,7 +576,11 @@
|
|||||||
"src/ui/visualPresets.js",
|
"src/ui/visualPresets.js",
|
||||||
"src/ui/visualSettings.js",
|
"src/ui/visualSettings.js",
|
||||||
"src/worldFocus.js",
|
"src/worldFocus.js",
|
||||||
"src/data/feedState.js"
|
"src/data/feedState.js",
|
||||||
|
"src/ui/aircraftDisplay.js",
|
||||||
|
"src/ui/layerBindings.js",
|
||||||
|
"src/ui/displayBindings.js",
|
||||||
|
"src/ui/shellFacade.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -1556,6 +1562,8 @@
|
|||||||
"src/styles/surveillance.js",
|
"src/styles/surveillance.js",
|
||||||
"src/styles/thermal.js",
|
"src/styles/thermal.js",
|
||||||
"src/ui/applicationShell.js",
|
"src/ui/applicationShell.js",
|
||||||
|
"src/ui/locationNavigation.js",
|
||||||
|
"src/ui/cockpitCoordinator.js",
|
||||||
"src/ui/cameraOrientationControls.js",
|
"src/ui/cameraOrientationControls.js",
|
||||||
"src/ui/applicationShortcuts.js",
|
"src/ui/applicationShortcuts.js",
|
||||||
"src/ui/cctv.js",
|
"src/ui/cctv.js",
|
||||||
@@ -1700,7 +1708,11 @@
|
|||||||
"src/sources/overpassFeaturesRecords.js",
|
"src/sources/overpassFeaturesRecords.js",
|
||||||
"src/sources/overpassFeatures.js",
|
"src/sources/overpassFeatures.js",
|
||||||
"src/sources/featureSource.js",
|
"src/sources/featureSource.js",
|
||||||
"src/sources/overpassRoads.js"
|
"src/sources/overpassRoads.js",
|
||||||
|
"src/ui/aircraftDisplay.js",
|
||||||
|
"src/ui/layerBindings.js",
|
||||||
|
"src/ui/displayBindings.js",
|
||||||
|
"src/ui/shellFacade.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"@mapbox/vector-tile",
|
"@mapbox/vector-tile",
|
||||||
|
|||||||
@@ -2171,7 +2171,8 @@ try {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const resetResult = await page.evaluate(() => {
|
const resetResult = await page.evaluate(() => {
|
||||||
const manager = window.__godsEyeView.styleManager;
|
// Both the UI and compatibility facade delegate to this navigation owner.
|
||||||
|
const manager = window.__godsEyeView.styleManager._locationNavigation;
|
||||||
const awareness = window.__godsEyeView.dataManager.layers.get('military-awareness')?.module;
|
const awareness = window.__godsEyeView.dataManager.layers.get('military-awareness')?.module;
|
||||||
window.__qaCockpitReset = {
|
window.__qaCockpitReset = {
|
||||||
calls: 0,
|
calls: 0,
|
||||||
@@ -2203,7 +2204,7 @@ try {
|
|||||||
const awareness = gev.dataManager.layers.get('military-awareness')?.module;
|
const awareness = gev.dataManager.layers.get('military-awareness')?.module;
|
||||||
const subjectId = awareness?.getContextSnapshot?.()?.subject?.id || null;
|
const subjectId = awareness?.getContextSnapshot?.()?.subject?.id || null;
|
||||||
const height = gev.viewer.camera.positionCartographic?.height;
|
const height = gev.viewer.camera.positionCartographic?.height;
|
||||||
gev.styleManager.resetToGlobeView = qa.original;
|
gev.styleManager._locationNavigation.resetToGlobeView = qa.original;
|
||||||
delete window.__qaCockpitReset;
|
delete window.__qaCockpitReset;
|
||||||
return {
|
return {
|
||||||
calls: qa.calls,
|
calls: qa.calls,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { LayerBindings } from './ui/layerBindings.js';
|
||||||
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
||||||
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
||||||
import { StyleManager } from './ui/applicationShell.js';
|
import { StyleManager } from './ui/applicationShell.js';
|
||||||
@@ -302,8 +303,8 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () =
|
|||||||
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
|
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
|
||||||
ordered(dispose, [
|
ordered(dispose, [
|
||||||
'this._disposed = true;',
|
'this._disposed = true;',
|
||||||
|
'this._layerBindings.stop();',
|
||||||
'this._navigation.destroy();',
|
'this._navigation.destroy();',
|
||||||
'this._removeWorldRequestFocusListener?.();',
|
|
||||||
'await this._contextControls.restoreForDisposal();',
|
'await this._contextControls.restoreForDisposal();',
|
||||||
], 'dispose invalidation');
|
], 'dispose invalidation');
|
||||||
assert.match(shellMethod('destroy').toString(), /this\._stampNavigation\(\)/);
|
assert.match(shellMethod('destroy').toString(), /this\._stampNavigation\(\)/);
|
||||||
@@ -312,12 +313,16 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () =
|
|||||||
test('teardown synchronously closes immediate camera entry points', () => {
|
test('teardown synchronously closes immediate camera entry points', () => {
|
||||||
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
|
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
|
||||||
ordered(dispose, [
|
ordered(dispose, [
|
||||||
|
'this._disposed = true;',
|
||||||
|
'this._layerBindings.stop();',
|
||||||
|
'await this._contextControls.restoreForDisposal();',
|
||||||
|
], 'synchronous teardown barrier');
|
||||||
|
ordered(LayerBindings.prototype.stop.toString(), [
|
||||||
'this._disposed = true;',
|
'this._disposed = true;',
|
||||||
'this._removeCctvRequestFocusListener?.();',
|
'this._removeCctvRequestFocusListener?.();',
|
||||||
'this._removeWorldRequestFocusListener?.();',
|
'this._removeWorldRequestFocusListener?.();',
|
||||||
'this._navigationOwnerChangedRemover?.();',
|
'this._navigationOwnerChangedRemover?.();',
|
||||||
'await this._contextControls.restoreForDisposal();',
|
], 'camera bindings close synchronously');
|
||||||
], 'synchronous teardown barrier');
|
|
||||||
|
|
||||||
const navigation = body(
|
const navigation = body(
|
||||||
ui,
|
ui,
|
||||||
|
|||||||
@@ -408,7 +408,8 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
|
|||||||
assert.match(entryPanels[1], new RegExp(`'${panelId}'`), `${panelId} must collapse on entry`);
|
assert.match(entryPanels[1], new RegExp(`'${panelId}'`), `${panelId} must collapse on entry`);
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.match(ui, /onEntered: \(\) => this\._panelChrome\.enterCockpit\(\)/);
|
assert.match(ui, /onEntered: \(\) => this\.enterPanels\(\)/);
|
||||||
|
assert.match(ui, /enterPanels: \(\) => this\._panelChrome\.enterCockpit\(\)/);
|
||||||
const callback = { 1: shellMethod('enterCockpit').toString() };
|
const callback = { 1: shellMethod('enterCockpit').toString() };
|
||||||
assert.ok(callback, 'Cockpit onEntered callback is missing');
|
assert.ok(callback, 'Cockpit onEntered callback is missing');
|
||||||
assert.match(
|
assert.match(
|
||||||
@@ -438,7 +439,8 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
|
|||||||
'normal Context must not reopen over Cockpit',
|
'normal Context must not reopen over Cockpit',
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.match(ui, /onExited: \(\) => this\._panelChrome\.exitCockpit\(\)/);
|
assert.match(ui, /onExited: \(\) => this\.exitPanels\(\)/);
|
||||||
|
assert.match(ui, /exitPanels: \(\) => this\._panelChrome\.exitCockpit\(\)/);
|
||||||
const exitCallback = { 1: shellMethod('exitCockpit').toString() };
|
const exitCallback = { 1: shellMethod('exitCockpit').toString() };
|
||||||
assert.ok(exitCallback, 'Cockpit onExited callback is missing');
|
assert.ok(exitCallback, 'Cockpit onExited callback is missing');
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ test('Radio disclosure is explicit, starts closed while off, and preserves playb
|
|||||||
assert.doesNotMatch(renderMethod, /_radioMiniExpanded\s*=\s*false.*audioState === 'playing'/s);
|
assert.doesNotMatch(renderMethod, /_radioMiniExpanded\s*=\s*false.*audioState === 'playing'/s);
|
||||||
assert.match(ui, /contextRadioDetailsBtn/);
|
assert.match(ui, /contextRadioDetailsBtn/);
|
||||||
const syncStart = ui.indexOf('\n _syncPanelCollapseButton(panelEl)');
|
const syncStart = ui.indexOf('\n _syncPanelCollapseButton(panelEl)');
|
||||||
const syncMethod = ui.slice(syncStart, ui.indexOf('\n /**', syncStart + 10));
|
const syncMethod = ui.slice(syncStart, ui.indexOf('\n }', syncStart + 10));
|
||||||
assert.doesNotMatch(syncMethod, /contextRadioDetailsBtn[\s\S]*?(?:aria-label|textContent|\.title)/);
|
assert.doesNotMatch(syncMethod, /contextRadioDetailsBtn[\s\S]*?(?:aria-label|textContent|\.title)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -253,8 +253,9 @@ test('detection-on-by-default is a default, not an operator override', () => {
|
|||||||
const displayActions = uiSource.slice(uiSource.indexOf('this._displayControls ='));
|
const displayActions = uiSource.slice(uiSource.indexOf('this._displayControls ='));
|
||||||
const detectionButton = displayActions.slice(displayActions.indexOf('cycleDetection:'), displayActions.indexOf('toggleModels:'));
|
const detectionButton = displayActions.slice(displayActions.indexOf('cycleDetection:'), displayActions.indexOf('toggleModels:'));
|
||||||
assert.match(detectionButton, /cycleDetectionMode\(\)/, 'the button still invokes the detection action');
|
assert.match(detectionButton, /cycleDetectionMode\(\)/, 'the button still invokes the detection action');
|
||||||
assert.match(detectionButton, /this\._detectionUserOverridden = true;/,
|
assert.match(detectionButton, /this\.claimDetection\(\);/,
|
||||||
'and the detection control still claims the override when the operator uses it');
|
'and the detection control still claims the override when the operator uses it');
|
||||||
|
assert.match(uiSource, /claimDetection: \(\) => \{\s*this\._visualSettings\._detectionUserOverridden = true;/);
|
||||||
|
|
||||||
// Style-switch semantics are unchanged: Normal is still not a preset owner,
|
// Style-switch semantics are unchanged: Normal is still not a preset owner,
|
||||||
// so switching TO Normal does not re-apply or clear anything.
|
// so switching TO Normal does not re-apply or clear anything.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { DisplayBindings } from './ui/displayBindings.js';
|
||||||
|
import { LocationNavigation } from './ui/locationNavigation.js';
|
||||||
import { VisualSettings } from './ui/visualSettings.js';
|
import { VisualSettings } from './ui/visualSettings.js';
|
||||||
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
||||||
import { StyleManager } from './ui/applicationShell.js';
|
import { StyleManager } from './ui/applicationShell.js';
|
||||||
@@ -700,13 +702,15 @@ test('visual input listeners are revoked before asynchronous UI teardown', () =>
|
|||||||
const firstAwait = disposal.indexOf('await ');
|
const firstAwait = disposal.indexOf('await ');
|
||||||
assert.ok(firstAwait > 0);
|
assert.ok(firstAwait > 0);
|
||||||
const synchronous = disposal.slice(0, firstAwait);
|
const synchronous = disposal.slice(0, firstAwait);
|
||||||
assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/);
|
assert.match(synchronous, /this\._displayBindings\.destroy\(\)/);
|
||||||
assert.match(synchronous, /this\._visualSettings\.stop\(\)/);
|
assert.match(synchronous, /this\._visualSettings\.stop\(\)/);
|
||||||
assert.match(synchronous, /this\._mapSourceControls\?\.destroy\(\)/);
|
assert.match(synchronous, /this\._mapSourceControls\?\.destroy\(\)/);
|
||||||
assert.match(synchronous, /this\._clearLayersControl\?\.destroy\(\)/);
|
assert.match(synchronous, /this\._clearLayersControl\?\.destroy\(\)/);
|
||||||
assert.match(synchronous, /this\._locationControls\?\.destroy\(\)/);
|
assert.match(synchronous, /this\._locationNavigation\.destroy\(\)/);
|
||||||
assert.match(synchronous, /this\._locationLookup\?\.destroy\(\)/);
|
assert.match(LocationNavigation.prototype.destroy.toString(), /this\._locationControls\?\.destroy\(\)/);
|
||||||
assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/);
|
assert.match(LocationNavigation.prototype.destroy.toString(), /this\._locationLookup\?\.destroy\(\)/);
|
||||||
|
assert.match(DisplayBindings.prototype.destroy.toString(), /this\._displayControls\?\.destroy\(\)/);
|
||||||
|
assert.match(DisplayBindings.prototype.destroy.toString(), /this\._applicationShortcuts\?\.destroy\(\)/);
|
||||||
assert.match(VisualSettings.prototype.stop.toString(), /this\._styleParameters\?\.destroy\(\)/);
|
assert.match(VisualSettings.prototype.stop.toString(), /this\._styleParameters\?\.destroy\(\)/);
|
||||||
assert.match(VisualSettings.prototype.stop.toString(), /this\._visualEffects\.stop\(\)/);
|
assert.match(VisualSettings.prototype.stop.toString(), /this\._visualEffects\.stop\(\)/);
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
|
import { ShellFacade } from '../ui/shellFacade.js';
|
||||||
|
import { AircraftDisplay } from '../ui/aircraftDisplay.js';
|
||||||
|
import { LayerBindings } from '../ui/layerBindings.js';
|
||||||
|
import { DisplayBindings } from '../ui/displayBindings.js';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { CockpitCoordinator } from '../ui/cockpitCoordinator.js';
|
||||||
|
import { LocationNavigation } from '../ui/locationNavigation.js';
|
||||||
import { StyleManager } from '../ui/applicationShell.js';
|
import { StyleManager } from '../ui/applicationShell.js';
|
||||||
import { NavigationController } from '../ui/navigationController.js';
|
import { NavigationController } from '../ui/navigationController.js';
|
||||||
import { ShareRestoration } from '../ui/shareRestoration.js';
|
import { ShareRestoration } from '../ui/shareRestoration.js';
|
||||||
@@ -8,10 +14,16 @@ import { PanelChrome } from '../ui/panelChrome.js';
|
|||||||
/** Read the state owners as well as the compatibility/composition facade. */
|
/** Read the state owners as well as the compatibility/composition facade. */
|
||||||
export function readShellSource() {
|
export function readShellSource() {
|
||||||
return [
|
return [
|
||||||
|
'locationNavigation',
|
||||||
|
'cockpitCoordinator',
|
||||||
'navigationController',
|
'navigationController',
|
||||||
'shareRestoration',
|
'shareRestoration',
|
||||||
'visualSettings',
|
'visualSettings',
|
||||||
'panelChrome',
|
'panelChrome',
|
||||||
|
'aircraftDisplay',
|
||||||
|
'layerBindings',
|
||||||
|
'displayBindings',
|
||||||
|
'shellFacade',
|
||||||
'applicationShell',
|
'applicationShell',
|
||||||
]
|
]
|
||||||
.map((name) =>
|
.map((name) =>
|
||||||
@@ -27,7 +39,13 @@ export function shellMethod(name) {
|
|||||||
ShareRestoration,
|
ShareRestoration,
|
||||||
VisualSettings,
|
VisualSettings,
|
||||||
PanelChrome,
|
PanelChrome,
|
||||||
|
LocationNavigation,
|
||||||
|
CockpitCoordinator,
|
||||||
|
AircraftDisplay,
|
||||||
|
LayerBindings,
|
||||||
|
DisplayBindings,
|
||||||
StyleManager,
|
StyleManager,
|
||||||
|
ShellFacade,
|
||||||
]) {
|
]) {
|
||||||
const method = Object.getOwnPropertyDescriptor(
|
const method = Object.getOwnPropertyDescriptor(
|
||||||
owner.prototype,
|
owner.prototype,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/** Own the shared aircraft display preference and its controls. */
|
||||||
|
export class AircraftDisplay {
|
||||||
|
constructor({ elements, readDataManager, layout }) {
|
||||||
|
Object.assign(this, elements, {
|
||||||
|
readDataManager,
|
||||||
|
_layoutRightPanels: layout,
|
||||||
|
});
|
||||||
|
// Both aircraft layers default to models enabled in proximity mode.
|
||||||
|
this._models3dEnabled = true;
|
||||||
|
this._models3dMode = 'proximity';
|
||||||
|
this._models3dModeBtns = [
|
||||||
|
document.getElementById('models3d-mode-proximity'),
|
||||||
|
document.getElementById('models3d-mode-all'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
get _dataManager() {
|
||||||
|
return this.readDataManager();
|
||||||
|
}
|
||||||
|
_setModels3dParams(params, { origin = 'user' } = {}) {
|
||||||
|
this._dataManager?.setLayerParams('flights', params, { origin });
|
||||||
|
this._dataManager?.setLayerParams('military', params, { origin });
|
||||||
|
}
|
||||||
|
|
||||||
|
_syncModels3dFromLayerState(state) {
|
||||||
|
const options = state?.options?.flights;
|
||||||
|
if (!options) return;
|
||||||
|
this._models3dEnabled = options.models3d === true;
|
||||||
|
this._models3dMode = options.models3dMode === 'all' ? 'all' : 'proximity';
|
||||||
|
this._syncModels3dButtonState();
|
||||||
|
this._models3dModeRow?.classList.toggle('visible', this._models3dEnabled);
|
||||||
|
for (const button of this._models3dModeBtns || []) {
|
||||||
|
if (!button) continue;
|
||||||
|
const active = button.dataset.mode === this._models3dMode;
|
||||||
|
button.classList.toggle('active', active);
|
||||||
|
button.setAttribute('aria-checked', String(active));
|
||||||
|
}
|
||||||
|
this._layoutRightPanels();
|
||||||
|
}
|
||||||
|
|
||||||
|
_syncModels3dModeRow() {
|
||||||
|
if (this._models3dModeRow)
|
||||||
|
this._models3dModeRow.classList.toggle('visible', this._models3dEnabled);
|
||||||
|
this._layoutRightPanels();
|
||||||
|
}
|
||||||
|
|
||||||
|
_initModels3dToggle() {
|
||||||
|
if (!this._models3dBtn) return;
|
||||||
|
this._syncModels3dButtonState();
|
||||||
|
this._syncModels3dModeRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
_setModels3dEnabled(enabled) {
|
||||||
|
this._models3dEnabled = !!enabled;
|
||||||
|
this._setModels3dParams({ models3d: this._models3dEnabled });
|
||||||
|
this._syncModels3dButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
_setModels3dMode(mode) {
|
||||||
|
const normalized = mode === 'all' ? 'all' : 'proximity';
|
||||||
|
this._models3dMode = normalized;
|
||||||
|
this._setModels3dParams({ models3dMode: normalized });
|
||||||
|
for (const button of this._models3dModeBtns) {
|
||||||
|
if (!button) continue;
|
||||||
|
const active = button.dataset.mode === normalized;
|
||||||
|
button.classList.toggle('active', active);
|
||||||
|
button.setAttribute('aria-checked', String(active));
|
||||||
|
}
|
||||||
|
this._syncModels3dButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
_syncModels3dButtonState() {
|
||||||
|
this._models3dBtn?.classList.toggle('active', this._models3dEnabled);
|
||||||
|
// The lit/dark state is a colour to a sighted operator and nothing at all to
|
||||||
|
// a screen reader without this. It matters more now that the button ships
|
||||||
|
// ACTIVE from markup (default-on, 2026-08-22): the very first thing assistive
|
||||||
|
// tech reported was an unpressed-looking control over an armed layer.
|
||||||
|
// Mirrors #scope-toggle, which has always carried aria-pressed.
|
||||||
|
this._models3dBtn?.setAttribute(
|
||||||
|
'aria-pressed',
|
||||||
|
String(this._models3dEnabled),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+224
-2466
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,408 @@
|
|||||||
|
import { CockpitViewController, CockpitDisplayPortal } from './cockpit.js';
|
||||||
|
import { STYLE_STATUS_LABELS } from './visualPresets.js';
|
||||||
|
import { cockpitEntryAllowed } from '../contextModePolicy.js';
|
||||||
|
import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js';
|
||||||
|
import { enterCockpitWithTracking } from '../cockpitTracking.js';
|
||||||
|
|
||||||
|
/** Own Cockpit entry/rollback, readouts and the single Display portal. */
|
||||||
|
export class CockpitCoordinator {
|
||||||
|
constructor({
|
||||||
|
viewer,
|
||||||
|
services,
|
||||||
|
elements,
|
||||||
|
operations,
|
||||||
|
readDataManager,
|
||||||
|
readContext,
|
||||||
|
readActiveStyle,
|
||||||
|
enterPanels,
|
||||||
|
exitPanels,
|
||||||
|
}) {
|
||||||
|
Object.assign(
|
||||||
|
this,
|
||||||
|
{
|
||||||
|
viewer,
|
||||||
|
services,
|
||||||
|
readDataManager,
|
||||||
|
readContext,
|
||||||
|
readActiveStyle,
|
||||||
|
enterPanels,
|
||||||
|
exitPanels,
|
||||||
|
},
|
||||||
|
elements,
|
||||||
|
operations,
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
flightsLayer,
|
||||||
|
militaryFlightsLayer,
|
||||||
|
isTr3b,
|
||||||
|
toggleTr3b,
|
||||||
|
militaryAwarenessLayer,
|
||||||
|
cachedGroundFloor,
|
||||||
|
cachedMeshFloor,
|
||||||
|
GROUND_FLOOR_LIFT_M,
|
||||||
|
meshFloorPreferred,
|
||||||
|
warmGroundFloor,
|
||||||
|
sampleMeshFloorCells,
|
||||||
|
holdContinuousRender,
|
||||||
|
releaseContinuousRender,
|
||||||
|
fetchRegionalBrief,
|
||||||
|
regionalDistanceM,
|
||||||
|
weatherCodeLabel,
|
||||||
|
} = services;
|
||||||
|
this._cockpitDisplayPortal = null;
|
||||||
|
this.cockpitView = new CockpitViewController(viewer, {
|
||||||
|
services: {
|
||||||
|
flightsLayer,
|
||||||
|
militaryFlightsLayer,
|
||||||
|
isTr3b,
|
||||||
|
toggleTr3b,
|
||||||
|
militaryAwarenessLayer,
|
||||||
|
formatAwarenessLabel,
|
||||||
|
cachedGroundFloor,
|
||||||
|
cachedMeshFloor,
|
||||||
|
GROUND_FLOOR_LIFT_M,
|
||||||
|
meshFloorPreferred,
|
||||||
|
warmGroundFloor,
|
||||||
|
sampleMeshFloorCells,
|
||||||
|
holdContinuousRender,
|
||||||
|
releaseContinuousRender,
|
||||||
|
fetchRegionalBrief,
|
||||||
|
regionalDistanceM,
|
||||||
|
weatherCodeLabel,
|
||||||
|
},
|
||||||
|
onVisionChange: (mode, active, options) =>
|
||||||
|
this._setCockpitVision(mode, active, options),
|
||||||
|
onCameraTakeover: () =>
|
||||||
|
this._stampNavigation({ cancelPendingSelection: false }),
|
||||||
|
getInheritedVisionLabel: () =>
|
||||||
|
STYLE_STATUS_LABELS[this.activeStyle] ||
|
||||||
|
String(this.activeStyle || 'normal').toUpperCase(),
|
||||||
|
isEntryAllowed: () =>
|
||||||
|
cockpitEntryAllowed({
|
||||||
|
contextMode: this._contextMode,
|
||||||
|
contextModeChanging: this._contextModeChanging,
|
||||||
|
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
|
||||||
|
militaryEnabled: !!this._dataManager?.isEnabled('military'),
|
||||||
|
}),
|
||||||
|
onEntered: () => this.enterPanels(),
|
||||||
|
onExited: () => this.exitPanels(),
|
||||||
|
restoreTrackingFrame: (entity) => {
|
||||||
|
const [layerId, ...idParts] = String(entity?.gevTrackedId || '').split(
|
||||||
|
':',
|
||||||
|
);
|
||||||
|
const trackedId = idParts.join(':');
|
||||||
|
if (!trackedId) return false;
|
||||||
|
if (layerId === 'flights')
|
||||||
|
return flightsLayer.refocusTrackedById?.(trackedId) === true;
|
||||||
|
if (layerId === 'military')
|
||||||
|
return militaryFlightsLayer.refocusTrackedById?.(trackedId) === true;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
get _dataManager() {
|
||||||
|
return this.readDataManager();
|
||||||
|
}
|
||||||
|
get _contextMode() {
|
||||||
|
return this.readContext()?.mode ?? null;
|
||||||
|
}
|
||||||
|
get _contextModeChanging() {
|
||||||
|
return !!this.readContext()?.changing;
|
||||||
|
}
|
||||||
|
get activeStyle() {
|
||||||
|
return this.readActiveStyle();
|
||||||
|
}
|
||||||
|
getCockpitState() {
|
||||||
|
const { militaryAwarenessLayer } = this.services;
|
||||||
|
const snapshot = militaryAwarenessLayer.getContextSnapshot?.();
|
||||||
|
const info = this.cockpitView?.readAircraftInfo?.();
|
||||||
|
const active = Boolean(this.cockpitView?.active);
|
||||||
|
const gateOpen = Boolean(this.cockpitView?.isEntryAllowed?.());
|
||||||
|
// "Could Cockpit be ENTERED right now" — so it is false while already
|
||||||
|
// inside, unconditionally. Cockpit takes the entity off
|
||||||
|
// `viewer.trackedEntity` on entry and NEXT puts one back, which made this
|
||||||
|
// flip true/false between calls while `active` stayed true; readers
|
||||||
|
// (including the voice model) read that as a broken half-entered state.
|
||||||
|
const entryAllowed =
|
||||||
|
!active &&
|
||||||
|
Boolean(gateOpen && info && this.viewer?.trackedEntity?.position);
|
||||||
|
return {
|
||||||
|
active,
|
||||||
|
entryAllowed,
|
||||||
|
// Why entry is unavailable, so a refusal can be explained rather than
|
||||||
|
// guessed at.
|
||||||
|
entryBlockedReason:
|
||||||
|
entryAllowed || active
|
||||||
|
? null
|
||||||
|
: !gateOpen
|
||||||
|
? this._contextModeChanging
|
||||||
|
? 'contacts-starting'
|
||||||
|
: 'contacts-inactive'
|
||||||
|
: 'no-tracked-aircraft',
|
||||||
|
visionMode: this.cockpitView?.visionMode || null,
|
||||||
|
subject: info
|
||||||
|
? {
|
||||||
|
id: info.icao24 || info.id || null,
|
||||||
|
layerId: info.layerId || null,
|
||||||
|
callsign: info.callsign || null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
navigation: snapshot
|
||||||
|
? {
|
||||||
|
canPrevious: Boolean(snapshot.navigation?.canPrevious),
|
||||||
|
canNext: Boolean(snapshot.navigation?.canNext),
|
||||||
|
canFocus: Boolean(snapshot.navigation?.canFocus),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
awareness: snapshot
|
||||||
|
? {
|
||||||
|
radiusM: Number.isFinite(snapshot.radiusM)
|
||||||
|
? snapshot.radiusM
|
||||||
|
: null,
|
||||||
|
subject: snapshot.subject
|
||||||
|
? {
|
||||||
|
id: snapshot.subject.id || null,
|
||||||
|
layerId: snapshot.subject.layerId || null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
cohorts: Array.isArray(snapshot.cohorts)
|
||||||
|
? snapshot.cohorts.map((cohort) => ({
|
||||||
|
id: cohort?.id || null,
|
||||||
|
source: cohort?.source || null,
|
||||||
|
count: Number.isFinite(cohort?.count) ? cohort.count : null,
|
||||||
|
relationship: cohort?.relationship || null,
|
||||||
|
reason: cohort?.reason || null,
|
||||||
|
coverage: cohort?.coverage || null,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
navigation: snapshot.navigation
|
||||||
|
? {
|
||||||
|
canPrevious: Boolean(snapshot.navigation.canPrevious),
|
||||||
|
canNext: Boolean(snapshot.navigation.canNext),
|
||||||
|
canFocus: Boolean(snapshot.navigation.canFocus),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
activeTracked: this.cockpitView?.active
|
||||||
|
? Boolean(this.cockpitView?.trackedEntity)
|
||||||
|
: false,
|
||||||
|
activeMapView: !this.cockpitView?.active && entryAllowed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_retargetCockpitEntryLayer({
|
||||||
|
targetLayer,
|
||||||
|
aircraftClass,
|
||||||
|
currentTarget,
|
||||||
|
selectedTarget,
|
||||||
|
}) {
|
||||||
|
const { militaryAwarenessLayer } = this.services;
|
||||||
|
if (!['flights', 'military'].includes(targetLayer)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Cockpit flies aircraft only — ${targetLayer} contacts cannot be entered`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const activeLayer =
|
||||||
|
selectedTarget?.layerId || currentTarget?.layerId || null;
|
||||||
|
const alreadyOnLayer = activeLayer === targetLayer;
|
||||||
|
if (alreadyOnLayer && !aircraftClass)
|
||||||
|
return { ok: true, retargeted: false };
|
||||||
|
const moved = militaryAwarenessLayer?.navigateNext
|
||||||
|
? !!militaryAwarenessLayer.navigateNext({
|
||||||
|
targetLayer,
|
||||||
|
aircraftClass,
|
||||||
|
origin: 'voice',
|
||||||
|
})
|
||||||
|
: false;
|
||||||
|
if (moved) return { ok: true, retargeted: true };
|
||||||
|
// A filter that matched nothing still enters, as long as the layer is
|
||||||
|
// already right — the operator asked for that layer and is on it.
|
||||||
|
if (alreadyOnLayer) return { ok: true, retargeted: false };
|
||||||
|
const label = targetLayer === 'military' ? 'military' : 'civilian';
|
||||||
|
const filtered = aircraftClass ? `${aircraftClass} ` : '';
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `No ${filtered}${label} contact is available to enter — track one first, or say "next ${label}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
controlCockpit(
|
||||||
|
action,
|
||||||
|
{
|
||||||
|
notificationToken = null,
|
||||||
|
targetLayer = null,
|
||||||
|
aircraftClass = null,
|
||||||
|
selectedTarget = null,
|
||||||
|
rollbackTarget = undefined,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const { flightsLayer, militaryFlightsLayer } = this.services;
|
||||||
|
const normalized = String(action || '').toLowerCase();
|
||||||
|
if (!this.cockpitView) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
action: 'control_cockpit',
|
||||||
|
error: 'Cockpit controller unavailable',
|
||||||
|
state: this.getCockpitState(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (normalized === 'status') {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
action: 'control_cockpit',
|
||||||
|
state: this.getCockpitState(),
|
||||||
|
notificationToken: notificationToken || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (normalized === 'enter') {
|
||||||
|
// Entry is gated exactly as the manual entry chip is. Attempting it while
|
||||||
|
// the gate is shut produced the half-entered look the operator reported
|
||||||
|
// (a plane anchored under the camera with no cockpit around it), so
|
||||||
|
// refuse with the reason instead of trying.
|
||||||
|
if (!this.cockpitView.isEntryAllowed?.()) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
action: 'control_cockpit',
|
||||||
|
error: this._contextModeChanging
|
||||||
|
? 'Contacts is still starting up — try Cockpit again in a moment'
|
||||||
|
: 'Contacts must be active to enter Cockpit — say "open contacts" first',
|
||||||
|
state: this.getCockpitState(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let currentTarget = this.getAircraftTrackingTarget();
|
||||||
|
const layerForTarget = (target) =>
|
||||||
|
target?.layerId === 'military'
|
||||||
|
? militaryFlightsLayer
|
||||||
|
: target?.layerId === 'flights'
|
||||||
|
? flightsLayer
|
||||||
|
: null;
|
||||||
|
// A requested layer retargets BEFORE entry, through the same filtered
|
||||||
|
// navigation NEXT uses. Ignoring it entered on whatever was already
|
||||||
|
// tracked and reported success, so "cockpit in that military helicopter"
|
||||||
|
// silently put the operator in an airliner.
|
||||||
|
if (targetLayer) {
|
||||||
|
const requested = this._retargetCockpitEntryLayer({
|
||||||
|
targetLayer,
|
||||||
|
aircraftClass,
|
||||||
|
currentTarget,
|
||||||
|
selectedTarget,
|
||||||
|
});
|
||||||
|
if (!requested.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
action: 'control_cockpit',
|
||||||
|
error: requested.error,
|
||||||
|
state: this.getCockpitState(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (requested.retargeted) {
|
||||||
|
// The retarget is now the authority; a selection sampled before it
|
||||||
|
// would drag entry back to the wrong layer.
|
||||||
|
selectedTarget = null;
|
||||||
|
rollbackTarget =
|
||||||
|
rollbackTarget === undefined ? currentTarget : rollbackTarget;
|
||||||
|
currentTarget = this.getAircraftTrackingTarget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const selectedLayer =
|
||||||
|
selectedTarget?.layerId === 'military'
|
||||||
|
? militaryFlightsLayer
|
||||||
|
: selectedTarget?.layerId === 'flights'
|
||||||
|
? flightsLayer
|
||||||
|
: null;
|
||||||
|
const entry = enterCockpitWithTracking({
|
||||||
|
cockpitView: this.cockpitView,
|
||||||
|
selectedLayer,
|
||||||
|
selectedTarget,
|
||||||
|
currentLayer: layerForTarget(currentTarget),
|
||||||
|
rollbackLayer: layerForTarget(
|
||||||
|
rollbackTarget === undefined ? currentTarget : rollbackTarget,
|
||||||
|
),
|
||||||
|
rollbackTarget,
|
||||||
|
selectionOrigin: 'voice',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: entry.entered,
|
||||||
|
action: 'control_cockpit',
|
||||||
|
state: this.getCockpitState(),
|
||||||
|
error: entry.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (normalized === 'exit') {
|
||||||
|
const exited = !!this.cockpitView.exit();
|
||||||
|
return {
|
||||||
|
ok: exited,
|
||||||
|
action: 'control_cockpit',
|
||||||
|
state: this.getCockpitState(),
|
||||||
|
error: exited ? null : 'Cockpit was already inactive',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (normalized === 'next' || normalized === 'previous') {
|
||||||
|
const changed = this.cockpitView.navigateContext(
|
||||||
|
normalized === 'next' ? 1 : -1,
|
||||||
|
{
|
||||||
|
targetLayer,
|
||||||
|
aircraftClass,
|
||||||
|
origin: 'voice',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: changed,
|
||||||
|
action: 'control_cockpit',
|
||||||
|
state: this.getCockpitState(),
|
||||||
|
error: changed ? null : 'No further context target was available',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
action: 'control_cockpit',
|
||||||
|
error: `Unknown cockpit action: ${action}`,
|
||||||
|
state: this.getCockpitState(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_initCockpitDisplayPortal() {
|
||||||
|
this._cockpitDisplayPortal?.destroy();
|
||||||
|
this._cockpitDisplayPortal = new CockpitDisplayPortal({
|
||||||
|
standardPanel: this._ppToggles,
|
||||||
|
cockpitPanel: this._cockpitDisplayPanel,
|
||||||
|
groups: [
|
||||||
|
['hud', this._hudBtn?.closest('.pp-toggle-group')],
|
||||||
|
['detection', this._detectionBtn?.closest('.pp-toggle-group')],
|
||||||
|
['parameters', this._sliderPanel],
|
||||||
|
['models3d', this._models3dBtn?.closest('.pp-toggle-group')],
|
||||||
|
],
|
||||||
|
layout: () => {
|
||||||
|
this._layoutRightPanels();
|
||||||
|
this.cockpitView?.scheduleContextLayout();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_setCockpitDisplayPortalActive(active) {
|
||||||
|
this._cockpitDisplayPortal?.setActive(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
get _cockpitDisplayPortalActive() {
|
||||||
|
return this._cockpitDisplayPortal?.active ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get _displayPortalScrollRestoreOwner() {
|
||||||
|
return this._cockpitDisplayPortal?.restoreOwner ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get _standardDisplayScrollTop() {
|
||||||
|
return this._cockpitDisplayPortal?.standardScrollTop ?? 0;
|
||||||
|
}
|
||||||
|
stop() {
|
||||||
|
this.cockpitView?.stop();
|
||||||
|
this._cockpitDisplayPortal?.stop();
|
||||||
|
}
|
||||||
|
destroy() {
|
||||||
|
this.cockpitView?.dispose();
|
||||||
|
this._cockpitDisplayPortal?.destroy();
|
||||||
|
this._cockpitDisplayPortal = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { createFrameRateMonitor } from './frameRateMonitor.js';
|
||||||
|
import { bindApplicationShortcuts } from './visualInput.js';
|
||||||
|
import { bindDisplayControls } from './displayControls.js';
|
||||||
|
import { canonicalizeDensity } from '../data/detectionPolicy.js';
|
||||||
|
|
||||||
|
/** Own keyboard/display event subscriptions; settings remain with their state owners. */
|
||||||
|
export class DisplayBindings {
|
||||||
|
constructor({
|
||||||
|
viewer,
|
||||||
|
services,
|
||||||
|
elements,
|
||||||
|
operations,
|
||||||
|
readState,
|
||||||
|
claimDetection,
|
||||||
|
}) {
|
||||||
|
Object.assign(
|
||||||
|
this,
|
||||||
|
{ viewer, services, readState, claimDetection },
|
||||||
|
elements,
|
||||||
|
operations,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
get shareLinkManager() {
|
||||||
|
return this.readState().shareLinkManager;
|
||||||
|
}
|
||||||
|
get hud() {
|
||||||
|
return this.readState().hud;
|
||||||
|
}
|
||||||
|
get bloomEnabled() {
|
||||||
|
return this.readState().bloomEnabled;
|
||||||
|
}
|
||||||
|
get sharpenEnabled() {
|
||||||
|
return this.readState().sharpenEnabled;
|
||||||
|
}
|
||||||
|
get celestialRing() {
|
||||||
|
return this.readState().celestialRing;
|
||||||
|
}
|
||||||
|
get celestialRingEnabled() {
|
||||||
|
return this.readState().celestialRingEnabled;
|
||||||
|
}
|
||||||
|
get _models3dEnabled() {
|
||||||
|
return this.readState()._models3dEnabled;
|
||||||
|
}
|
||||||
|
get _models3dModeBtns() {
|
||||||
|
return this.readState()._models3dModeBtns;
|
||||||
|
}
|
||||||
|
get _detectionAllocationBtns() {
|
||||||
|
return this.readState()._detectionAllocationBtns;
|
||||||
|
}
|
||||||
|
_initUI() {
|
||||||
|
const {
|
||||||
|
cycleDetectionMode,
|
||||||
|
setScopeMaskEnabled,
|
||||||
|
isScopeMaskEnabled,
|
||||||
|
setScopeMaskFeather,
|
||||||
|
} = this.services;
|
||||||
|
this._applicationShortcuts?.destroy();
|
||||||
|
this._frameRateMonitor?.destroy();
|
||||||
|
this._frameRateMonitor = createFrameRateMonitor({
|
||||||
|
viewer: this.viewer,
|
||||||
|
documentRef: document,
|
||||||
|
});
|
||||||
|
this._applicationShortcuts = bindApplicationShortcuts({
|
||||||
|
documentRef: document,
|
||||||
|
searchInput: this._locationSearch,
|
||||||
|
actions: {
|
||||||
|
setStyle: (style) => this.setStyle(style),
|
||||||
|
dismissSearch: () => {
|
||||||
|
if (this._locationSearch.classList.contains('expanded')) {
|
||||||
|
this._locationSearch.classList.remove('expanded');
|
||||||
|
this._locationSearch.value = '';
|
||||||
|
this._locationSearch.blur();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleHud: () => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this.hud.toggle();
|
||||||
|
this._updateHudButtonState();
|
||||||
|
this._syncShareState();
|
||||||
|
},
|
||||||
|
toggleOrbit: () => this._toggleOrbit(),
|
||||||
|
toggleCleanView: () => this.toggleCleanView(),
|
||||||
|
toggleLayers: () =>
|
||||||
|
document.getElementById('data-panel').classList.toggle('active'),
|
||||||
|
cycleDetection: () => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this.claimDetection();
|
||||||
|
cycleDetectionMode();
|
||||||
|
this._syncShareState();
|
||||||
|
},
|
||||||
|
toggleCctv: () => this._toggleCctvEnabled(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this._displayControls?.destroy();
|
||||||
|
this._displayControls = bindDisplayControls({
|
||||||
|
elements: {
|
||||||
|
styleButtons: document.querySelectorAll('.style-btn'),
|
||||||
|
bloomButton: this._bloomBtn,
|
||||||
|
bloomSlider: this._bloomSlider,
|
||||||
|
sharpenButton: this._sharpenBtn,
|
||||||
|
sharpenSlider: this._sharpenSlider,
|
||||||
|
scopeButton: this._scopeBtn,
|
||||||
|
scopeFeatherSlider: this._scopeFeatherSlider,
|
||||||
|
hudLayout: this._hudLayoutSelect,
|
||||||
|
hudButton: this._hudBtn,
|
||||||
|
cleanViewButton: this._cleanViewBtn,
|
||||||
|
cleanViewExitButton: this._cleanViewExitBtn,
|
||||||
|
densitySlider: this._detectionDensitySlider,
|
||||||
|
detectionButton: this._detectionBtn,
|
||||||
|
allocationButtons: this._detectionAllocationBtns,
|
||||||
|
fadeSliders: [this._detectionFadeSlider, this._detectionOpacitySlider],
|
||||||
|
celestialButton: this._celestialBtn,
|
||||||
|
modelsButton: this._models3dBtn,
|
||||||
|
modelModeButtons: this._models3dBtn ? this._models3dModeBtns : [],
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
setStyle: (style) => this.setStyle(style),
|
||||||
|
toggleBloom: () => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this._setBloomEnabled(!this.bloomEnabled);
|
||||||
|
},
|
||||||
|
setBloomIntensity: (value) => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this._setBloomIntensity(value);
|
||||||
|
},
|
||||||
|
toggleSharpen: () => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this._setSharpenEnabled(!this.sharpenEnabled);
|
||||||
|
},
|
||||||
|
toggleScope: () => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
const next = !isScopeMaskEnabled();
|
||||||
|
setScopeMaskEnabled(next);
|
||||||
|
this._scopeBtn.classList.toggle('active', next);
|
||||||
|
this._scopeBtn.setAttribute('aria-pressed', String(next));
|
||||||
|
this._syncShareState();
|
||||||
|
},
|
||||||
|
setScopeFeather: (value) => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
const pct = Math.max(0, Math.min(100, value || 0));
|
||||||
|
if (this._scopeFeatherValue)
|
||||||
|
this._scopeFeatherValue.textContent = `${pct}%`;
|
||||||
|
setScopeMaskFeather(pct / 100);
|
||||||
|
this._syncShareState();
|
||||||
|
},
|
||||||
|
setSharpenIntensity: (pct) => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
if (this._sharpenSliderValue)
|
||||||
|
this._sharpenSliderValue.textContent = `${pct}%`;
|
||||||
|
this._applySharpenIntensity(pct / 100);
|
||||||
|
this._syncShareState();
|
||||||
|
},
|
||||||
|
setHudLayout: (value) => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this._setHudVariant(value);
|
||||||
|
},
|
||||||
|
toggleCleanView: () => this.toggleCleanView(),
|
||||||
|
exitCleanView: () => this.toggleCleanView(false),
|
||||||
|
setDensity: (value) => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this.claimDetection();
|
||||||
|
const pct = canonicalizeDensity(value);
|
||||||
|
this._detectionDensitySlider.value = String(pct);
|
||||||
|
if (this._detectionDensityValue)
|
||||||
|
this._detectionDensityValue.textContent = `${pct}%`;
|
||||||
|
this._applyDetectionDensityFromUi();
|
||||||
|
this._syncShareState();
|
||||||
|
},
|
||||||
|
setAllocation: (value) => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this.claimDetection();
|
||||||
|
this._setDetectionAllocation(value);
|
||||||
|
},
|
||||||
|
setFade: () => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this._applyDetectionFadeFromUi();
|
||||||
|
this._syncShareState();
|
||||||
|
},
|
||||||
|
toggleCelestial: () => {
|
||||||
|
const ringIsVisible = !!this.celestialRing?.visible;
|
||||||
|
if (!this.celestialRingEnabled || !ringIsVisible) {
|
||||||
|
this.setCelestialRingEnabled(true, { focus: true });
|
||||||
|
} else {
|
||||||
|
this.setCelestialRingEnabled(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleHud: () => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this.hud.toggle();
|
||||||
|
this._updateHudButtonState();
|
||||||
|
this._syncShareState();
|
||||||
|
},
|
||||||
|
cycleDetection: () => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this.claimDetection();
|
||||||
|
cycleDetectionMode();
|
||||||
|
this._syncShareState();
|
||||||
|
},
|
||||||
|
toggleModels: () => {
|
||||||
|
this._setModels3dEnabled(!this._models3dEnabled);
|
||||||
|
this._syncModels3dModeRow();
|
||||||
|
},
|
||||||
|
setModelsMode: (mode) => this._setModels3dMode(mode),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
destroy() {
|
||||||
|
this._applicationShortcuts?.destroy();
|
||||||
|
this._applicationShortcuts = null;
|
||||||
|
this._frameRateMonitor?.destroy();
|
||||||
|
this._frameRateMonitor = null;
|
||||||
|
this._displayControls?.destroy();
|
||||||
|
this._displayControls = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
|
||||||
|
import {
|
||||||
|
registerCctvFocusRequestListener,
|
||||||
|
routeCctvFocusRequest,
|
||||||
|
} from '../cctvFocusRequest.js';
|
||||||
|
import {
|
||||||
|
flyToWorldTarget,
|
||||||
|
registerWorldFocusRequestListener,
|
||||||
|
routeWorldFocusRequest,
|
||||||
|
} from '../worldFocus.js';
|
||||||
|
import { registerNavigationAuthorityListener } from '../navigationPolicy.js';
|
||||||
|
/** Own manager subscriptions and the camera-entry events that outlive controls. */
|
||||||
|
export class LayerBindings {
|
||||||
|
constructor({
|
||||||
|
viewer,
|
||||||
|
services,
|
||||||
|
readControls,
|
||||||
|
operations,
|
||||||
|
feedback,
|
||||||
|
shareRestoration,
|
||||||
|
}) {
|
||||||
|
Object.assign(
|
||||||
|
this,
|
||||||
|
{
|
||||||
|
viewer,
|
||||||
|
services,
|
||||||
|
readControls,
|
||||||
|
_feedback: feedback,
|
||||||
|
_shareRestoration: shareRestoration,
|
||||||
|
},
|
||||||
|
operations,
|
||||||
|
);
|
||||||
|
this._disposed = false;
|
||||||
|
this._dataManager = null;
|
||||||
|
this._directionsShellModule = null;
|
||||||
|
this._cctvRequestFocusHandler = null;
|
||||||
|
this._removeCctvRequestFocusListener = null;
|
||||||
|
this._worldRequestFocusHandler = null;
|
||||||
|
this._removeWorldRequestFocusListener = null;
|
||||||
|
this._removeNavigationAuthorityListener = null;
|
||||||
|
this._navigationOwnerChangedRemover = null;
|
||||||
|
this._awarenessSelectedHandler = null;
|
||||||
|
this._awarenessClearedHandler = null;
|
||||||
|
}
|
||||||
|
get hud() {
|
||||||
|
return this.readControls().hud;
|
||||||
|
}
|
||||||
|
get _contextControls() {
|
||||||
|
return this.readControls()._contextControls;
|
||||||
|
}
|
||||||
|
get _cctvControls() {
|
||||||
|
return this.readControls()._cctvControls;
|
||||||
|
}
|
||||||
|
get _radioControls() {
|
||||||
|
return this.readControls()._radioControls;
|
||||||
|
}
|
||||||
|
observeCamera() {
|
||||||
|
this._cctvRequestFocusHandler = (event) =>
|
||||||
|
routeCctvFocusRequest(
|
||||||
|
event,
|
||||||
|
(activate, focus) => this._runExplicitCctvFocus(activate, focus),
|
||||||
|
(cameraId, durationSec) =>
|
||||||
|
this.services.cctvLayer.focusCamera(cameraId, durationSec),
|
||||||
|
);
|
||||||
|
this._removeCctvRequestFocusListener = registerCctvFocusRequestListener(
|
||||||
|
window,
|
||||||
|
this._cctvRequestFocusHandler,
|
||||||
|
);
|
||||||
|
this._worldRequestFocusHandler = (event) =>
|
||||||
|
routeWorldFocusRequest(
|
||||||
|
event,
|
||||||
|
(detail, fly) => this._runExplicitWorldFocus(detail, fly),
|
||||||
|
(detail) => flyToWorldTarget(this.viewer, detail),
|
||||||
|
);
|
||||||
|
this._removeWorldRequestFocusListener = registerWorldFocusRequestListener(
|
||||||
|
window,
|
||||||
|
this._worldRequestFocusHandler,
|
||||||
|
);
|
||||||
|
this._navigationOwnerChangedRemover =
|
||||||
|
this.viewer.trackedEntityChanged.addEventListener((entity) => {
|
||||||
|
if (entity && !this._disposed)
|
||||||
|
this._stampNavigation({ cancelPendingSelection: false });
|
||||||
|
});
|
||||||
|
// Vessel/installation focus flies without ever assigning a tracked entity,
|
||||||
|
// so it cannot reach the listener above. It announces instead.
|
||||||
|
this._removeNavigationAuthorityListener =
|
||||||
|
registerNavigationAuthorityListener(window, (event) => {
|
||||||
|
if (this._disposed) return;
|
||||||
|
this._stampNavigation({
|
||||||
|
cancelPendingSelection:
|
||||||
|
event?.detail?.cancelPendingSelection !== false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_connectDirectionsCamera() {
|
||||||
|
if (!this._dataManager) {
|
||||||
|
// Detaching: the layer outlives this shell, so it must not keep calling
|
||||||
|
// a facade whose viewer is going away.
|
||||||
|
this._directionsShellModule?.attachShellServices?.(null);
|
||||||
|
this._directionsShellModule = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const directions = this._dataManager.layers?.get('directions')?.module;
|
||||||
|
if (this._directionsShellModule !== directions) {
|
||||||
|
this._directionsShellModule?.attachShellServices?.(null);
|
||||||
|
this._directionsShellModule = null;
|
||||||
|
}
|
||||||
|
if (typeof directions?.attachShellServices !== 'function') return;
|
||||||
|
this._directionsShellModule = directions;
|
||||||
|
directions.attachShellServices({
|
||||||
|
runNavigation: (navigate) =>
|
||||||
|
this.runImmediateNavigation('route', navigate),
|
||||||
|
floorFn: (lat, lon) => this.services.cachedGroundFloor(lat, lon),
|
||||||
|
warmFn: (cells) => this.services.warmGroundFloor(cells),
|
||||||
|
showToast: (message) => this._showToast(message),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_persistAwarenessSelection(event, cleared = false) {
|
||||||
|
if (!this._dataManager) return;
|
||||||
|
const origin = String(event?.detail?.origin || 'programmatic');
|
||||||
|
if (!isExplicitLayerStateOrigin(origin)) return;
|
||||||
|
const layerId = String(event?.detail?.layerId || '');
|
||||||
|
const config = {
|
||||||
|
flights: {
|
||||||
|
key: 'selectedFlightsTrackingId',
|
||||||
|
normalize: (value) =>
|
||||||
|
String(value ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase() || null,
|
||||||
|
},
|
||||||
|
military: {
|
||||||
|
key: 'selectedMilitaryTrackingId',
|
||||||
|
normalize: (value) =>
|
||||||
|
String(value ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase() || null,
|
||||||
|
},
|
||||||
|
satellites: {
|
||||||
|
key: 'selectedSatTrackingId',
|
||||||
|
normalize: (value) => {
|
||||||
|
const candidate = Number(value);
|
||||||
|
return Number.isFinite(candidate) && candidate > 0
|
||||||
|
? Math.trunc(candidate)
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}[layerId];
|
||||||
|
if (!config) return;
|
||||||
|
const selectedValue = cleared ? null : config.normalize(event?.detail?.id);
|
||||||
|
if (cleared || selectedValue === null) {
|
||||||
|
this._dataManager.adoptLayerParams?.(
|
||||||
|
layerId,
|
||||||
|
{
|
||||||
|
[config.key]: selectedValue,
|
||||||
|
},
|
||||||
|
{ origin },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A direct selection promotes a Context-owned tracker dependency into
|
||||||
|
// durable visibility before its selected ID is normalized. Context exit
|
||||||
|
// also keeps this adopted layer instead of tearing down the user's track.
|
||||||
|
const visibilityAdopted = this._dataManager.adoptLayerVisibility?.(
|
||||||
|
layerId,
|
||||||
|
true,
|
||||||
|
{ origin, adoptedFromSelection: true },
|
||||||
|
);
|
||||||
|
if (visibilityAdopted === false) return;
|
||||||
|
// Clear the prior family before publishing the replacement. Otherwise the
|
||||||
|
// coordinator briefly sees two IDs and correctly treats them as an
|
||||||
|
// ambiguous incoming state, which would discard the new durable target.
|
||||||
|
for (const [otherLayerId, otherKey] of [
|
||||||
|
['flights', 'selectedFlightsTrackingId'],
|
||||||
|
['military', 'selectedMilitaryTrackingId'],
|
||||||
|
['satellites', 'selectedSatTrackingId'],
|
||||||
|
]) {
|
||||||
|
if (otherLayerId === layerId) continue;
|
||||||
|
this._dataManager.setLayerParams(
|
||||||
|
otherLayerId,
|
||||||
|
{ [otherKey]: null },
|
||||||
|
{ origin },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this._dataManager.adoptLayerParams?.(
|
||||||
|
layerId,
|
||||||
|
{
|
||||||
|
[config.key]: selectedValue,
|
||||||
|
},
|
||||||
|
{ origin },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
attachDataManager(dataManager) {
|
||||||
|
if (this._disposed) return;
|
||||||
|
this._dataManager = dataManager || null;
|
||||||
|
this.hud.attachDataManager(this._dataManager);
|
||||||
|
this._updateTrafficSyncChip();
|
||||||
|
if (this._dataManagerUnsubscribe) {
|
||||||
|
this._dataManagerUnsubscribe();
|
||||||
|
this._dataManagerUnsubscribe = null;
|
||||||
|
}
|
||||||
|
this._contextControls.connect(this._dataManager);
|
||||||
|
if (typeof this._dataManager?.subscribe === 'function') {
|
||||||
|
this._dataManagerUnsubscribe = this._dataManager.subscribe((change) => {
|
||||||
|
this._feedback._loadingFeedbackEvent = change;
|
||||||
|
this._updateGlobalLoadingFeedback(performance.now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this._updateGlobalLoadingFeedback(performance.now());
|
||||||
|
this._syncContextModeButtons();
|
||||||
|
this._cctvControls.connect();
|
||||||
|
this._radioControls.connect();
|
||||||
|
this._connectDirectionsCamera();
|
||||||
|
if (!this._awarenessSelectedHandler) {
|
||||||
|
this._awarenessSelectedHandler = (event) =>
|
||||||
|
this._persistAwarenessSelection(event, false);
|
||||||
|
this._awarenessClearedHandler = (event) =>
|
||||||
|
this._persistAwarenessSelection(event, true);
|
||||||
|
window.addEventListener(
|
||||||
|
'gev:awareness-subject-selected',
|
||||||
|
this._awarenessSelectedHandler,
|
||||||
|
);
|
||||||
|
window.addEventListener(
|
||||||
|
'gev:awareness-subject-cleared',
|
||||||
|
this._awarenessClearedHandler,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this._shareRestoration.connect(this._dataManager);
|
||||||
|
}
|
||||||
|
stop() {
|
||||||
|
if (this._disposed) return;
|
||||||
|
this._disposed = true;
|
||||||
|
if (this._awarenessSelectedHandler) {
|
||||||
|
window.removeEventListener(
|
||||||
|
'gev:awareness-subject-selected',
|
||||||
|
this._awarenessSelectedHandler,
|
||||||
|
);
|
||||||
|
this._awarenessSelectedHandler = null;
|
||||||
|
}
|
||||||
|
if (this._awarenessClearedHandler) {
|
||||||
|
window.removeEventListener(
|
||||||
|
'gev:awareness-subject-cleared',
|
||||||
|
this._awarenessClearedHandler,
|
||||||
|
);
|
||||||
|
this._awarenessClearedHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._removeCctvRequestFocusListener?.();
|
||||||
|
this._removeCctvRequestFocusListener = null;
|
||||||
|
this._cctvRequestFocusHandler = null;
|
||||||
|
this._removeWorldRequestFocusListener?.();
|
||||||
|
this._removeWorldRequestFocusListener = null;
|
||||||
|
this._worldRequestFocusHandler = null;
|
||||||
|
this._navigationOwnerChangedRemover?.();
|
||||||
|
this._navigationOwnerChangedRemover = null;
|
||||||
|
this._removeNavigationAuthorityListener?.();
|
||||||
|
this._removeNavigationAuthorityListener = null;
|
||||||
|
}
|
||||||
|
disconnect() {
|
||||||
|
this._dataManagerUnsubscribe?.();
|
||||||
|
this._dataManagerUnsubscribe = null;
|
||||||
|
this._directionsShellModule?.attachShellServices?.(null);
|
||||||
|
this._directionsShellModule = null;
|
||||||
|
this._dataManager = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { createStateChannel } from '../app/stateChannel.js';
|
||||||
|
import { LocationControls } from './location.js';
|
||||||
|
|
||||||
|
/** Own destination selection, lookup, orbit and world-jump lifetime. */
|
||||||
|
export class LocationNavigation {
|
||||||
|
constructor({
|
||||||
|
viewer,
|
||||||
|
placeSearch,
|
||||||
|
services,
|
||||||
|
elements,
|
||||||
|
navigation,
|
||||||
|
readCockpit,
|
||||||
|
operations,
|
||||||
|
}) {
|
||||||
|
Object.assign(
|
||||||
|
this,
|
||||||
|
{ viewer, placeSearch, services, navigation, readCockpit },
|
||||||
|
elements,
|
||||||
|
operations,
|
||||||
|
);
|
||||||
|
this._disposed = false;
|
||||||
|
this._activeLocationId = null;
|
||||||
|
this._expandedCityId = null;
|
||||||
|
this._activePoiIndex = null;
|
||||||
|
this._currentTarget = null;
|
||||||
|
this._currentPoi = null;
|
||||||
|
this._searchedLocationLabel = null;
|
||||||
|
this._trafficTransitionTimer = null;
|
||||||
|
this._globeResetPromise = null;
|
||||||
|
this._cancelGlobeReset = null;
|
||||||
|
this._worldJumpActive = false;
|
||||||
|
this.orbitController = new services.OrbitController(viewer);
|
||||||
|
this._orbitIndicator = null;
|
||||||
|
this._locationState = createStateChannel(
|
||||||
|
() => this._locationLookup?.getState() || null,
|
||||||
|
);
|
||||||
|
this._locationState.subscribe(
|
||||||
|
({ state, change }) => this._handleLocationSearchState(state, change),
|
||||||
|
{ emitCurrent: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
get cockpitView() {
|
||||||
|
return this.readCockpit();
|
||||||
|
}
|
||||||
|
get _navigationGeneration() {
|
||||||
|
return this.navigation._navigationGeneration;
|
||||||
|
}
|
||||||
|
get _activeLocationSearchGeneration() {
|
||||||
|
return this.navigation._activeLocationSearchGeneration;
|
||||||
|
}
|
||||||
|
set _activeLocationSearchGeneration(value) {
|
||||||
|
this.navigation._activeLocationSearchGeneration = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrbit(enabled) {
|
||||||
|
const active = !!this.orbitController?.active;
|
||||||
|
if (typeof enabled === 'boolean' && enabled === active) {
|
||||||
|
return { ok: true, orbiting: active };
|
||||||
|
}
|
||||||
|
if (enabled === false) {
|
||||||
|
this._stopOrbit();
|
||||||
|
return { ok: true, orbiting: false };
|
||||||
|
}
|
||||||
|
if (!this._currentTarget) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
orbiting: false,
|
||||||
|
error: 'No active landmark to orbit — fly to a landmark first',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this._toggleOrbit();
|
||||||
|
return { ok: true, orbiting: !!this.orbitController?.active };
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeLocationSearch(listener, options) {
|
||||||
|
return this._locationState.subscribe(listener, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleLocationSearchState(state, change) {
|
||||||
|
if (this._disposed || !change) return;
|
||||||
|
if (change.type === 'started')
|
||||||
|
this._activeLocationSearchGeneration = change.generation;
|
||||||
|
else if (change.type === 'found') {
|
||||||
|
this._searchedLocationLabel = state.destination.label || state.query;
|
||||||
|
this._setActiveLocation(null);
|
||||||
|
this._currentPoi = null;
|
||||||
|
this._collapsePOIRow();
|
||||||
|
this._updateLocationMiniStatus();
|
||||||
|
} else if (change.type === 'missing') this._showToast('Location not found');
|
||||||
|
else if (change.type === 'failed') this._showToast('Search failed');
|
||||||
|
else if (change.type === 'settled')
|
||||||
|
this._settleLocationSearchUi(change.generation);
|
||||||
|
else if (
|
||||||
|
change.type === 'reset' &&
|
||||||
|
this._activeLocationSearchGeneration !== null
|
||||||
|
) {
|
||||||
|
this._settleLocationSearchUi(this._activeLocationSearchGeneration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_initLocationBar() {
|
||||||
|
const { CITY_POIS, searchAndFlyTo, LocationSearch } = this.services;
|
||||||
|
this._locationControls?.destroy();
|
||||||
|
this._locationLookupUnsubscribe?.();
|
||||||
|
this._locationLookup?.destroy();
|
||||||
|
this._locationLookup = new LocationSearch({
|
||||||
|
input: this._locationSearch,
|
||||||
|
begin: () => this._beginDeferredNavigation('location'),
|
||||||
|
isCurrent: (generation) =>
|
||||||
|
!this._disposed && generation === this._navigationGeneration,
|
||||||
|
beforeFly: (generation) => this._reassertNavigationHandoff(generation),
|
||||||
|
search: (query, options) =>
|
||||||
|
searchAndFlyTo(this.viewer, query, {
|
||||||
|
placeSearch: this.placeSearch,
|
||||||
|
...options,
|
||||||
|
}),
|
||||||
|
onError: (error) => console.error('[Search] Geocoding failed:', error),
|
||||||
|
});
|
||||||
|
this._locationLookupUnsubscribe = this._locationLookup.subscribe(
|
||||||
|
({ initial, change }) => {
|
||||||
|
this._locationState.publish(initial ? { type: 'reset' } : change);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this._locationControls = new LocationControls({
|
||||||
|
elements: {
|
||||||
|
pills: this._locationPills,
|
||||||
|
poiRow: this._poiRow,
|
||||||
|
divider: this._locationBarDivider,
|
||||||
|
search: this._locationSearch,
|
||||||
|
searchToggle: this._searchToggle,
|
||||||
|
resetButtons: [this._resetGlobeBtn, this._cockpitResetGlobeBtn],
|
||||||
|
statusCity: this._locationMiniCity,
|
||||||
|
statusPoi: this._locationMiniPoi,
|
||||||
|
},
|
||||||
|
cities: CITY_POIS,
|
||||||
|
getExpandedCity: () => this._expandedCityId,
|
||||||
|
onCity: (id) => this._onCityPillClick(id),
|
||||||
|
onPoi: (id, index) => this._onPoiClick(id, index),
|
||||||
|
onSearch: (query) => this._locationLookup.run(query),
|
||||||
|
onReset: () => this.resetToGlobeView(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_beginWorldJumpTransition() {
|
||||||
|
const { suspendDetection, trafficLayer } = this.services;
|
||||||
|
clearTimeout(this._trafficTransitionTimer);
|
||||||
|
this._worldJumpActive = true;
|
||||||
|
trafficLayer.beginWorldJump?.();
|
||||||
|
suspendDetection('intercity');
|
||||||
|
}
|
||||||
|
|
||||||
|
_endWorldJumpTransition() {
|
||||||
|
const { resumeDetection, trafficLayer } = this.services;
|
||||||
|
clearTimeout(this._trafficTransitionTimer);
|
||||||
|
this._worldJumpActive = false;
|
||||||
|
this._trafficTransitionTimer = null;
|
||||||
|
trafficLayer.endWorldJump?.();
|
||||||
|
resumeDetection();
|
||||||
|
this._updateTrafficSyncChip(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
_flyWithTransition(cityChanged, flyAction) {
|
||||||
|
return this._runExplicitNavigation('location', () => {
|
||||||
|
if (!cityChanged) return flyAction({});
|
||||||
|
let completed = false;
|
||||||
|
const finalize = () => {
|
||||||
|
if (completed || this._disposed) return;
|
||||||
|
completed = true;
|
||||||
|
this._endWorldJumpTransition();
|
||||||
|
};
|
||||||
|
const result = flyAction({
|
||||||
|
onStart: () => this._beginWorldJumpTransition(),
|
||||||
|
onComplete: finalize,
|
||||||
|
});
|
||||||
|
this._trafficTransitionTimer = window.setTimeout(finalize, 5200);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_onCityPillClick(cityId) {
|
||||||
|
const { CITY_POIS, flyToPresetLocation } = this.services;
|
||||||
|
if (this._expandedCityId === cityId) {
|
||||||
|
// Same city clicked again — toggle collapse
|
||||||
|
this._collapsePOIRow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCityChanged =
|
||||||
|
this._activeLocationId && this._activeLocationId !== cityId;
|
||||||
|
const result = this._flyWithTransition(!!isCityChanged, (hooks) =>
|
||||||
|
flyToPresetLocation(this.viewer, cityId, hooks),
|
||||||
|
);
|
||||||
|
if (result === false) return;
|
||||||
|
this._expandPOIRow(cityId);
|
||||||
|
this._setActiveLocation(cityId);
|
||||||
|
this._activePoiIndex = 0;
|
||||||
|
this._updatePoiHighlight();
|
||||||
|
|
||||||
|
// Track current target + POI for orbit
|
||||||
|
if (result) {
|
||||||
|
this._currentTarget = result.targetPosition;
|
||||||
|
this._currentPoi = CITY_POIS[cityId].pois[0];
|
||||||
|
}
|
||||||
|
this._updateLocationMiniStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
_onPoiClick(cityId, poiIndex) {
|
||||||
|
const { CITY_POIS, flyToPOI } = this.services;
|
||||||
|
const isCityChanged =
|
||||||
|
this._activeLocationId && this._activeLocationId !== cityId;
|
||||||
|
const result = this._flyWithTransition(!!isCityChanged, (hooks) =>
|
||||||
|
flyToPOI(this.viewer, cityId, poiIndex, hooks),
|
||||||
|
);
|
||||||
|
if (result === false) return;
|
||||||
|
this._setActiveLocation(cityId);
|
||||||
|
this._activePoiIndex = poiIndex;
|
||||||
|
this._updatePoiHighlight();
|
||||||
|
|
||||||
|
// Track current target + POI for orbit
|
||||||
|
if (result) {
|
||||||
|
this._currentTarget = result.targetPosition;
|
||||||
|
this._currentPoi = CITY_POIS[cityId].pois[poiIndex];
|
||||||
|
}
|
||||||
|
this._updateLocationMiniStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
_expandPOIRow(cityId) {
|
||||||
|
const { CITY_POIS } = this.services;
|
||||||
|
if (!CITY_POIS[cityId]) return;
|
||||||
|
this._expandedCityId = cityId;
|
||||||
|
this._locationControls.showPois(cityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
_collapsePOIRow() {
|
||||||
|
this._expandedCityId = null;
|
||||||
|
this._activePoiIndex = null;
|
||||||
|
this._locationControls.hidePois();
|
||||||
|
}
|
||||||
|
|
||||||
|
_updatePoiHighlight() {
|
||||||
|
this._locationControls.highlightPoi(this._activePoiIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSearchedLocation() {
|
||||||
|
if (this._searchedLocationLabel === null) return;
|
||||||
|
this._searchedLocationLabel = null;
|
||||||
|
this._updateLocationMiniStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
_setActiveLocation(locationId) {
|
||||||
|
this._activeLocationId = locationId;
|
||||||
|
// A preset city is now what the camera is framed on, so any earlier
|
||||||
|
// free-text destination has been superseded. Clearing only on a real id
|
||||||
|
// leaves the search path's own _setActiveLocation(null) untouched.
|
||||||
|
if (locationId) this._searchedLocationLabel = null;
|
||||||
|
this._locationControls?.highlightCity(locationId);
|
||||||
|
this._updateLocationMiniStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateLocationMiniStatus() {
|
||||||
|
const { CITY_POIS } = this.services;
|
||||||
|
this._locationControls?.renderStatus({
|
||||||
|
city: this._activeLocationId ? CITY_POIS[this._activeLocationId] : null,
|
||||||
|
currentPoi: this._currentPoi,
|
||||||
|
searchedLabel: this._searchedLocationLabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_initOrbit() {
|
||||||
|
this._orbitIndicator = this._locationControls.createOrbitIndicator();
|
||||||
|
}
|
||||||
|
|
||||||
|
_toggleOrbit() {
|
||||||
|
if (!this._currentTarget) {
|
||||||
|
this._showToast('Fly to a POI first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = this.orbitController.toggle(this._currentTarget, {
|
||||||
|
radius: this._currentPoi?.alt || 500,
|
||||||
|
pitch: this._currentPoi?.pitch || -30,
|
||||||
|
});
|
||||||
|
|
||||||
|
this._orbitIndicator.classList.toggle('active', isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopOrbit() {
|
||||||
|
if (this.orbitController.active) {
|
||||||
|
this.orbitController.stop();
|
||||||
|
this._orbitIndicator.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetToGlobeView() {
|
||||||
|
const {
|
||||||
|
GLOBE_VIEW,
|
||||||
|
flyToGlobeView,
|
||||||
|
interruptCameraMotion,
|
||||||
|
flightsLayer,
|
||||||
|
militaryFlightsLayer,
|
||||||
|
satellitesLayer,
|
||||||
|
aisLiveVesselsLayer,
|
||||||
|
militaryAwarenessLayer,
|
||||||
|
rocketLaunchesLayer,
|
||||||
|
} = this.services;
|
||||||
|
if (this._disposed)
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
action: 'zoom_to_globe',
|
||||||
|
cancelled: true,
|
||||||
|
});
|
||||||
|
if (this._globeResetPromise) return this._globeResetPromise;
|
||||||
|
this._stampNavigation();
|
||||||
|
interruptCameraMotion('reset-globe');
|
||||||
|
this._stopOrbit();
|
||||||
|
this.cockpitView?.exit({ restoreTracking: false });
|
||||||
|
try {
|
||||||
|
militaryAwarenessLayer.releaseCameraOwnership?.({ origin: 'tool' });
|
||||||
|
} catch {
|
||||||
|
// Keep reset available if Context has not initialized completely.
|
||||||
|
try {
|
||||||
|
flightsLayer.stopTracking?.({ origin: 'tool' });
|
||||||
|
} catch {
|
||||||
|
/* best-effort release */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
militaryFlightsLayer.stopTracking?.({ origin: 'tool' });
|
||||||
|
} catch {
|
||||||
|
/* best-effort release */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
aisLiveVesselsLayer.clearSelection?.();
|
||||||
|
} catch {
|
||||||
|
/* best-effort release */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
satellitesLayer.stopTracking?.({ origin: 'tool' });
|
||||||
|
} catch {
|
||||||
|
/* best-effort release */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
rocketLaunchesLayer.releaseCameraOwnership?.();
|
||||||
|
} catch {
|
||||||
|
/* best-effort release */
|
||||||
|
}
|
||||||
|
this.viewer.trackedEntity = undefined;
|
||||||
|
this.viewer.camera.cancelFlight();
|
||||||
|
this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||||
|
this._beginWorldJumpTransition();
|
||||||
|
|
||||||
|
let resolveReset;
|
||||||
|
const resetPromise = new Promise((resolve) => {
|
||||||
|
resolveReset = resolve;
|
||||||
|
});
|
||||||
|
this._globeResetPromise = resetPromise;
|
||||||
|
let settled = false;
|
||||||
|
let timer = null;
|
||||||
|
const finish = (cancelled = false) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
this._endWorldJumpTransition();
|
||||||
|
const carto = this.viewer.camera.positionCartographic;
|
||||||
|
const result = {
|
||||||
|
ok: !cancelled,
|
||||||
|
action: 'zoom_to_globe',
|
||||||
|
cancelled,
|
||||||
|
heightKm: Math.round(GLOBE_VIEW.heightM / 1000),
|
||||||
|
centeredOn: {
|
||||||
|
latitude: Number(Cesium.Math.toDegrees(carto.latitude).toFixed(2)),
|
||||||
|
longitude: Number(Cesium.Math.toDegrees(carto.longitude).toFixed(2)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this._resetGlobeBtn?.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
'Reset to full globe view',
|
||||||
|
);
|
||||||
|
this._cockpitResetGlobeBtn?.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
'Reset cockpit to full globe view',
|
||||||
|
);
|
||||||
|
this._globeResetPromise = null;
|
||||||
|
this._cancelGlobeReset = null;
|
||||||
|
resolveReset(result);
|
||||||
|
};
|
||||||
|
this._cancelGlobeReset = () => finish(true);
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
const height = this.viewer.camera.positionCartographic?.height;
|
||||||
|
finish(
|
||||||
|
!Number.isFinite(height) ||
|
||||||
|
Math.abs(height - GLOBE_VIEW.heightM) > 1000,
|
||||||
|
);
|
||||||
|
}, 4200);
|
||||||
|
this._resetGlobeBtn?.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
'Resetting to full globe view',
|
||||||
|
);
|
||||||
|
this._cockpitResetGlobeBtn?.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
'Resetting cockpit to full globe view',
|
||||||
|
);
|
||||||
|
const target = flyToGlobeView(this.viewer, {
|
||||||
|
onComplete: () => finish(false),
|
||||||
|
onCancel: () => finish(true),
|
||||||
|
});
|
||||||
|
if (!target) finish(true);
|
||||||
|
return resetPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revoke callbacks and settle owned camera work before the viewer is released. */
|
||||||
|
destroy() {
|
||||||
|
if (this._disposed) return;
|
||||||
|
this._disposed = true;
|
||||||
|
this._locationState.destroy();
|
||||||
|
this._locationLookupUnsubscribe?.();
|
||||||
|
this._locationLookupUnsubscribe = null;
|
||||||
|
this._locationLookup?.destroy();
|
||||||
|
this._locationControls?.destroy();
|
||||||
|
this._cancelGlobeReset?.();
|
||||||
|
this._cancelGlobeReset = null;
|
||||||
|
if (this._worldJumpActive) this._endWorldJumpTransition();
|
||||||
|
clearTimeout(this._trafficTransitionTimer);
|
||||||
|
this._trafficTransitionTimer = null;
|
||||||
|
this.orbitController?.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,135 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { LocationNavigation } from './locationNavigation.js';
|
||||||
|
import { LayerBindings } from './layerBindings.js';
|
||||||
|
|
||||||
|
function replaceWindow(t, value) {
|
||||||
|
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, writable: true, value });
|
||||||
|
t.after(() => {
|
||||||
|
if (previous) Object.defineProperty(globalThis, 'window', previous);
|
||||||
|
else delete globalThis.window;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function location(t) {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
replaceWindow(t, { setTimeout });
|
||||||
|
const calls = [];
|
||||||
|
let hooks;
|
||||||
|
const owner = new LocationNavigation({
|
||||||
|
viewer: { camera: {
|
||||||
|
cancelFlight() {}, lookAtTransform() {},
|
||||||
|
positionCartographic: { latitude: 0, longitude: 0, height: 20000000 },
|
||||||
|
} },
|
||||||
|
services: {
|
||||||
|
OrbitController: class { stop() { calls.push('orbit-stop'); } },
|
||||||
|
GLOBE_VIEW: { heightM: 20000000 },
|
||||||
|
flyToGlobeView(_viewer, callbacks) { hooks = callbacks; return {}; },
|
||||||
|
interruptCameraMotion() {},
|
||||||
|
militaryAwarenessLayer: {}, satellitesLayer: {}, rocketLaunchesLayer: {},
|
||||||
|
trafficLayer: {
|
||||||
|
beginWorldJump() { calls.push('begin'); },
|
||||||
|
endWorldJump() { calls.push('end'); },
|
||||||
|
},
|
||||||
|
suspendDetection() { calls.push('suspend'); },
|
||||||
|
resumeDetection() { calls.push('resume'); },
|
||||||
|
},
|
||||||
|
elements: {}, navigation: {}, readCockpit: () => null,
|
||||||
|
operations: {
|
||||||
|
_stampNavigation() {}, _updateTrafficSyncChip() {},
|
||||||
|
_runExplicitNavigation(_noun, run) { return run(); },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { owner, calls, readHooks: () => hooks };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shell disposal settles a pending globe reset once and revokes its timeout', async (t) => {
|
||||||
|
const { owner, calls, readHooks } = location(t);
|
||||||
|
const pending = owner.resetToGlobeView();
|
||||||
|
assert.equal(owner.resetToGlobeView(), pending);
|
||||||
|
owner.destroy();
|
||||||
|
assert.equal((await pending).cancelled, true);
|
||||||
|
const after = [...calls];
|
||||||
|
readHooks().onComplete();
|
||||||
|
t.mock.timers.tick(10000);
|
||||||
|
owner.destroy();
|
||||||
|
assert.deepEqual(calls, after);
|
||||||
|
assert.equal(calls.filter(c => c === 'resume').length, 1);
|
||||||
|
assert.equal((await owner.resetToGlobeView()).cancelled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('world jump completion cannot revive navigation after shell disposal', (t) => {
|
||||||
|
const { owner, calls } = location(t);
|
||||||
|
let complete;
|
||||||
|
owner._flyWithTransition(true, ({ onStart, onComplete }) => {
|
||||||
|
onStart(); complete = onComplete;
|
||||||
|
});
|
||||||
|
owner.destroy();
|
||||||
|
const after = [...calls];
|
||||||
|
complete();
|
||||||
|
t.mock.timers.tick(10000);
|
||||||
|
assert.deepEqual(calls, after);
|
||||||
|
assert.equal(calls.filter(c => c === 'resume').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
function bindings(t) {
|
||||||
|
replaceWindow(t, new EventTarget());
|
||||||
|
const events = [];
|
||||||
|
const controls = {
|
||||||
|
hud: { attachDataManager() {} },
|
||||||
|
_contextControls: { connect() {} },
|
||||||
|
_cctvControls: { connect() {} }, _radioControls: { connect() {} },
|
||||||
|
};
|
||||||
|
const owner = new LayerBindings({
|
||||||
|
viewer: {}, services: {}, readControls: () => controls,
|
||||||
|
feedback: {}, shareRestoration: { connect() {} },
|
||||||
|
operations: {
|
||||||
|
_updateTrafficSyncChip() {}, _updateGlobalLoadingFeedback() {},
|
||||||
|
_syncContextModeButtons() {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
function manager(id, directions = true) {
|
||||||
|
return {
|
||||||
|
layers: new Map(directions ? [['directions', { module: {
|
||||||
|
attachShellServices(value) { events.push([id, value ? 'attach' : 'detach']); },
|
||||||
|
} }]] : []),
|
||||||
|
subscribe() { events.push([id, 'subscribe']); return () => events.push([id, 'unsubscribe']); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { owner, manager, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('manager replacement releases Directions even when the new manager has no route layer', (t) => {
|
||||||
|
const { owner, manager, events } = bindings(t);
|
||||||
|
owner.attachDataManager(manager('old'));
|
||||||
|
owner.attachDataManager(manager('new', false));
|
||||||
|
assert.deepEqual(events, [
|
||||||
|
['old', 'subscribe'], ['old', 'attach'], ['old', 'unsubscribe'],
|
||||||
|
['new', 'subscribe'], ['old', 'detach'],
|
||||||
|
]);
|
||||||
|
owner.stop(); owner.disconnect();
|
||||||
|
assert.deepEqual(events.at(-1), ['new', 'unsubscribe']);
|
||||||
|
const count = events.length;
|
||||||
|
owner.attachDataManager(manager('late'));
|
||||||
|
owner.stop(); owner.disconnect();
|
||||||
|
assert.equal(events.length, count);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('camera-entry listeners are removed before any asynchronous layer cleanup', (t) => {
|
||||||
|
const { owner } = bindings(t);
|
||||||
|
let changes = 0;
|
||||||
|
let trackedCallback;
|
||||||
|
owner.viewer = { trackedEntityChanged: {
|
||||||
|
addEventListener(callback) {
|
||||||
|
trackedCallback = callback;
|
||||||
|
return () => { changes += 1; };
|
||||||
|
},
|
||||||
|
} };
|
||||||
|
owner._stampNavigation = () => { changes += 100; };
|
||||||
|
owner.observeCamera();
|
||||||
|
owner.stop();
|
||||||
|
trackedCallback({});
|
||||||
|
owner.stop();
|
||||||
|
assert.equal(changes, 1);
|
||||||
|
});
|
||||||
@@ -36,6 +36,123 @@ const DETECTION_ALLOCATION_STORAGE_KEY = 'gev:detection-allocation:v1';
|
|||||||
|
|
||||||
/** Own visual preferences, detection overrides and display-control state. */
|
/** Own visual preferences, detection overrides and display-control state. */
|
||||||
export class VisualSettings {
|
export class VisualSettings {
|
||||||
|
async restoreShareState(state) {
|
||||||
|
const {
|
||||||
|
setScopeMaskEnabled,
|
||||||
|
setScopeMaskFeather,
|
||||||
|
setScopeTerminusOverride,
|
||||||
|
clampScopeTerminusPct,
|
||||||
|
} = this.services;
|
||||||
|
const {
|
||||||
|
style,
|
||||||
|
bloom,
|
||||||
|
sharpen,
|
||||||
|
bloomIntensity,
|
||||||
|
bloomVersion,
|
||||||
|
sharpenIntensity,
|
||||||
|
hudVariant,
|
||||||
|
hudVisible,
|
||||||
|
detectionMode,
|
||||||
|
detectionDensity,
|
||||||
|
detectionAllocation,
|
||||||
|
detectionFadePct,
|
||||||
|
detectionOutsideOpacityPct,
|
||||||
|
celestialRing,
|
||||||
|
scopeEnabled,
|
||||||
|
scopeFeatherPct,
|
||||||
|
scopeTerminusPct,
|
||||||
|
mapStack,
|
||||||
|
panelState,
|
||||||
|
styleParams,
|
||||||
|
} = state || {};
|
||||||
|
// Ignore the retired 'ai-edit' style from older share links.
|
||||||
|
if (style && style !== 'normal' && style !== 'ai-edit') {
|
||||||
|
this.setStyle(style, {
|
||||||
|
applyPreset: true,
|
||||||
|
revealParameters: false,
|
||||||
|
restore: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (styleParams && style && this.stages[style] && STYLES[style]?.uniforms) {
|
||||||
|
for (const [uniformName, uniformValue] of Object.entries(styleParams)) {
|
||||||
|
if (!Object.hasOwn(STYLES[style].uniforms, uniformName)) continue;
|
||||||
|
this.stages[style].uniforms[uniformName] = uniformValue;
|
||||||
|
}
|
||||||
|
this._updateSliderPanel(style, { reveal: false });
|
||||||
|
}
|
||||||
|
if (typeof bloomIntensity === 'number' && this._bloomSlider) {
|
||||||
|
const intensity = decodeBloomIntensity(bloomIntensity, bloomVersion);
|
||||||
|
this._setBloomIntensity(intensity, { syncShare: false });
|
||||||
|
}
|
||||||
|
if (typeof sharpenIntensity === 'number' && this._sharpenSlider) {
|
||||||
|
const pct = Math.max(0, Math.min(100, Math.round(sharpenIntensity)));
|
||||||
|
this._sharpenSlider.value = String(pct);
|
||||||
|
this._sharpenSliderValue.textContent = `${pct}%`;
|
||||||
|
this._applySharpenIntensity(pct / 100);
|
||||||
|
}
|
||||||
|
if (typeof bloom === 'boolean') this._setBloomEnabled(bloom);
|
||||||
|
if (typeof sharpen === 'boolean') this._setSharpenEnabled(sharpen);
|
||||||
|
if (hudVariant) this._setHudVariant(hudVariant);
|
||||||
|
if (typeof hudVisible === 'boolean') {
|
||||||
|
this.hud.setMode(hudVisible ? 'on' : 'off');
|
||||||
|
this._updateHudButtonState();
|
||||||
|
}
|
||||||
|
if (typeof detectionDensity === 'number' && this._detectionDensitySlider) {
|
||||||
|
const pct = canonicalizeDensity(detectionDensity);
|
||||||
|
this._detectionDensitySlider.value = String(pct);
|
||||||
|
this._detectionDensityValue.textContent = `${pct}%`;
|
||||||
|
this._applyDetectionDensityFromUi();
|
||||||
|
}
|
||||||
|
if (detectionAllocation) {
|
||||||
|
this._setDetectionAllocation(detectionAllocation, {
|
||||||
|
syncShare: false,
|
||||||
|
persist: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof detectionFadePct === 'number' && this._detectionFadeSlider) {
|
||||||
|
this._detectionFadeSlider.value = String(detectionFadePct);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof detectionOutsideOpacityPct === 'number' &&
|
||||||
|
this._detectionOpacitySlider
|
||||||
|
) {
|
||||||
|
this._detectionOpacitySlider.value = String(detectionOutsideOpacityPct);
|
||||||
|
}
|
||||||
|
this._applyDetectionFadeFromUi();
|
||||||
|
if (detectionMode) this._setDetectionMode(detectionMode);
|
||||||
|
if (typeof celestialRing === 'boolean') {
|
||||||
|
this.setCelestialRingEnabled(celestialRing, {
|
||||||
|
syncShare: false,
|
||||||
|
focus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof scopeEnabled === 'boolean') {
|
||||||
|
setScopeMaskEnabled(scopeEnabled);
|
||||||
|
this._scopeBtn?.classList.toggle('active', scopeEnabled);
|
||||||
|
this._scopeBtn?.setAttribute('aria-pressed', String(scopeEnabled));
|
||||||
|
}
|
||||||
|
if (typeof scopeFeatherPct === 'number' && this._scopeFeatherSlider) {
|
||||||
|
const pct = Math.max(0, Math.min(100, Math.round(scopeFeatherPct)));
|
||||||
|
this._scopeFeatherSlider.value = String(pct);
|
||||||
|
if (this._scopeFeatherValue)
|
||||||
|
this._scopeFeatherValue.textContent = `${pct}%`;
|
||||||
|
setScopeMaskFeather(pct / 100);
|
||||||
|
}
|
||||||
|
// null restores the altitude-adaptive ramp; a number pins the terminus
|
||||||
|
// (clamped to the supported 94..100 band, same as the `sce` hash key).
|
||||||
|
if (scopeTerminusPct === null) setScopeTerminusOverride(null);
|
||||||
|
else if (typeof scopeTerminusPct === 'number') {
|
||||||
|
const pinned = clampScopeTerminusPct(scopeTerminusPct);
|
||||||
|
setScopeTerminusOverride(pinned == null ? null : pinned / 100);
|
||||||
|
}
|
||||||
|
const mapStackRestore = mapStack
|
||||||
|
? this._setMapStack(mapStack, { syncShare: false })
|
||||||
|
: Promise.resolve();
|
||||||
|
if (panelState) this._restorePanelState(panelState);
|
||||||
|
await mapStackRestore;
|
||||||
|
this._syncShareState();
|
||||||
|
}
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
viewer,
|
viewer,
|
||||||
services,
|
services,
|
||||||
|
|||||||
Reference in new issue
Block a user