refactor: separate application shell state and lifecycle owners

This commit is contained in:
Sameh Khamis committed 2026-09-15 11:17:10 -07:00
1 parent 81b82da056
commit bfefa875e1
20 files changed
+3510 -2481

No files matched your search

+4
View File
@@ -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.
+8
View File
@@ -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,
+35
View File
@@ -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.
+14 -2
View File
@@ -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",
+3 -2
View File
@@ -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,
+8 -3
View File
@@ -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,
+4 -2
View File
@@ -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(
+1 -1
View File
@@ -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)/);
});
+2 -1
View File
@@ -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.
+8 -4
View File
@@ -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\(\)/);
});
+18
View File
@@ -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,
+83
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+408
View File
@@ -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;
}
}
+216
View File
@@ -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;
}
}
+267
View File
@@ -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;
}
}
+428
View File
@@ -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
+135
View File
@@ -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);
});
+117
View File
@@ -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,