mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge pull request #599 from bilawalsidhu/refactor/application-shell-owners
refactor: separate application shell state and lifecycle owners
This commit is contained in:
20 files changed
+3510
-2481
No files matched your search
@@ -1,5 +1,9 @@
|
||||
# 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
|
||||
it. Preserve authored content, project files, camera/layer behavior and source
|
||||
attribution; document the planned timeline, scene-file and data-pack boundaries.
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 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,
|
||||
storage and scene content. The existing scene controller supplies an adapter for
|
||||
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/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/locationNavigation.js",
|
||||
"src/ui/cockpitCoordinator.js",
|
||||
"src/ui/cameraOrientationControls.js",
|
||||
"src/ui/applicationShortcuts.js",
|
||||
"src/ui/cctv.js",
|
||||
@@ -574,7 +576,11 @@
|
||||
"src/ui/visualPresets.js",
|
||||
"src/ui/visualSettings.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"]
|
||||
},
|
||||
@@ -1556,6 +1562,8 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/locationNavigation.js",
|
||||
"src/ui/cockpitCoordinator.js",
|
||||
"src/ui/cameraOrientationControls.js",
|
||||
"src/ui/applicationShortcuts.js",
|
||||
"src/ui/cctv.js",
|
||||
@@ -1700,7 +1708,11 @@
|
||||
"src/sources/overpassFeaturesRecords.js",
|
||||
"src/sources/overpassFeatures.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": [
|
||||
"@mapbox/vector-tile",
|
||||
|
||||
@@ -2171,7 +2171,8 @@ try {
|
||||
);
|
||||
|
||||
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;
|
||||
window.__qaCockpitReset = {
|
||||
calls: 0,
|
||||
@@ -2203,7 +2204,7 @@ try {
|
||||
const awareness = gev.dataManager.layers.get('military-awareness')?.module;
|
||||
const subjectId = awareness?.getContextSnapshot?.()?.subject?.id || null;
|
||||
const height = gev.viewer.camera.positionCartographic?.height;
|
||||
gev.styleManager.resetToGlobeView = qa.original;
|
||||
gev.styleManager._locationNavigation.resetToGlobeView = qa.original;
|
||||
delete window.__qaCockpitReset;
|
||||
return {
|
||||
calls: qa.calls,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LayerBindings } from './ui/layerBindings.js';
|
||||
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
||||
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
||||
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');
|
||||
ordered(dispose, [
|
||||
'this._disposed = true;',
|
||||
'this._layerBindings.stop();',
|
||||
'this._navigation.destroy();',
|
||||
'this._removeWorldRequestFocusListener?.();',
|
||||
'await this._contextControls.restoreForDisposal();',
|
||||
], 'dispose invalidation');
|
||||
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', () => {
|
||||
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, '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._removeCctvRequestFocusListener?.();',
|
||||
'this._removeWorldRequestFocusListener?.();',
|
||||
'this._navigationOwnerChangedRemover?.();',
|
||||
'await this._contextControls.restoreForDisposal();',
|
||||
], 'synchronous teardown barrier');
|
||||
], 'camera bindings close synchronously');
|
||||
|
||||
const navigation = body(
|
||||
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(ui, /onEntered: \(\) => this\._panelChrome\.enterCockpit\(\)/);
|
||||
assert.match(ui, /onEntered: \(\) => this\.enterPanels\(\)/);
|
||||
assert.match(ui, /enterPanels: \(\) => this\._panelChrome\.enterCockpit\(\)/);
|
||||
const callback = { 1: shellMethod('enterCockpit').toString() };
|
||||
assert.ok(callback, 'Cockpit onEntered callback is missing');
|
||||
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',
|
||||
);
|
||||
|
||||
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() };
|
||||
assert.ok(exitCallback, 'Cockpit onExited callback is missing');
|
||||
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.match(ui, /contextRadioDetailsBtn/);
|
||||
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)/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 detectionButton = displayActions.slice(displayActions.indexOf('cycleDetection:'), displayActions.indexOf('toggleModels:'));
|
||||
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');
|
||||
assert.match(uiSource, /claimDetection: \(\) => \{\s*this\._visualSettings\._detectionUserOverridden = true;/);
|
||||
|
||||
// Style-switch semantics are unchanged: Normal is still not a preset owner,
|
||||
// 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 { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
||||
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 ');
|
||||
assert.ok(firstAwait > 0);
|
||||
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\._mapSourceControls\?\.destroy\(\)/);
|
||||
assert.match(synchronous, /this\._clearLayersControl\?\.destroy\(\)/);
|
||||
assert.match(synchronous, /this\._locationControls\?\.destroy\(\)/);
|
||||
assert.match(synchronous, /this\._locationLookup\?\.destroy\(\)/);
|
||||
assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/);
|
||||
assert.match(synchronous, /this\._locationNavigation\.destroy\(\)/);
|
||||
assert.match(LocationNavigation.prototype.destroy.toString(), /this\._locationControls\?\.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\._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 { CockpitCoordinator } from '../ui/cockpitCoordinator.js';
|
||||
import { LocationNavigation } from '../ui/locationNavigation.js';
|
||||
import { StyleManager } from '../ui/applicationShell.js';
|
||||
import { NavigationController } from '../ui/navigationController.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. */
|
||||
export function readShellSource() {
|
||||
return [
|
||||
'locationNavigation',
|
||||
'cockpitCoordinator',
|
||||
'navigationController',
|
||||
'shareRestoration',
|
||||
'visualSettings',
|
||||
'panelChrome',
|
||||
'aircraftDisplay',
|
||||
'layerBindings',
|
||||
'displayBindings',
|
||||
'shellFacade',
|
||||
'applicationShell',
|
||||
]
|
||||
.map((name) =>
|
||||
@@ -27,7 +39,13 @@ export function shellMethod(name) {
|
||||
ShareRestoration,
|
||||
VisualSettings,
|
||||
PanelChrome,
|
||||
LocationNavigation,
|
||||
CockpitCoordinator,
|
||||
AircraftDisplay,
|
||||
LayerBindings,
|
||||
DisplayBindings,
|
||||
StyleManager,
|
||||
ShellFacade,
|
||||
]) {
|
||||
const method = Object.getOwnPropertyDescriptor(
|
||||
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. */
|
||||
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({
|
||||
viewer,
|
||||
services,
|
||||
|
||||
Reference in new issue
Block a user