Merge pull request #406 from bilawalsidhu/refactor/cockpit-components

refactor: separate Cockpit camera and presentation components
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-13 03:15:42 -07:00
commit 8b5ad67dcb
30 files changed
+3331 -1987

No files matched your search

+3
View File
@@ -13,6 +13,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
- Split Cockpit camera/controller, instruments, briefing, signals and layout into focused modules with explicit application operations.
- Give Display portal moves cancellable focus/scroll restoration and stop Cockpit work before asynchronous UI teardown.
- Separate Context controls, mode transitions and layer restoration; release tab listeners and suppress late panel/search feedback after disposal.
- Separate camera-panel controls, frame loading, calibration editing and status display; cancel stale image and calibration work on camera changes or disposal.
+10
View File
@@ -310,3 +310,13 @@ subscriptions. Composition supplies the manager, installations search and
explicit visual/panel actions. `ui/context/policy` exposes the existing pure
mode and restoration rules. No source transport or renderer is imported by
these components; initial state and action results retain their existing shape.
## Cockpit controls
`ui/cockpit` supplies the Cockpit controller and Display portal. Camera updates,
instruments, Context readouts, briefings, signals, layout and input have separate
modules. Composition supplies the existing aircraft/awareness operations, terrain
cache and sampling operations, continuous-render owner and regional briefing
service. Pure math, utility layout and vision helpers have explicit exports.
Disposal releases subscriptions and pending work; portal moves preserve the
original Display groups, independent scroll positions and current focus owner.
+12
View File
@@ -1,5 +1,17 @@
# God's Eye View Current State
## Cockpit component ownership
Cockpit presentation is separated from its camera/controller behavior. Existing
layer, terrain and rendering operations are supplied by composition, retaining
the same tracked identity, ground acquisition, motion correction and cadence.
The Display portal owns group anchors, focus/scroll restoration and listeners.
Superseded portal frames cannot repaint old state or steal focus after disposal;
retained Cockpit actions cannot restart a disposed controller. Input, subscriptions
and queued panel work stop before asynchronous layer restoration; final camera
and portal cleanup follows that restoration.
## Context coordination
Context controls own Contacts/Space Missions state, entry and exit transactions,
+5 -1
View File
@@ -133,6 +133,10 @@
"./ui/radio": "./src/ui/radio.js",
"./ui/cctv": "./src/ui/cctv.js",
"./ui/context": "./src/ui/context.js",
"./ui/context/policy": "./src/contextModePolicy.js"
"./ui/context/policy": "./src/contextModePolicy.js",
"./ui/cockpit": "./src/ui/cockpit.js",
"./ui/cockpit/math": "./src/cockpitMath.js",
"./ui/cockpit/layout": "./src/cockpitUtilityLayout.js",
"./ui/cockpit/vision": "./src/cockpitVisionPolicy.js"
}
}
+18 -1
View File
@@ -191,5 +191,22 @@
"src/ui/contextPresentation.js",
"src/ui/contextSession.js",
"src/ui/contextSubscriptions.js",
"src/ui/contextTransactions.js"
"src/ui/contextTransactions.js",
"src/ui/cockpit.js",
"src/ui/cockpitBriefing.js",
"src/ui/cockpitCamera.js",
"src/ui/cockpitContext.js",
"src/ui/cockpitController.js",
"src/ui/cockpitController.test.mjs",
"src/ui/cockpitDisplayPortal.js",
"src/ui/cockpitDisplayPortal.test.mjs",
"src/ui/cockpitInput.js",
"src/ui/cockpitInstruments.js",
"src/ui/cockpitLayout.js",
"src/ui/cockpitPresentation.js",
"src/ui/cockpitSignals.js",
"src/ui/cockpitTrackingController.js",
"src/cockpitMath.js",
"src/cockpitUtilityLayout.js",
"src/cockpitVisionPolicy.js"
]
+27
View File
@@ -387,5 +387,32 @@
"src/ui/panelRailGeometry.js"
],
"external": []
},
"cockpit-controls": {
"exports": [
"./ui/cockpit",
"./ui/cockpit/math",
"./ui/cockpit/layout",
"./ui/cockpit/vision"
],
"modules": [
"src/ui/cockpit.js",
"src/ui/cockpitBriefing.js",
"src/ui/cockpitCamera.js",
"src/ui/cockpitContext.js",
"src/ui/cockpitController.js",
"src/ui/cockpitDisplayPortal.js",
"src/ui/cockpitInput.js",
"src/ui/cockpitInstruments.js",
"src/ui/cockpitLayout.js",
"src/ui/cockpitPresentation.js",
"src/ui/cockpitSignals.js",
"src/ui/cockpitTrackingController.js",
"src/cockpitMath.js",
"src/cockpitUtilityLayout.js",
"src/cockpitVisionPolicy.js",
"src/ui/panelRailGeometry.js"
],
"external": ["cesium"]
}
}
+68 -1
View File
@@ -75,6 +75,33 @@ try {
return unsubscribe?.();
};
}
const cockpit = ui.cockpitView;
const portal = ui._cockpitDisplayPortal;
const cockpitListenerCount = cockpit._listenerRemovers.length;
counts.cockpit = 0;
cockpit._listenerRemovers = cockpit._listenerRemovers.map(
(remove) => () => {
counts.cockpit++;
return remove();
},
);
const portalHomes = portal.records.map((record) => ({
group: record.group,
home: record.anchor.parentNode,
anchor: record.anchor,
}));
portal.setActive(true);
let releaseRestoration;
const restorationGate = new Promise((resolve) => {
releaseRestoration = resolve;
});
const restoreForDisposal = ui._contextControls.restoreForDisposal.bind(
ui._contextControls,
);
ui._contextControls.restoreForDisposal = async () => {
await restorationGate;
return restoreForDisposal();
};
const resizeHandler = ui._windowResizeHandler;
const removeEventListener = window.removeEventListener;
counts.resize = 0;
@@ -83,7 +110,21 @@ try {
return removeEventListener.call(this, type, callback, options);
};
try {
await ui.dispose();
const disposal = ui.dispose();
const focusBefore = document.activeElement;
const stoppedBeforeRestoration =
cockpit.destroyed &&
portal.stopped &&
cockpit._listenerRemovers.length === 0 &&
portal.frames.size === 0 &&
cockpit.enter() === false &&
cockpit.navigateContext(1) === false;
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
const noDeferredFocus = document.activeElement === focusBefore;
releaseRestoration();
await disposal;
const once = JSON.stringify(counts);
await ui.dispose();
window.dispatchEvent(new Event('resize'));
@@ -91,6 +132,23 @@ try {
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
return {
cockpitStoppedBeforeRestoration:
stoppedBeforeRestoration && noDeferredFocus,
cockpitReleased:
cockpit.disposed &&
cockpit.destroyed &&
counts.cockpit === cockpitListenerCount &&
cockpitListenerCount > 0 &&
portal.destroyed &&
portal.listeners.signal.aborted &&
portal.frames.size === 0 &&
ui._cockpitDisplayPortal === null &&
portalHomes.length === 4 &&
portalHomes.every(
(record) =>
record.group.parentNode === record.home &&
!record.anchor.isConnected,
),
connected: Boolean(
cctvUnsubscribe &&
resizeHandler &&
@@ -116,6 +174,7 @@ try {
idempotent: once === JSON.stringify(counts),
};
} finally {
releaseRestoration();
window.removeEventListener = removeEventListener;
}
});
@@ -143,6 +202,14 @@ try {
'Radio, Location and CCTV controls are disposed with the UI',
result.controlsReleased,
);
check(
'Cockpit actions and queued portal work stop before layer restoration',
result.cockpitStoppedBeforeRestoration,
);
check(
'Cockpit cleanup releases listeners and returns all Display groups home',
result.cockpitReleased,
);
check('repeated UI disposal is inert', result.idempotent);
check(
'disposal and subsequent resize produce no uncaught browser errors',
+10 -15
View File
@@ -1,3 +1,5 @@
import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js';
import { CockpitViewController } from './ui/cockpitController.js';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
@@ -29,11 +31,7 @@ function ordered(source, needles, label) {
}
test('Cockpit takeover invalidates deferred work before camera cancellation', () => {
const enter = body(
ui,
/enter\(\) \{([\s\S]*?)\n \}\n\n exit\(/,
'Cockpit enter',
);
const enter = cockpitEnter.toString();
ordered(enter, [
'if (!info || !entity?.position) return false;',
'this.onCameraTakeover?.();',
@@ -131,21 +129,18 @@ test('voice Cockpit next/previous shares the manual Context navigation path', ()
// is what stamps. Divergence here is how a voice-only camera path escapes
// the arbiter.
assert.match(
ui,
/this\._listen\(this\.contextPrevious, 'click', \(\) => this\.navigateContext\(-1, \{ origin: 'user' \}\)\);/,
CockpitViewController.toString(),
/this\._listen\(this\.contextPrevious, 'click', \(\) =>\s*this\.navigateContext\(-1, \{ origin: 'user' \}\),?\s*\);/,
);
assert.match(
ui,
/this\._listen\(this\.contextNext, 'click', \(\) => this\.navigateContext\(1, \{ origin: 'user' \}\)\);/,
);
const funnel = body(
ui,
/navigateContext\(direction, options = \{\}\) \{([\s\S]*?)\n \}\n\n \/\*\* Adopt/,
'Cockpit Context navigation funnel',
CockpitViewController.toString(),
/this\._listen\(this\.contextNext, 'click', \(\) =>\s*this\.navigateContext\(1, \{ origin: 'user' \}\),?\s*\);/,
);
const funnel = navigateContext.toString();
assert.match(funnel, /const navigationOptions = wasActive\s*\? \{ \.\.\.options, aircraftOnly: true \}\s*: options;/);
ordered(funnel, [
"const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';",
'const navigationOptions = wasActive ? { ...options, aircraftOnly: true } : options;',
'const navigationOptions = wasActive',
'militaryAwarenessLayer?.[method]?.(navigationOptions)',
'this._adoptTrackedEntity(performance.now());',
], 'Cockpit Context navigation funnel');
+77 -74
View File
@@ -1,3 +1,11 @@
import { isRenderedOnScreen } from './ui/cockpitPresentation.js';
import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js';
import { enter as cockpitEnter, exit as cockpitExit, readAircraftInfo, _adoptTrackedEntity } from './ui/cockpitTrackingController.js';
import { setVisionMode, cycleVisionMode } from './ui/cockpitInstruments.js';
import { updateContext as cockpitContext } from './ui/cockpitContext.js';
import { syncSignalLayout, setContextCollapsed, setSignalCollapsed } from './ui/cockpitLayout.js';
import { setBriefAutoRotate } from './ui/cockpitBriefing.js';
import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js';
import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
import { clearSelectedLayers } from './ui/contextActions.js';
import { _selectContextMode } from './ui/contextTransactions.js';
@@ -72,9 +80,9 @@ test('Cockpit heading tape leaves the bottom exit row unobstructed', () => {
});
test('Cockpit vision cycle exposes exactly five real visual styles without NONE', () => {
assert.match(ui, /const modes = COCKPIT_VISION_MODES;/);
assert.match(ui, /const labels = \{ optical: inherited, crt: 'CRT', nvg: 'NVG', thermal: 'FLIR', noir: 'NOIR' \};/);
assert.doesNotMatch(ui, /none: 'NONE'/);
assert.match(cycleVisionMode.toString(), /const modes = COCKPIT_VISION_MODES;/);
assert.match(setVisionMode.toString(), /const labels = \{\s*optical: inherited,\s*crt: 'CRT',\s*nvg: 'NVG',\s*thermal: 'FLIR',\s*noir: 'NOIR',?\s*\};/);
assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/);
assert.match(ui, /getInheritedVisionLabel: \(\) => \([\s\S]*?STYLE_STATUS_LABELS\[this\.activeStyle\]/);
assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/);
assert.match(ui, /const target = applyCockpitVisionStageIntensities\(this\.stages, next, this\._cockpitVisionRestore\);/);
@@ -99,20 +107,20 @@ test('Contacts uses the approved radar icon', () => {
});
test('Cockpit Escape handling precedes form-control shortcut suppression and focus is restored', () => {
const keydown = ui.match(/onKeyDown\(event\) \{([\s\S]*?)\n \}\n\n enter\(\)/);
const keydown = cockpitKeyDown.toString();
assert.ok(keydown, 'Cockpit keyboard handler is missing');
const escapeIndex = keydown[1].indexOf("event.key === 'Escape'");
const formGuardIndex = keydown[1].indexOf("closest?.('input, textarea, select, [contenteditable]')");
const escapeIndex = keydown.indexOf("event.key === 'Escape'");
const formGuardIndex = keydown.indexOf("closest?.('input, textarea, select, [contenteditable]')");
assert.ok(escapeIndex >= 0 && formGuardIndex > escapeIndex, 'Escape must work while focus is inside a form control');
const enter = ui.match(/\n enter\(\) \{([\s\S]*?)\n \}\n\n exit\(/);
const exit = ui.match(/\n exit\(\{ restoreTracking = true \} = \{\}\) \{([\s\S]*?)\n \}\n\n update\(\)/);
const enter = cockpitEnter.toString();
const exit = cockpitExit.toString();
assert.ok(enter && exit, 'Cockpit entry/exit methods are missing');
assert.match(enter[1], /activeElement/);
assert.match(enter[1], /mapViewButton.*focus|focus.*mapViewButton/s);
assert.match(exit[1], /focus\(\{ preventScroll: true \}\)/);
assert.match(exit[1], /this\.restoreTrackingFrame\(entity\)/);
assert.doesNotMatch(exit[1], /applyTrackedCameraFrame/);
assert.match(enter, /activeElement/);
assert.match(enter, /mapViewButton.*focus|focus.*mapViewButton/s);
assert.match(exit, /focus\(\{ preventScroll: true \}\)/);
assert.match(exit, /this\.restoreTrackingFrame\(entity\)/);
assert.doesNotMatch(exit, /applyTrackedCameraFrame/);
assert.match(
ui,
/restoreTrackingFrame: \(entity\) => \{[\s\S]*?gevTrackedId[\s\S]*?flightsLayer\.refocusTrackedById[\s\S]*?militaryFlightsLayer\.refocusTrackedById/,
@@ -121,24 +129,22 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
});
test('Cockpit shortcut failures do not leak and open Radio owns the first Escape', () => {
const keydown = ui.match(/onKeyDown\(event\) \{([\s\S]*?)\n \}\n\n enter\(\)/);
const keydown = cockpitKeyDown.toString();
assert.ok(keydown, 'Cockpit keyboard handler is missing');
assert.match(keydown[1], /document\.getElementById\('context-radio-dock'\)\?\.classList\.contains\('disclosure-open'\)/);
assert.match(keydown[1], /#cockpit-utility-controls \[aria-expanded="true"\]/);
assert.match(keydown, /document\s*\.getElementById\('context-radio-dock'\)\s*\?\.classList\.contains\('disclosure-open'\)/);
assert.match(keydown, /#cockpit-utility-controls \[aria-expanded="true"\]/);
assert.match(
keydown[1],
/const cockpitAttempt = !!\(this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
keydown,
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
);
});
test('the Contact panel never hides itself out from under its own NEXT button', () => {
const updateContext = ui.match(/\n updateContext\(info, heading\) \{([\s\S]*?)\n \}\n\n scheduleContextLayout\(\)/);
const updateContext = cockpitContext.toString();
assert.ok(updateContext, 'Cockpit updateContext is missing');
const body = updateContext[1];
// ui.js cannot be imported under node (Cesium's `mgrs` dependency), so the
// decision lives in cockpitMath.resolveCockpitContextReadout and this pins
// ui.js to it. The panel hosts PREVIOUS/NEXT: the only reason it may hide is
// that no snapshot exists at all.
const body = updateContext;
// Pin the real presentation owner to the readout policy: PREVIOUS/NEXT
// remains reachable unless no Context snapshot exists at all.
assert.match(body, /resolveCockpitContextReadout\(\{ snapshot, info \}\)/);
assert.match(body, /if \(!readout\.visible\) \{[\s\S]*?this\.context\.hidden = true;/);
assert.equal(
@@ -177,13 +183,13 @@ test('the Contact panel never hides itself out from under its own NEXT button',
});
test('the cockpit reads its aircraft from the layer that owns Cesium tracking', () => {
const read = ui.match(/\n readAircraftInfo\(\) \{([\s\S]*?)\n \}/);
const read = readAircraftInfo.toString();
assert.ok(read, 'readAircraftInfo is missing');
assert.match(read[1], /resolveTrackedAircraftInfo\(\{/);
assert.match(read[1], /gevTrackedId/);
assert.match(read, /resolveTrackedAircraftInfo\(\{/);
assert.match(read, /gevTrackedId/);
// In cockpit mode the controller moves the entity off viewer.trackedEntity,
// so its own handle is the tracked identity there.
assert.match(read[1], /this\.viewer\?\.trackedEntity \|\| this\.trackedEntity/);
assert.match(read, /this\.viewer\?\.trackedEntity \|\| this\.trackedEntity/);
});
test('programmatic Context layer changes cannot bypass explicit expansion policy', () => {
@@ -278,8 +284,8 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
);
assert.match(css, /\.cockpit-utility-controls\.layout-primary-only[\s\S]*?display:\s*none/);
assert.match(css, /@media \(max-width:\s*760px\)[\s\S]*?\.cockpit-utility-controls:has\(\.cockpit-utility-control\.is-expanded\)[\s\S]*?display:\s*none/);
assert.match(ui, /resolveCockpitUtilityLayout\(\{ availableHeight, expandedHeight, collapsedHeight \}\)/);
assert.match(ui, /setAttribute\('aria-hidden', String\(hiddenSibling\)\)/);
assert.match(syncSignalLayout.toString(), /resolveCockpitUtilityLayout\(\{\s*availableHeight,\s*expandedHeight,\s*collapsedHeight,?\s*\}\)/);
assert.match(syncSignalLayout.toString(), /setAttribute\('aria-hidden', String\(hiddenSibling\)\)/);
});
test('Display orders 3D above Celestial, Clean UI below it, and Parameters below Detection', () => {
@@ -317,11 +323,12 @@ test('Clear Selected Layers uses one adopted batch and discards Context restorat
});
test('Cockpit Display portal retains both scroll owners across round trips', () => {
assert.match(ui, /this\._standardDisplayScrollTop = this\._ppToggles\?\.scrollTop \|\| 0/);
assert.match(ui, /this\._cockpitDisplayScrollTop = this\._cockpitDisplayPanel\?\.scrollTop \|\| 0/);
assert.match(ui, /if \(!this\._cockpitDisplayPortalActive\)[\s\S]*?this\._standardDisplayScrollTop = this\._ppToggles\?\.scrollTop/);
assert.match(ui, /if \(this\._cockpitDisplayPortalActive\)[\s\S]*?this\._cockpitDisplayScrollTop = this\._cockpitDisplayPanel\?\.scrollTop/);
assert.match(ui, /this\._cockpitDisplayPanel\.scrollTop = this\._cockpitDisplayScrollTop[\s\S]*?this\._ppToggles\.scrollTop = this\._standardDisplayScrollTop/);
const portal = CockpitDisplayPortal.toString();
assert.match(portal, /this\.standardScrollTop = standardPanel\?\.scrollTop \|\| 0/);
assert.match(portal, /this\.cockpitScrollTop = cockpitPanel\?\.scrollTop \|\| 0/);
assert.match(portal, /if \(!this\.active\)[\s\S]*?this\.standardScrollTop = standardPanel\.scrollTop/);
assert.match(portal, /if \(this\.active\)[\s\S]*?this\.cockpitScrollTop = cockpitPanel\.scrollTop/);
assert.match(portal, /this\.cockpitPanel\.scrollTop = this\.cockpitScrollTop[\s\S]*?this\.standardPanel\.scrollTop = this\.standardScrollTop/);
});
test('Cockpit side surfaces behave as two single-expanded accordions', () => {
@@ -343,16 +350,16 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => {
/nextCollapsed && this\.cockpitView\?\.active && panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
'closing Data Layers must restore Contact only after an automatic collapse',
);
assert.match(ui,
assert.match(setContextCollapsed.toString(),
/const wasCollapsed = this\.contextCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.contextCollapsed[\s\S]*?'gev:cockpit-context-expanded'/,
'Contact expansion must notify only on a collapsed-to-expanded transition',
);
assert.match(ui,
assert.match(setSignalCollapsed.toString(),
/const wasCollapsed = this\.signalCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.signalCollapsed[\s\S]*?'gev:cockpit-signal-expanded'/,
'Live Signals expansion must notify only on a collapsed-to-expanded transition',
);
assert.match(
ui,
setSignalCollapsed.toString(),
/setSignalCollapsed\(collapsed, \{ user = false \} = \{\}\)[\s\S]*?if \(user\) this\.signalUserCollapsed = this\.signalCollapsed/,
);
assert.match(
@@ -473,8 +480,8 @@ test('Reset releases Contact camera ownership through its selection-preserving r
assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/);
assert.match(controls, /for \(const button of elements\.resetButtons\)[\s\S]*?this\.bind\(button, 'click', onReset\)/,
'both reset controls delegate to the same supplied reset action');
assert.match(ui, /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/);
assert.match(ui, /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/);
assert.match(cockpitEnter.toString(), /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/);
assert.match(readRadioSource(new URL('./ui/cockpitTrackingController.js', import.meta.url), 'utf8'), /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/);
});
test('Location navigation releases immediate routes before flight and deferred routes after resolution', () => {
@@ -536,28 +543,28 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
'the right margin must not borrow the left accordion corridor: it is solved '
+ 'against left-lane obstacles and put the strip through the briefing card',
);
const signalLayout = ui.match(/syncSignalLayout\(\) \{([\s\S]*?)\n \}\n\n dispose\(\)/);
const signalLayout = syncSignalLayout.toString();
assert.ok(signalLayout, 'Cockpit signal layout method is missing');
assert.match(
signalLayout[1],
/setProperty\('--cockpit-utility-top', `\$\{utilityAnchor\.top\.toFixed\(1\)\}px`\)/,
signalLayout,
/setProperty\(\s*'--cockpit-utility-top',\s*`\$\{utilityAnchor\.top\.toFixed\(1\)\}px`,?\s*\)/,
'Cockpit owns the utility strip anchor and republishes it every layout tick',
);
assert.match(
signalLayout[1],
signalLayout,
/resolveCockpitUtilityAnchor\(\{[\s\S]*?recBottom: recBounds \? recBounds\.bottom : 0,[\s\S]*?signalTop: signalBounds\.top,[\s\S]*?stripHeight: utilityBounds\.height,/,
'the strip hangs off the REC readout and is clamped by the briefing card it shares the margin with',
);
assert.match(signalLayout[1], /#intel-hud \.hud-top-right/);
assert.match(signalLayout, /#intel-hud \.hud-top-right/);
assert.match(
signalLayout[1],
/const recBounds = isRenderedOnScreen\(recReadout\) \? recReadout\.getBoundingClientRect\(\) : null;/,
signalLayout,
/const recBounds = isRenderedOnScreen\(recReadout\)\s*\? recReadout\.getBoundingClientRect\(\)\s*: null;/,
'HUD Off retires the Intel HUD with visibility/opacity, which leaves the REC '
+ 'readout a rect — a rect test alone would anchor the strip to an invisible readout',
);
assert.match(
ui,
/function isRenderedOnScreen\(element\) \{[\s\S]*?style\.display === 'none' \|\| style\.visibility === 'hidden' \|\| Number\(style\.opacity\) === 0[\s\S]*?rect\.width > 0 && rect\.height > 0;/,
isRenderedOnScreen.toString(),
/function isRenderedOnScreen\(element\) \{[\s\S]*?style\.display === 'none'\s*\|\|\s*style\.visibility === 'hidden'\s*\|\|\s*Number\(style\.opacity\) === 0[\s\S]*?rect\.width > 0 && rect\.height > 0;/,
);
assert.match(
ui,
@@ -566,12 +573,12 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
+ 'readout keeps its rect until the transition ends',
);
assert.doesNotMatch(
signalLayout[1],
signalLayout,
/Math\.max\(120,/,
'the 120px corridor floor can never be reached by a 107px strip and only hid the collision',
);
assert.match(signalLayout[1], /const availableHeight = utilityAnchor\.maxHeight;/);
assert.match(signalLayout[1], /cockpit-utility-controls[\s\S]*?--cockpit-utility-max-height/);
assert.match(signalLayout, /const availableHeight = utilityAnchor\.maxHeight;/);
assert.match(signalLayout, /cockpit-utility-controls[\s\S]*?--cockpit-utility-max-height/);
assert.match(
css,
/\.cockpit-utility-controls\s*\{[\s\S]*?top:\s*var\(--cockpit-utility-top, var\(--left-stack-safe-top, var\(--left-stack-top\)\)\)/,
@@ -637,16 +644,16 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
);
assert.doesNotMatch(ui, /\['presets',/);
assert.match(
ui,
/group\.before\(anchor\)[\s\S]*?window\.addEventListener\('gev:cockpit-mode-changed', this\._cockpitDisplayModeHandler\)/,
CockpitDisplayPortal.toString(),
/group\.before\(anchor\)[\s\S]*?window\.addEventListener\(\s*'gev:cockpit-mode-changed'/,
);
assert.match(
ui,
/_setCockpitDisplayPortalActive\(active\) \{[\s\S]*?record\.slot\.append\(record\.group\)[\s\S]*?record\.anchor\.after\(record\.group\)/,
CockpitDisplayPortal.prototype.setActive.toString(),
/record\.slot\.append\(record\.group\)[\s\S]*?record\.anchor\.after\(record\.group\)/,
);
assert.match(
ui,
/window\.removeEventListener\('gev:cockpit-mode-changed', this\._cockpitDisplayModeHandler\)[\s\S]*?_setCockpitDisplayPortalActive\(false\)[\s\S]*?record\.anchor\.remove\(\)/,
CockpitDisplayPortal.prototype.destroy.toString(),
/this\.stop\(\)[\s\S]*?record\.anchor\.after\(record\.group\)[\s\S]*?record\.anchor\.remove\(\)/,
);
assert.doesNotMatch(ui, /_cycleCockpitHud|_cockpitModels3dToggle|_cockpitDetectionToggle/);
assert.equal((html.match(/id="style-buttons"/g) || []).length, 1);
@@ -656,7 +663,7 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
assert.match(radioBindings, /closest\?\.\('\.cockpit-vision-controls'\)\) return;/);
assert.match(ui, /_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?aria-expanded'\) !== 'true'[\s\S]*?classList\.remove\('collapsed'\)/);
assert.match(radioBindings, /if \(displayOpen\) this\.actions\.revealStyleParameters\(\);/);
assert.match(ui, /setVisionMode\(modes\[nextIndex\], \{ revealParameters: true \}\);/);
assert.match(cycleVisionMode.toString(), /setVisionMode\(modes\[nextIndex\], \{ revealParameters: true \}\);/);
assert.match(css, /\.cockpit-display-slot\s*\{[\s\S]*?display:\s*block;[\s\S]*?min-width:\s*0;/);
assert.match(css, /#cockpit-display-panel \.pp-toggle-group\s*\{\s*width:\s*100%/);
assert.match(css, /#cockpit-display-panel \.pp-toggle-group\s*\{[\s\S]*?min-width:\s*0;/);
@@ -694,14 +701,12 @@ test('cockpit route direction uses self-contained vector artwork, not a font lig
});
test('cockpit aircraft handoff invalidates the prior world-position anchor', () => {
const match = ui.match(
/_adoptTrackedEntity\(nowMs, suppliedInfo = null\) \{([\s\S]*?)\n \}\n\n setVisionMode/,
);
const match = _adoptTrackedEntity.toString();
assert.ok(match, 'cockpit tracked-entity handoff block is missing');
assert.match(match[1], /this\.viewer\.trackedEntity = undefined;/);
assert.match(match[1], /this\.cockpitAnchorValid = false;/);
assert.match(match[1], /this\.heading = normalizeHeading\(info\.track \?\? 0\);/);
assert.match(match[1], /this\.lastFrameMs = nowMs;/);
assert.match(match, /this\.viewer\.trackedEntity = undefined;/);
assert.match(match, /this\.cockpitAnchorValid = false;/);
assert.match(match, /this\.heading = normalizeHeading\(info\.track \?\? 0\);/);
assert.match(match, /this\.lastFrameMs = nowMs;/);
});
test('cockpit weather control is off before JavaScript restores an explicit opt-in', () => {
@@ -726,7 +731,7 @@ test('cockpit summary presents the focused item as Contact', () => {
assert.match(match[0], /aria-label="Next — nearest unvisited contact in the 250 km window"/);
assert.match(match[0], /aria-label="Collapse Contact panel"/);
assert.doesNotMatch(match[0], />GLOBAL CONTEXT</);
assert.match(ui, /`\$\{expanded \? 'Collapse' : 'Expand'\} Contact panel`/);
assert.match(setContextCollapsed.toString(), /`\$\{expanded \? 'Collapse' : 'Expand'\} Contact panel`/);
});
test('Cockpit Contact navigation omits the redundant Focus camera action', () => {
@@ -776,14 +781,12 @@ test('cockpit briefing cycle control keeps its state as the accessible name', ()
assert.match(match[0], />CYCLE OFF<\/button>/);
assert.match(match[0], /title="Cycle briefing pages automatically every 9 seconds \(Signals → News → Local\)\./);
const update = ui.match(
/setBriefAutoRotate\(enabled\) \{([\s\S]*?)\n \}\n\n startBriefRotation/,
);
const update = setBriefAutoRotate.toString();
assert.ok(update, 'cockpit briefing cycle state updater is missing');
assert.match(update[1], /const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/);
assert.match(update[1], /setAttribute\('aria-label', label\)/);
assert.match(update[1], /\.title = help;/);
assert.doesNotMatch(update[1], /setAttribute\('aria-label', help\)/);
assert.match(update, /const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/);
assert.match(update, /setAttribute\('aria-label', label\)/);
assert.match(update, /\.title = help;/);
assert.doesNotMatch(update, /setAttribute\('aria-label', help\)/);
});
test('voice Cockpit entry honours a requested contact layer before it enters', () => {
+76 -36
View File
@@ -25,8 +25,13 @@ export function slewHeading(current, target, maxStepDeg) {
* becoming a first-person surge or reversal while still removing drift.
*/
export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
if (!Number.isFinite(distanceM) || distanceM <= 0
|| !Number.isFinite(dtSec) || dtSec <= 0) return 0;
if (
!Number.isFinite(distanceM) ||
distanceM <= 0 ||
!Number.isFinite(dtSec) ||
dtSec <= 0
)
return 0;
const dt = Math.min(0.1, dtSec);
const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0;
const eased = distanceM * (1 - Math.exp(-1.25 * dt));
@@ -36,14 +41,21 @@ export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
/** Return whether a throttled cockpit presentation update is due. */
export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) {
if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0) return false;
if (!Number.isFinite(lastUpdateMs) || lastUpdateMs <= 0 || nowMs < lastUpdateMs) return true;
if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0)
return false;
if (
!Number.isFinite(lastUpdateMs) ||
lastUpdateMs <= 0 ||
nowMs < lastUpdateMs
)
return true;
return nowMs - lastUpdateMs >= intervalMs;
}
/** Return whether the bounded rendered-surface acquisition window has elapsed. */
export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) {
if (![nowMs, startedMs, timeoutMs].every(Number.isFinite) || timeoutMs < 0) return true;
if (![nowMs, startedMs, timeoutMs].every(Number.isFinite) || timeoutMs < 0)
return true;
return nowMs - startedMs >= timeoutMs;
}
@@ -51,7 +63,11 @@ export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) {
* Keep the cockpit camera above the shared rendered-surface floor.
* Unknown floors preserve the proposed camera height.
*/
export function cockpitGroundSafeHeight(proposedHeightM, groundHeightM, clearanceM) {
export function cockpitGroundSafeHeight(
proposedHeightM,
groundHeightM,
clearanceM,
) {
if (!Number.isFinite(proposedHeightM)) return proposedHeightM;
if (!Number.isFinite(groundHeightM)) return proposedHeightM;
const clearance = Number.isFinite(clearanceM) ? Math.max(0, clearanceM) : 0;
@@ -68,36 +84,51 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) {
}
/** Format the cockpit Context scope without overstating installation coverage. */
export function formatCockpitContextScope(subjectLabel, radiusM, installationCoverage = null) {
const normalizedLabel = typeof subjectLabel === 'string'
? subjectLabel.trim() || '—'
: Number.isFinite(subjectLabel) ? String(subjectLabel) : '—';
const radiusKm = Number.isFinite(radiusM) && radiusM >= 0
? String(Math.round(radiusM / 1000))
: '—';
const coverage = typeof installationCoverage === 'string'
? installationCoverage.trim()
: '';
export function formatCockpitContextScope(
subjectLabel,
radiusM,
installationCoverage = null,
) {
const normalizedLabel =
typeof subjectLabel === 'string'
? subjectLabel.trim() || '—'
: Number.isFinite(subjectLabel)
? String(subjectLabel)
: '—';
const radiusKm =
Number.isFinite(radiusM) && radiusM >= 0
? String(Math.round(radiusM / 1000))
: '—';
const coverage =
typeof installationCoverage === 'string' ? installationCoverage.trim() : '';
const base = `${normalizedLabel} · ${radiusKm} KM AIR/SEA WINDOW`;
return coverage
? `${base} · INSTALLATIONS ${coverage}`
: base;
return coverage ? `${base} · INSTALLATIONS ${coverage}` : base;
}
/** Return seven 30-degree compass divisions centered on a heading. */
export function compassDivisions(heading) {
const center = Math.round(normalizeHeading(heading) / 30) * 30;
return [-90, -60, -30, 0, 30, 60, 90].map((offset) => normalizeHeading(center + offset));
return [-90, -60, -30, 0, 30, 60, 90].map((offset) =>
normalizeHeading(center + offset),
);
}
/** Format a compass division as a cardinal/intercardinal label or degrees. */
export function formatCompassDivision(heading) {
const normalized = normalizeHeading(heading);
const labels = new Map([
[0, 'N'], [45, 'NE'], [90, 'E'], [135, 'SE'],
[180, 'S'], [225, 'SW'], [270, 'W'], [315, 'NW'],
[0, 'N'],
[45, 'NE'],
[90, 'E'],
[135, 'SE'],
[180, 'S'],
[225, 'SW'],
[270, 'W'],
[315, 'NW'],
]);
return labels.get(normalized) || String(Math.round(normalized)).padStart(3, '0');
return (
labels.get(normalized) || String(Math.round(normalized)).padStart(3, '0')
);
}
/** Choose a readable altitude-tape interval for the current flight level. */
@@ -190,20 +221,22 @@ export function formatSpeedRulerTick(valueKt) {
/** Return the initial great-circle bearing between two latitude/longitude points. */
export function bearingBetweenCoordinates(fromLat, fromLon, toLat, toLon) {
if (![fromLat, fromLon, toLat, toLon].every(Number.isFinite)) return null;
const φ1 = fromLat * Math.PI / 180;
const φ2 = toLat * Math.PI / 180;
const Δλ = (toLon - fromLon) * Math.PI / 180;
const φ1 = (fromLat * Math.PI) / 180;
const φ2 = (toLat * Math.PI) / 180;
const Δλ = ((toLon - fromLon) * Math.PI) / 180;
const y = Math.sin(Δλ) * Math.cos(φ2);
const x = Math.cos(φ1) * Math.sin(φ2)
- Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
const x =
Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
if (Math.abs(x) < 1e-12 && Math.abs(y) < 1e-12) return null;
return normalizeHeading(Math.atan2(y, x) * 180 / Math.PI);
return normalizeHeading((Math.atan2(y, x) * 180) / Math.PI);
}
/** Return a target bearing relative to the current heading in the [-180, 180) range. */
export function relativeBearing(bearing, heading) {
if (!Number.isFinite(bearing) || !Number.isFinite(heading)) return null;
return ((normalizeHeading(bearing) - normalizeHeading(heading) + 540) % 360) - 180;
return (
((normalizeHeading(bearing) - normalizeHeading(heading) + 540) % 360) - 180
);
}
/**
@@ -229,10 +262,13 @@ export function resolveTrackedAircraftInfo({
if (civilian) candidates.push({ ...civilian, layerId: 'flights' });
if (military) candidates.push({ ...military, layerId: 'military' });
if (!candidates.length) return null;
const trackedKey = String(trackedId || '').trim().toLowerCase();
const trackedKey = String(trackedId || '')
.trim()
.toLowerCase();
if (trackedKey) {
const owner = candidates.find(
(candidate) => `${candidate.layerId}:${candidate.icao24}`.toLowerCase() === trackedKey,
(candidate) =>
`${candidate.layerId}:${candidate.icao24}`.toLowerCase() === trackedKey,
);
if (owner) return owner;
}
@@ -253,7 +289,10 @@ export function resolveTrackedAircraftInfo({
* aircraftRelative: boolean, contactLost: boolean}}
* Panel visibility plus the per-field rendering rules for this frame.
*/
export function resolveCockpitContextReadout({ snapshot = null, info = null } = {}) {
export function resolveCockpitContextReadout({
snapshot = null,
info = null,
} = {}) {
if (!snapshot) {
return {
visible: false,
@@ -264,14 +303,15 @@ export function resolveCockpitContextReadout({ snapshot = null, info = null } =
};
}
const trackedId = info?.icao24 ?? info?.id;
const subjectMatchesTracked = snapshot.subject?.layerId === info?.layerId
&& String(snapshot.subject?.id) === String(trackedId);
const subjectMatchesTracked =
snapshot.subject?.layerId === info?.layerId &&
String(snapshot.subject?.id) === String(trackedId);
// `subjectPresent` is absent on snapshots built before the presence signal
// existed; only an explicit `false` means the subject left its source.
const contactLost = snapshot.subjectPresent === false;
return {
visible: true,
mode: contactLost ? 'lost' : (subjectMatchesTracked ? 'tracked' : 'foreign'),
mode: contactLost ? 'lost' : subjectMatchesTracked ? 'tracked' : 'foreign',
subjectMatchesTracked,
// The nose-relative arrow and BRG readout are measured in the tracked
// aircraft's own frame; every other value in that row is measured from the
+9 -13
View File
@@ -1,14 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { afterEach } from 'node:test';
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
const source = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const renderStart = source.indexOf(' renderCockpitSignals() {');
const renderEnd = source.indexOf(' setContextCollapsed(', renderStart);
assert.ok(renderStart >= 0 && renderEnd > renderStart);
const clickSource = source.match(/this\._listen\(this\.signalList, 'click', (\(event\) => \{[\s\S]*?\n \})\);/)?.[1];
assert.ok(clickSource, 'the installed signal click route exists');
import { CockpitViewController } from './ui/cockpitController.js';
const previousDocument = globalThis.document;
afterEach(() => { globalThis.document = previousDocument; });
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
// object reference alone is insufficient if reconciliation disconnects it.
@@ -84,8 +80,9 @@ function fixture() {
document.body = new Node('body');
document.createElement = (tag) => new Node(tag);
document.activeElement = document.body;
const controller = new (new Function('document', 'formatAwarenessLabel',
`return class { ${source.slice(renderStart, renderEnd)} };`)(document, formatAwarenessLabel))();
globalThis.document = document;
const controller = Object.create(CockpitViewController.prototype);
controller.services = { formatAwarenessLabel };
controller.signalList = new Node('ol');
controller.signalToggle = new Node('button');
controller.briefTabs = [new Node('button'), new Node('button'), new Node('button')];
@@ -98,9 +95,8 @@ function fixture() {
const outside = new Node('button');
document.body.append(controller.signalToggle, controller.signalList, ...controller.briefTabs, display, radio, outside);
const selected = [];
const click = new Function('militaryAwarenessLayer', `return ${clickSource};`)({
focusTarget: (...args) => selected.push(args),
});
controller.services.militaryAwarenessLayer = { focusTarget: (...args) => selected.push(args) };
const click = event => controller.handleSignalClick(event);
const buttons = () => controller.signalList.querySelectorAll('button');
const tab = () => {
const nodes = document.body.querySelectorAll('button');
+4 -2
View File
@@ -62,7 +62,10 @@ export function resolveCockpitUtilityLayout({
gap = 7,
minimumExpandedHeight = 120,
}) {
const available = Math.max(minimumExpandedHeight, Number(availableHeight) || 0);
const available = Math.max(
minimumExpandedHeight,
Number(availableHeight) || 0,
);
const expanded = Math.max(0, Number(expandedHeight) || 0);
const collapsed = Math.max(0, Number(collapsedHeight) || 0);
const spacing = Math.max(0, Number(gap) || 0);
@@ -76,4 +79,3 @@ export function resolveCockpitUtilityLayout({
),
};
}
+7 -1
View File
@@ -1,4 +1,10 @@
export const COCKPIT_VISION_MODES = Object.freeze(['optical', 'crt', 'nvg', 'thermal', 'noir']);
export const COCKPIT_VISION_MODES = Object.freeze([
'optical',
'crt',
'nvg',
'thermal',
'noir',
]);
const TARGET_STYLE_BY_MODE = Object.freeze({
crt: 'retro',
+19 -11
View File
@@ -1,3 +1,4 @@
import { dispatchCockpitModeChanged, enter, exit, _adoptTrackedEntity } from '../ui/cockpitTrackingController.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -14,7 +15,6 @@ import militaryFlightsLayer, {
const SUBJECT = 'abc123';
const NEXT_SUBJECT = 'def456';
const UI_SOURCE = readFileSync(new URL('../ui.js', import.meta.url), 'utf8');
const FLIGHTS_SOURCE = readFileSync(new URL('./flights.js', import.meta.url), 'utf8');
const MILITARY_SOURCE = readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8');
@@ -84,16 +84,24 @@ function candidateIds(layer) {
}
test('Cockpit lifecycle publishes one normalized aircraft identity to both detection owners', () => {
const dispatcher = /dispatchCockpitModeChanged\(active, info = null\) \{[\s\S]*?\n \}/
.exec(UI_SOURCE)?.[0];
assert.ok(dispatcher, 'Cockpit event dispatcher is defined');
assert.match(dispatcher, /info\?\.icao24/);
assert.match(dispatcher, /\.trim\(\)\.toLowerCase\(\)/);
assert.match(dispatcher, /\['flights', 'military'\]\.includes\(info\?\.layerId\)/);
assert.match(dispatcher, /detail: \{ active: active === true, subjectId, layerId \}/);
assert.match(UI_SOURCE, /this\.dispatchCockpitModeChanged\(true, info\);/,
'entry and in-Cockpit handoff publish the active subject');
assert.match(UI_SOURCE, /this\.dispatchCockpitModeChanged\(false\);/,
const previousWindow = globalThis.window;
const details = [];
globalThis.window = { dispatchEvent: event => details.push(event.detail) };
try {
dispatchCockpitModeChanged(true, { icao24: ' ABC123 ', layerId: 'military' });
dispatchCockpitModeChanged(true, { icao24: ' DEF456 ', layerId: 'other' });
dispatchCockpitModeChanged(false);
assert.deepEqual(details, [
{ active: true, subjectId: 'abc123', layerId: 'military' },
{ active: true, subjectId: 'def456', layerId: null },
{ active: false, subjectId: null, layerId: null },
]);
} finally { globalThis.window = previousWindow; }
for (const action of [enter, _adoptTrackedEntity]) {
assert.match(action.toString(), /this\.dispatchCockpitModeChanged\(true, info\);/,
'entry and in-Cockpit handoff each publish the active subject');
}
assert.match(exit.toString(), /this\.dispatchCockpitModeChanged\(false\);/,
'exit clears the active subject');
for (const [name, source] of [
+2 -1
View File
@@ -1,3 +1,4 @@
import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js';
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
@@ -139,7 +140,7 @@ test('panel chrome wires Escape for every declared collapse target', () => {
});
test('Cockpit Escape collapses Contact or Live Signals before exiting Cockpit', () => {
const onKeyDown = method('onKeyDown', 'enter');
const onKeyDown = cockpitKeyDown.toString();
assert.match(
onKeyDown,
/event\.target\?\.closest\?\.\('\.cesium-credit-lightbox'\)[\s\S]*?return;/,
+49 -1831
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -0,0 +1,2 @@
export { CockpitViewController } from './cockpitController.js';
export { CockpitDisplayPortal } from './cockpitDisplayPortal.js';
+260
View File
@@ -0,0 +1,260 @@
/** Regional briefing requests, pages and rotation for the Cockpit controller. */
import {
COCKPIT_BRIEF_ROTATE_MS,
COCKPIT_BRIEF_CYCLE_OFF_HELP,
COCKPIT_BRIEF_CYCLE_ON_HELP,
COCKPIT_REGIONAL_REFRESH_MS,
COCKPIT_REGIONAL_REFRESH_DISTANCE_M,
COCKPIT_BRIEF_PAGES,
formatCockpitBriefAge,
formatCockpitWindDirection,
} from './cockpitPresentation.js';
export function showBriefPage(index, { manual = false } = {}) {
if (this.destroyed) return;
const count = COCKPIT_BRIEF_PAGES.length;
this.briefPageIndex = ((Number(index) % count) + count) % count;
const page = COCKPIT_BRIEF_PAGES[this.briefPageIndex];
this.briefPages.forEach((element) => {
element.hidden = element.dataset.cockpitBriefPage !== page.id;
});
this.briefTabs.forEach((button) => {
const current =
Number(button.dataset.cockpitBriefIndex) === this.briefPageIndex;
button.setAttribute('aria-current', current ? 'true' : 'false');
});
if (this.briefKicker) {
const indicator = this.briefKicker.querySelector('i');
this.briefKicker.replaceChildren(
...[indicator, document.createTextNode(` ${page.kicker}`)].filter(
Boolean,
),
);
}
if (this.briefSubtitle) this.briefSubtitle.textContent = page.subtitle;
if (this.briefPosition)
this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`;
if (this.briefSource) this.briefSource.textContent = page.source;
if (this.signalStream) this.signalStream.dataset.briefPage = page.id;
if (manual && this.briefAutoRotateEnabled)
this.startBriefRotation({ reset: true });
this.scheduleContextLayout();
}
export function setBriefAutoRotate(enabled) {
if (this.destroyed) return;
this.briefAutoRotateEnabled = Boolean(enabled);
if (this.briefAutoToggle) {
this.briefAutoToggle.setAttribute(
'aria-pressed',
String(this.briefAutoRotateEnabled),
);
const label = this.briefAutoRotateEnabled ? 'CYCLE ON' : 'CYCLE OFF';
this.briefAutoToggle.textContent = label;
const help = this.briefAutoRotateEnabled
? COCKPIT_BRIEF_CYCLE_ON_HELP
: COCKPIT_BRIEF_CYCLE_OFF_HELP;
this.briefAutoToggle.setAttribute('aria-label', label);
this.briefAutoToggle.title = help;
}
if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true });
else this.stopBriefRotation();
}
export function startBriefRotation({ reset = false } = {}) {
if (this.destroyed) return;
if (reset) this.stopBriefRotation();
if (
!this.briefAutoRotateEnabled ||
this.briefTimer ||
!this.active ||
this.signalCollapsed ||
document.hidden
)
return;
this.briefTimer = window.setTimeout(() => {
this.briefTimer = null;
if (this.destroyed) return;
const hasPointer = this.signalStream?.matches(':hover') === true;
const hasFocus =
this.signalStream?.contains(document.activeElement) === true;
const isInteracting = hasPointer || hasFocus;
if (!isInteracting) {
this.showBriefPage(this.briefPageIndex + 1);
}
this.startBriefRotation();
}, COCKPIT_BRIEF_ROTATE_MS);
}
export function stopBriefRotation() {
if (this.briefTimer) window.clearTimeout(this.briefTimer);
this.briefTimer = null;
}
export function updateLocalPosition(info) {
if (!this.localCoordinates) return;
if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) {
this.localCoordinates.textContent = 'POSITION UNAVAILABLE';
return;
}
const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`;
const lon = `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`;
this.localCoordinates.textContent = `${lat} · ${lon}`;
}
export function maybeRefreshRegionalBrief(info) {
if (this.destroyed) return;
if (
!this.active ||
!Number.isFinite(info.latitude) ||
!Number.isFinite(info.longitude)
)
return;
const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.registration || info.callsign || 'unknown'}`;
if (subjectId !== this.regionalBriefSubjectId) {
this.regionalBriefAbort?.abort();
this.regionalBriefAbort = null;
this.regionalBriefRequestToken += 1;
this.regionalBriefSubjectId = subjectId;
this.regionalBrief = null;
this.regionalBriefAnchor = null;
this.regionalBriefFetchedAt = 0;
}
const point = { latitude: info.latitude, longitude: info.longitude };
const ageMs = Date.now() - this.regionalBriefFetchedAt;
const distanceM = this.services.regionalDistanceM(
this.regionalBriefAnchor,
point,
);
if (
this.regionalBriefAbort ||
(ageMs < COCKPIT_REGIONAL_REFRESH_MS &&
distanceM < COCKPIT_REGIONAL_REFRESH_DISTANCE_M)
)
return;
this.regionalBriefAnchor = point;
this.regionalBriefFetchedAt = Date.now();
const controller = new AbortController();
const requestToken = ++this.regionalBriefRequestToken;
this.regionalBriefAbort = controller;
if (!this.regionalBrief) this.renderRegionalBriefStatus('loading', info);
this.services
.fetchRegionalBrief(point.latitude, point.longitude, {
signal: controller.signal,
})
.then((payload) => {
if (
!this.active ||
requestToken !== this.regionalBriefRequestToken ||
subjectId !== this.regionalBriefSubjectId
)
return;
this.regionalBrief = payload;
this.renderRegionalBrief(payload, info);
})
.catch((error) => {
if (
error?.name !== 'AbortError' &&
this.active &&
requestToken === this.regionalBriefRequestToken &&
subjectId === this.regionalBriefSubjectId
) {
this.renderRegionalBriefStatus('unavailable', info);
}
})
.finally(() => {
if (this.regionalBriefAbort === controller)
this.regionalBriefAbort = null;
});
}
export function renderRegionalBriefStatus(status, info) {
if (this.newsStatus) {
this.newsStatus.hidden = false;
this.newsStatus.dataset.state = status;
this.newsStatus.textContent =
status === 'loading'
? 'ACQUIRING REGIONAL NEWS'
: 'REGIONAL NEWS UNAVAILABLE';
}
if (status === 'unavailable') this.newsList?.replaceChildren();
if (this.localPlace && status === 'loading')
this.localPlace.textContent = 'RESOLVING REGION';
if (this.localPlace && status === 'unavailable')
this.localPlace.textContent = 'REGION UNAVAILABLE';
this.updateLocalPosition(info);
}
export function renderRegionalBrief(payload, info) {
const articles = Array.isArray(payload?.articles) ? payload.articles : [];
if (this.newsStatus) {
this.newsStatus.hidden = articles.length > 0;
this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable';
this.newsStatus.textContent =
payload?.newsStatus === 'empty'
? 'NO RECENT LOCATION MATCHES'
: 'REGIONAL NEWS UNAVAILABLE';
}
if (this.newsList) {
this.newsList.replaceChildren(
...articles.slice(0, 4).map((article) => {
const entry = document.createElement('li');
const link = document.createElement('a');
link.href = article.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
const title = document.createElement('strong');
title.textContent = article.title;
const metadata = document.createElement('span');
metadata.textContent = `${article.domain || 'SOURCE'} · ${formatCockpitBriefAge(article.publishedAt)}`;
link.append(title, metadata);
entry.append(link);
return entry;
}),
);
}
const placeLabel =
payload?.place?.label || payload?.place?.country || 'REGION UNAVAILABLE';
if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase();
this.updateLocalPosition(info);
const weather = payload?.weather;
if (this.localTemperature) {
this.localTemperature.textContent = Number.isFinite(weather?.temperatureC)
? `${Math.round(weather.temperatureC)}°C`
: '—';
}
if (this.localWind) {
this.localWind.textContent = Number.isFinite(weather?.windKph)
? `${Math.round(weather.windKph)} KM/H`
: '—';
}
if (this.localWindDirection) {
this.localWindDirection.textContent = formatCockpitWindDirection(
weather?.windDirectionDeg,
);
}
if (this.localCondition)
this.localCondition.textContent = this.services.weatherCodeLabel(
weather?.weatherCode,
);
if (this.localCloud) {
this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct)
? `CLOUD ${Math.round(weather.cloudCoverPct)}%`
: 'CLOUD UNKNOWN';
}
if (this.localPrecipitation) {
this.localPrecipitation.textContent = Number.isFinite(
weather?.precipitationMm,
)
? weather.precipitationMm.toFixed(1)
: '—';
}
if (this.signalStream)
this.signalStream.dataset.regionalStatus = payload?.status || 'partial';
if (this.briefPageIndex === 1 && this.briefSource) {
this.briefSource.textContent = `${String(payload?.newsSource || 'REGIONAL NEWS').toUpperCase()} · LOCATION QUERY`;
}
this.scheduleContextLayout();
}
+360
View File
@@ -0,0 +1,360 @@
/** Camera placement, ground acquisition and motion correction for the Cockpit controller. */
import * as Cesium from 'cesium';
import {
cockpitAnchorCorrectionStep,
cockpitGroundSafeHeight,
cockpitSurfaceWaitExpired,
cockpitUiUpdateDue,
slewHeading,
} from '../cockpitMath.js';
import {
COCKPIT_HEADING_SLEW_DPS,
COCKPIT_FORWARD_OFFSET_M,
COCKPIT_UP_OFFSET_M,
COCKPIT_MIN_GROUND_CLEARANCE_M,
COCKPIT_VIEW_PITCH_DEG,
COCKPIT_CAMERA_UPDATE_MS,
COCKPIT_HUD_UPDATE_MS,
COCKPIT_GROUND_PROBE_MS,
COCKPIT_GROUND_WAIT_TIMEOUT_MS,
} from './cockpitPresentation.js';
export function update() {
if (this.destroyed) return false;
if (!this.active) {
// Entry availability changes on a human timescale (tracking start/stop,
// info arriving after a poll) — polling it every rendered frame ran
// readAircraftInfo() + DOM pokes at display rate in plain map mode.
// 250 ms keeps the chip imperceptibly fresh; trackedEntityChanged still
// fires syncEntry immediately on the events that matter. (perf item 9)
const nowMs = performance.now();
if (nowMs - (this._lastEntrySyncMs || 0) >= 250) {
this._lastEntrySyncMs = nowMs;
this.syncEntry();
}
return;
}
const nowMs = performance.now();
const info = this.readAircraftInfo();
// Adopt a newly selected track before the camera cadence gate. A context
// NEXT/PREV selection can otherwise spend one frame driving the old
// aircraft with the new aircraft's metadata.
this._adoptTrackedEntity(nowMs, info);
if (
!info ||
!this.trackedEntity ||
!this.viewer.entities.contains(this.trackedEntity)
) {
if (nowMs < this.contextNavigationDeadlineMs) return;
this.exit({ restoreTracking: false });
return;
}
if (
!cockpitUiUpdateDue(
nowMs,
this.lastCameraUpdateMs,
COCKPIT_CAMERA_UPDATE_MS,
)
)
return;
this.lastCameraUpdateMs = nowMs;
const target = this.trackedEntity.position.getValue(
this.viewer.clock.currentTime,
this.scratchTarget,
);
if (!target) return;
const dtSec = Math.min(0.1, Math.max(0, (nowMs - this.lastFrameMs) / 1000));
this.lastFrameMs = nowMs;
if (Number.isFinite(info.track)) {
this.heading = slewHeading(
this.heading ?? info.track,
info.track,
COCKPIT_HEADING_SLEW_DPS * dtSec,
);
}
if (!this.cockpitAnchorValid) {
Cesium.Cartesian3.clone(target, this.cockpitAnchor);
this.cockpitAnchorValid = true;
}
// First-person motion cannot use the delayed feed correction as a raw
// camera destination: a harmless icon re-anchor becomes a whole-world
// surge/reversal in cockpit view. Advance the camera anchor inertially
// from the reported course/speed and converge on the authoritative layer
// display position at a bounded rate. This preserves the layer's required
// 15/30-second interpolation and per-frame cache without exposing its
// sample-boundary corrections to the camera.
const headingRad = Cesium.Math.toRadians(this.heading ?? 0);
const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG);
const speedMps = Number.isFinite(info.velocityMps)
? Math.max(0, info.velocityMps)
: 0;
if (info.stale) {
// A feed backoff has no authoritative velocity epoch to advance from.
// Hold the cockpit on the exact layer-rendered position so the camera
// cannot coast away while the icon correctly remains fixed.
Cesium.Cartesian3.clone(target, this.cockpitAnchor);
} else {
Cesium.Transforms.eastNorthUpToFixedFrame(
this.cockpitAnchor,
Cesium.Ellipsoid.WGS84,
this.scratchEnu,
);
this.scratchLocal.x = Math.sin(headingRad);
this.scratchLocal.y = Math.cos(headingRad);
this.scratchLocal.z = 0;
Cesium.Matrix4.multiplyByPointAsVector(
this.scratchEnu,
this.scratchLocal,
this.scratchHorizontal,
);
Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal);
Cesium.Cartesian3.multiplyByScalar(
this.scratchHorizontal,
speedMps * dtSec,
this.scratchAdvance,
);
Cesium.Cartesian3.add(
this.cockpitAnchor,
this.scratchAdvance,
this.cockpitAnchor,
);
Cesium.Cartesian3.subtract(
target,
this.cockpitAnchor,
this.scratchCorrection,
);
const correctionDistanceM = Cesium.Cartesian3.magnitude(
this.scratchCorrection,
);
const correctionStepM = cockpitAnchorCorrectionStep(
correctionDistanceM,
speedMps,
dtSec,
);
if (correctionStepM > 0 && correctionDistanceM > 0) {
Cesium.Cartesian3.multiplyByScalar(
this.scratchCorrection,
correctionStepM / correctionDistanceM,
this.scratchCorrection,
);
Cesium.Cartesian3.add(
this.cockpitAnchor,
this.scratchCorrection,
this.cockpitAnchor,
);
}
}
// The inertial anchor is independent of the layer's render-floor clamp and
// can otherwise coast into a photoreal mesh while a landing contact is
// between fixes. Clamp it against the same mesh-first shared floor used by
// aircraft rendering. For a slow contact whose floor cell is still cold,
// its already-clamped render position is a conservative temporary floor.
const anchorCartographic = Cesium.Cartographic.fromCartesian(
this.cockpitAnchor,
Cesium.Ellipsoid.WGS84,
this.scratchAnchorCartographic,
);
const targetCartographic = Cesium.Cartographic.fromCartesian(
target,
Cesium.Ellipsoid.WGS84,
this.scratchTargetCartographic,
);
let cockpitFloorM = this.services.cachedGroundFloor(
info.latitude,
info.longitude,
);
if (info.onGround === true) {
const groundPoint = [{ lat: info.latitude, lon: info.longitude }];
this.services.warmGroundFloor(groundPoint);
const meshFloorM = this.services.cachedMeshFloor(
info.latitude,
info.longitude,
);
if (this.services.meshFloorPreferred() && !Number.isFinite(meshFloorM)) {
if (
cockpitUiUpdateDue(
nowMs,
this.lastGroundProbeMs,
COCKPIT_GROUND_PROBE_MS,
)
) {
this.lastGroundProbeMs = nowMs;
const viewerCartographic = this.viewer.camera.positionCartographic;
this.services.sampleMeshFloorCells(this.viewer.scene, groundPoint, {
excludeObjects: [this.trackedEntity],
viewerLat: Cesium.Math.toDegrees(viewerCartographic.latitude),
viewerLon: Cesium.Math.toDegrees(viewerCartographic.longitude),
});
}
cockpitFloorM = this.services.cachedMeshFloor(
info.latitude,
info.longitude,
);
if (!Number.isFinite(cockpitFloorM)) {
if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs;
if (
!cockpitSurfaceWaitExpired(
nowMs,
this.surfaceWaitStartedMs,
COCKPIT_GROUND_WAIT_TIMEOUT_MS,
)
) {
// Keep the already-safe map camera in place while the photoreal
// surface under a parked aircraft is acquired. The bounded wait
// prevents a permanently cold mesh cell from freezing cockpit.
this.surfaceAcquiring = true;
this.surfaceFallback = false;
if (
cockpitUiUpdateDue(
nowMs,
this.lastHudUpdateMs,
COCKPIT_HUD_UPDATE_MS,
)
) {
this.lastHudUpdateMs = nowMs;
this.updateHud(info, nowMs);
}
return;
}
this.surfaceAcquiring = false;
this.surfaceFallback = true;
} else {
this.surfaceWaitStartedMs = 0;
this.surfaceAcquiring = false;
this.surfaceFallback = false;
}
}
} else {
this.surfaceWaitStartedMs = 0;
this.surfaceAcquiring = false;
this.surfaceFallback = false;
}
if (
!Number.isFinite(cockpitFloorM) &&
speedMps < 90 &&
Number.isFinite(targetCartographic?.height)
) {
cockpitFloorM =
targetCartographic.height - this.services.GROUND_FLOOR_LIFT_M;
}
if (anchorCartographic && Number.isFinite(cockpitFloorM)) {
const minimumAnchorHeightM = cockpitGroundSafeHeight(
anchorCartographic.height,
cockpitFloorM,
COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M,
);
if (minimumAnchorHeightM !== anchorCartographic.height) {
anchorCartographic.height = minimumAnchorHeightM;
Cesium.Ellipsoid.WGS84.cartographicToCartesian(
anchorCartographic,
this.cockpitAnchor,
);
}
}
// Rebuild the local frame at the stabilized anchor after advancing it.
Cesium.Transforms.eastNorthUpToFixedFrame(
this.cockpitAnchor,
Cesium.Ellipsoid.WGS84,
this.scratchEnu,
);
this.scratchLocal.x = Math.sin(headingRad);
this.scratchLocal.y = Math.cos(headingRad);
this.scratchLocal.z = 0;
Cesium.Matrix4.multiplyByPointAsVector(
this.scratchEnu,
this.scratchLocal,
this.scratchHorizontal,
);
Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal);
this.scratchLocal.x = Math.sin(headingRad) * Math.cos(pitchRad);
this.scratchLocal.y = Math.cos(headingRad) * Math.cos(pitchRad);
this.scratchLocal.z = Math.sin(pitchRad);
Cesium.Matrix4.multiplyByPointAsVector(
this.scratchEnu,
this.scratchLocal,
this.scratchForward,
);
Cesium.Cartesian3.normalize(this.scratchForward, this.scratchForward);
this.scratchLocal.x = -Math.sin(headingRad) * Math.sin(pitchRad);
this.scratchLocal.y = -Math.cos(headingRad) * Math.sin(pitchRad);
this.scratchLocal.z = Math.cos(pitchRad);
Cesium.Matrix4.multiplyByPointAsVector(
this.scratchEnu,
this.scratchLocal,
this.scratchUp,
);
Cesium.Cartesian3.normalize(this.scratchUp, this.scratchUp);
Cesium.Cartesian3.multiplyByScalar(
this.scratchHorizontal,
COCKPIT_FORWARD_OFFSET_M,
this.scratchCamera,
);
Cesium.Cartesian3.add(
this.cockpitAnchor,
this.scratchCamera,
this.scratchCamera,
);
Cesium.Matrix4.getTranslation(this.scratchEnu, this.scratchTarget);
Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget);
Cesium.Cartesian3.multiplyByScalar(
this.scratchTarget,
COCKPIT_UP_OFFSET_M,
this.scratchTarget,
);
Cesium.Cartesian3.add(
this.scratchCamera,
this.scratchTarget,
this.scratchCamera,
);
// Recheck at the final forward-offset camera coordinate because a taxiing
// aircraft can cross into an adjacent coarse floor cell between updates.
const cameraCartographic = Cesium.Cartographic.fromCartesian(
this.scratchCamera,
Cesium.Ellipsoid.WGS84,
this.scratchCameraCartographic,
);
if (cameraCartographic) {
const cameraLat = Cesium.Math.toDegrees(cameraCartographic.latitude);
const cameraLon = Cesium.Math.toDegrees(cameraCartographic.longitude);
const cameraFloorM = this.services.cachedGroundFloor(cameraLat, cameraLon);
if (Number.isFinite(cameraFloorM)) {
cockpitFloorM = Math.max(
cockpitFloorM ?? Number.NEGATIVE_INFINITY,
cameraFloorM,
);
}
const safeHeightM = cockpitGroundSafeHeight(
cameraCartographic.height,
cockpitFloorM,
COCKPIT_MIN_GROUND_CLEARANCE_M,
);
if (safeHeightM !== cameraCartographic.height) {
cameraCartographic.height = safeHeightM;
Cesium.Ellipsoid.WGS84.cartographicToCartesian(
cameraCartographic,
this.scratchCamera,
);
}
}
this.viewer.camera.setView({
destination: this.scratchCamera,
orientation: { direction: this.scratchForward, up: this.scratchUp },
});
if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) {
this.lastHudUpdateMs = nowMs;
this.updateHud(info, nowMs);
}
}
+157
View File
@@ -0,0 +1,157 @@
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
import * as Cesium from 'cesium';
import {
bearingBetweenCoordinates,
formatCockpitContextScope,
relativeBearing,
resolveCockpitContextReadout,
} from '../cockpitMath.js';
export function updateContext(info, heading) {
if (!this.context) return;
const snapshot =
this.services.militaryAwarenessLayer.getContextSnapshot?.() || null;
const trackedId = info.icao24 || info.id;
const readout = resolveCockpitContextReadout({ snapshot, info });
if (!readout.visible) {
this.context.hidden = true;
this.hud?.classList.remove('context-active');
this.contextLayoutStamp = null;
this.pushCockpitSignal(
'context-status',
'info',
'CONTEXT STANDBY',
'ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS',
);
return;
}
this.context.hidden = false;
this.hud?.classList.add('context-active');
if (this.contextSubject) {
const installationCoverage = snapshot.cohorts.find(
(cohort) => cohort.id === 'military-installations',
)?.coverage;
this.contextSubject.textContent = formatCockpitContextScope(
snapshot.subject.label || trackedId,
snapshot.radiusM,
installationCoverage,
);
}
// Navigation stays wired in every state — the operator must always be able
// to step off the current contact from the panel that hosts the controls.
if (this.contextPrevious)
this.contextPrevious.disabled = !snapshot.navigation?.canPrevious;
if (this.contextNext)
this.contextNext.disabled = !snapshot.navigation?.canNext;
if (readout.contactLost) {
// The subject left its source. Every number below is measured against a
// position that stopped updating, so hold the last rendered readout and
// say so instead of re-deriving stale geometry as if it were live.
const enteringLost = this.context.dataset.state !== 'lost';
this.context.dataset.state = 'lost';
if (this.contextUncertainty) {
this.contextUncertainty.textContent =
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR';
}
// The cue changes the footer's height; re-run layout once on the way in
// rather than every frame the contact stays lost.
if (enteringLost) this.scheduleContextLayout();
this.pushCockpitSignal(
'context-status',
'warning',
`CONTACT LOST · ${snapshot.subject.label || snapshot.subject.id || 'SUBJECT'}`,
'SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN',
);
return;
}
let unknownCount = 0;
const nearest = [];
for (const cohort of snapshot.cohorts) {
const element = this.contextCohorts.get(cohort.id);
const value = element?.querySelector('strong');
if (value)
value.textContent = cohort.count === null ? '?' : String(cohort.count);
element?.classList.toggle('unknown', cohort.relationship === 'UNKNOWN');
if (cohort.count === null) unknownCount += 1;
for (const item of cohort.nearest) nearest.push({ ...item, cohort });
}
nearest.sort((a, b) => (a.distanceM ?? Infinity) - (b.distanceM ?? Infinity));
const closest = nearest[0] || null;
const closestLabel = this.services.formatAwarenessLabel(closest);
if (this.contextNearestLabel) {
this.contextNearestLabel.textContent = closest
? `${closest.cohort.label.toUpperCase()} · ${closestLabel}`
: 'NO AVAILABLE EXAMPLE';
this.contextNearestLabel.setAttribute(
'aria-label',
closest && closestLabel === '—'
? `${closest.cohort.label}, Unavailable`
: this.contextNearestLabel.textContent,
);
}
if (this.contextDistance) {
const distanceM = closest?.distanceM;
this.contextDistance.textContent = Number.isFinite(distanceM)
? `${distanceM < 10000 ? (distanceM / 1000).toFixed(1) : Math.round(distanceM / 1000)} KM`
: '—';
this.contextDistance.setAttribute(
'aria-label',
Number.isFinite(distanceM)
? this.contextDistance.textContent
: 'Unavailable',
);
}
// The arrow and BRG are nose-relative to the tracked aircraft. When the
// subject is a vessel, an installation, or another aircraft, the rest of
// this row is measured from that subject — so the aircraft-frame half is
// dashed rather than presented alongside subject-frame distances as if the
// two shared an origin.
let relative = null;
if (
readout.aircraftRelative &&
closest?.position &&
Number.isFinite(info.latitude) &&
Number.isFinite(info.longitude)
) {
const cartographic = Cesium.Cartographic.fromCartesian(closest.position);
const bearing = cartographic
? bearingBetweenCoordinates(
info.latitude,
info.longitude,
Cesium.Math.toDegrees(cartographic.latitude),
Cesium.Math.toDegrees(cartographic.longitude),
)
: null;
relative = relativeBearing(bearing, heading);
}
if (this.contextDirection) {
this.contextDirection.style.transform = `rotate(${relative ?? 0}deg)`;
this.contextDirection.classList.toggle('unknown', relative === null);
}
if (this.contextBearing) {
if (relative === null) this.contextBearing.textContent = 'BRG —';
else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD';
else
this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`;
}
if (this.contextUncertainty) {
this.contextUncertainty.textContent = unknownCount
? `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN · NOT AN ALL-CLEAR`
: 'AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR';
}
if (this.contextUpdated) {
this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt)
? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z'
: '--:--:--Z';
}
this.context.dataset.state = unknownCount ? 'uncertain' : 'current';
this.updateCockpitSignals(snapshot, unknownCount);
if (this.contextLayoutStamp !== snapshot.evaluatedAt) {
this.contextLayoutStamp = snapshot.evaluatedAt;
this.scheduleContextLayout();
}
}
+432
View File
@@ -0,0 +1,432 @@
/** Coordinate Cockpit lifecycle through supplied aircraft, terrain, rendering and briefing operations. */
import {
readAircraftInfo,
dispatchCockpitModeChanged,
toggleTrackedTr3b,
syncTr3bToggle,
syncEntry,
navigateContext,
_adoptTrackedEntity,
enter,
exit,
} from './cockpitTrackingController.js';
import { update } from './cockpitCamera.js';
import {
updateHud,
updateRoute,
syncWeatherToggle,
setVisionMode,
cycleVisionMode,
clearPredictiveRoute,
} from './cockpitInstruments.js';
import { updateContext } from './cockpitContext.js';
import {
showBriefPage,
setBriefAutoRotate,
startBriefRotation,
stopBriefRotation,
updateLocalPosition,
maybeRefreshRegionalBrief,
renderRegionalBriefStatus,
renderRegionalBrief,
} from './cockpitBriefing.js';
import {
handleSignalClick,
renderCockpitSignals,
pushCockpitSignal,
updateCockpitSignals,
} from './cockpitSignals.js';
import {
scheduleContextLayout,
setContextCollapsed,
setSignalCollapsed,
syncContextLayout,
syncSignalLayout,
} from './cockpitLayout.js';
import { onKeyDown } from './cockpitInput.js';
import * as Cesium from 'cesium';
export class CockpitViewController {
constructor(
viewer,
{
services,
onVisionChange = null,
onCameraTakeover = null,
isEntryAllowed = null,
onEntered = null,
onExited = null,
getInheritedVisionLabel = null,
restoreTrackingFrame = null,
} = {},
) {
this.viewer = viewer;
this.services = services;
this.destroyed = false;
this.disposed = false;
this.active = false;
this.trackedEntity = null;
this.trackedEntityWasShown = true;
this.heading = null;
this.lastFrameMs = 0;
this.lastCameraUpdateMs = 0;
this.lastHudUpdateMs = 0;
this.lastContextUpdateMs = 0;
this.lastGroundProbeMs = 0;
this.contextNavigationDeadlineMs = 0;
this.surfaceWaitStartedMs = 0;
this.surfaceAcquiring = false;
this.surfaceFallback = false;
this.lastCompassSignature = '';
this.entry = document.getElementById('cockpit-entry');
this.tr3bToggle = document.getElementById('tr3b-toggle');
this._tr3bSignature = null;
this.mapViewButton = document.getElementById('map-view-switch');
this.resetGlobeButton = document.getElementById('cockpit-reset-globe');
this.hud = document.getElementById('cockpit-hud');
this.entryFocusOrigin = null;
this.callsign = document.getElementById('cockpit-callsign');
this.speed = document.getElementById('cockpit-speed-value');
this.speedRim = document.getElementById('cockpit-speed-rim');
this.speedRimValue = document.getElementById('cockpit-speed-rim-value');
this.speedRimTicks = Array.from(
document.querySelectorAll('[data-speed-rim-tick]'),
);
this.altitude = document.getElementById('cockpit-altitude-value');
this.altitudeRim = document.getElementById('cockpit-altitude-rim');
this.altitudeRimValue = document.getElementById(
'cockpit-altitude-rim-value',
);
this.altitudeRimTicks = Array.from(
document.querySelectorAll('[data-altitude-rim-tick]'),
);
this.headingValue = document.getElementById('cockpit-heading-value');
this.compassTape = document.getElementById('cockpit-compass-tape');
this.clock = document.getElementById('cockpit-clock');
this.position = document.getElementById('cockpit-position');
this.aircraftMeta = document.getElementById('cockpit-aircraft-meta');
this.route = document.getElementById('cockpit-route');
this.routeFrom = document.getElementById('cockpit-route-from');
this.routeTo = document.getElementById('cockpit-route-to');
this.routeStatus = document.getElementById('cockpit-route-status');
this.routeDirection = document.getElementById('cockpit-route-direction');
this.routeDirectionLabel = document.getElementById(
'cockpit-route-direction-label',
);
this.visionPrevious = document.getElementById('cockpit-vision-previous');
this.visionCurrent = document.getElementById('cockpit-vision-current');
this.visionCurrentLabel = document.getElementById(
'cockpit-vision-current-label',
);
this.visionNext = document.getElementById('cockpit-vision-next');
this.visionMode = 'optical';
this.onVisionChange = onVisionChange;
this.onCameraTakeover = onCameraTakeover;
this.onEntered = onEntered;
this.onExited = onExited;
this.getInheritedVisionLabel =
typeof getInheritedVisionLabel === 'function'
? getInheritedVisionLabel
: () => 'NORMAL';
this.restoreTrackingFrame =
typeof restoreTrackingFrame === 'function'
? restoreTrackingFrame
: () => false;
this.isEntryAllowed =
typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true;
this.context = document.getElementById('cockpit-context');
this.contextSubject = document.getElementById('cockpit-context-subject');
this.contextNearestLabel = document.getElementById(
'cockpit-context-nearest-label',
);
this.contextBearing = document.getElementById('cockpit-context-bearing');
this.contextDistance = document.getElementById('cockpit-context-distance');
this.contextDirection = document.getElementById(
'cockpit-context-direction',
);
this.contextUncertainty = document.getElementById(
'cockpit-context-uncertainty',
);
this.contextUpdated = document.getElementById('cockpit-context-updated');
this.contextCohorts = new Map(
Array.from(document.querySelectorAll('[data-context-cohort]')).map(
(element) => [element.dataset.contextCohort, element],
),
);
this.contextPrevious = document.getElementById('cockpit-context-previous');
this.contextNext = document.getElementById('cockpit-context-next');
this.contextToggle = document.getElementById('cockpit-context-toggle');
this.weatherToggle = document.getElementById('cockpit-weather-toggle');
this.weatherState = document.getElementById('cockpit-weather-state');
this.contextCollapsed = false;
this.signalStream = document.getElementById('cockpit-signal-stream');
this.signalList = document.getElementById('cockpit-signal-list');
this.signalToggle = document.getElementById('cockpit-signal-toggle');
this.briefKicker = document.getElementById('cockpit-brief-kicker');
this.briefSubtitle = document.getElementById('cockpit-brief-subtitle');
this.briefPrevious = document.getElementById('cockpit-brief-previous');
this.briefNext = document.getElementById('cockpit-brief-next');
this.briefAutoToggle = document.getElementById('cockpit-brief-auto');
this.briefPosition = document.getElementById('cockpit-brief-position');
this.briefSource = document.getElementById('cockpit-brief-source');
this.briefPages = Array.from(
document.querySelectorAll('[data-cockpit-brief-page]'),
);
this.briefTabs = Array.from(
document.querySelectorAll('[data-cockpit-brief-index]'),
);
this.newsStatus = document.getElementById('cockpit-news-status');
this.newsList = document.getElementById('cockpit-news-list');
this.localPlace = document.getElementById('cockpit-local-place');
this.localCoordinates = document.getElementById(
'cockpit-local-coordinates',
);
this.localTemperature = document.getElementById(
'cockpit-local-temperature',
);
this.localWind = document.getElementById('cockpit-local-wind');
this.localWindDirection = document.getElementById(
'cockpit-local-wind-direction',
);
this.localCondition = document.getElementById('cockpit-local-condition');
this.localCloud = document.getElementById('cockpit-local-cloud');
this.localPrecipitation = document.getElementById(
'cockpit-local-precipitation',
);
this.signalCollapsed = false;
this.signalUserCollapsed = false;
this.signalItems = [];
this.signalSignatures = new Map();
this.briefPageIndex = 0;
this.briefAutoRotateEnabled = false;
this.briefTimer = null;
this.lastAircraftInfo = null;
this.regionalBrief = null;
this.regionalBriefAnchor = null;
this.regionalBriefFetchedAt = 0;
this.regionalBriefAbort = null;
this.regionalBriefRequestToken = 0;
this.regionalBriefSubjectId = null;
this.contextLayoutFrame = null;
this.contextLayoutStamp = null;
this.scratchTarget = new Cesium.Cartesian3();
this.cockpitAnchor = new Cesium.Cartesian3();
this.cockpitAnchorValid = false;
this.scratchCamera = new Cesium.Cartesian3();
this.scratchAdvance = new Cesium.Cartesian3();
this.scratchCorrection = new Cesium.Cartesian3();
this.scratchForward = new Cesium.Cartesian3();
this.scratchHorizontal = new Cesium.Cartesian3();
this.scratchUp = new Cesium.Cartesian3();
this.scratchLocal = new Cesium.Cartesian3();
this.scratchEnu = new Cesium.Matrix4();
this.scratchAnchorCartographic = new Cesium.Cartographic();
this.scratchCameraCartographic = new Cesium.Cartographic();
this.scratchTargetCartographic = new Cesium.Cartographic();
this._listenerRemovers = [];
// Camera mutations belong before scene update/culling. Changing the camera
// from preRender makes 3D Tiles discover a new view after traversal and can
// create a self-sustaining refinement loop under a moving cockpit camera.
this._listenerRemovers.push(
viewer.scene.preUpdate.addEventListener(() => this.update()),
viewer.trackedEntityChanged.addEventListener(() => {
if (this.active) this._adoptTrackedEntity(performance.now());
else this.syncEntry();
}),
);
this._listen(this.entry, 'click', () => this.enter());
this._listen(this.tr3bToggle, 'click', () => this.toggleTrackedTr3b());
this._listen(this.mapViewButton, 'click', () => this.exit());
this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1));
this._listen(this.visionCurrent, 'click', () => this.cycleVisionMode(1));
this._listen(this.visionNext, 'click', () => this.cycleVisionMode(1));
this._listen(this.contextPrevious, 'click', () =>
this.navigateContext(-1, { origin: 'user' }),
);
this._listen(this.contextNext, 'click', () =>
this.navigateContext(1, { origin: 'user' }),
);
this._listen(this.contextToggle, 'click', () =>
this.setContextCollapsed(!this.contextCollapsed),
);
this._listen(this.weatherToggle, 'click', () => {
const enabled =
this.weatherToggle.getAttribute('aria-pressed') !== 'true';
this.syncWeatherToggle(enabled);
window.dispatchEvent(
new CustomEvent('gev:cockpit-weather-toggle', {
detail: { enabled },
}),
);
});
this._listen(window, 'gev:cockpit-weather-state', (event) => {
this.syncWeatherToggle(event?.detail?.enabled !== false);
});
this._listen(this.signalToggle, 'click', () =>
this.setSignalCollapsed(!this.signalCollapsed, { user: true }),
);
this._listen(this.signalList, 'click', (event) =>
this.handleSignalClick(event),
);
this._listen(this.briefPrevious, 'click', () =>
this.showBriefPage(this.briefPageIndex - 1, { manual: true }),
);
this._listen(this.briefNext, 'click', () =>
this.showBriefPage(this.briefPageIndex + 1, { manual: true }),
);
this._listen(this.briefAutoToggle, 'click', () => {
this.setBriefAutoRotate(!this.briefAutoRotateEnabled);
});
this.briefTabs.forEach((button) =>
this._listen(button, 'click', () => {
this.showBriefPage(Number(button.dataset.cockpitBriefIndex), {
manual: true,
});
}),
);
this._listen(document, 'visibilitychange', () => {
if (document.hidden) this.stopBriefRotation();
else if (this.briefAutoRotateEnabled) this.startBriefRotation();
});
this._listen(window, 'resize', () => this.scheduleContextLayout());
this._listen(document, 'keydown', (event) => this.onKeyDown(event), true);
}
_listen(target, type, handler, options) {
if (!target?.addEventListener) return;
target.addEventListener(type, handler, options);
this._listenerRemovers.push(() =>
target.removeEventListener(type, handler, options),
);
}
readAircraftInfo() {
return readAircraftInfo.call(this);
}
dispatchCockpitModeChanged(active, info) {
return dispatchCockpitModeChanged.call(this, active, info);
}
toggleTrackedTr3b() {
return toggleTrackedTr3b.call(this);
}
syncTr3bToggle(info) {
return syncTr3bToggle.call(this, info);
}
syncEntry() {
return syncEntry.call(this);
}
navigateContext(direction, options) {
return navigateContext.call(this, direction, options);
}
_adoptTrackedEntity(nowMs, suppliedInfo) {
return _adoptTrackedEntity.call(this, nowMs, suppliedInfo);
}
enter() {
return enter.call(this);
}
exit(options) {
return exit.call(this, options);
}
update() {
return update.call(this);
}
updateHud(info, nowMs, forceContext) {
return updateHud.call(this, info, nowMs, forceContext);
}
updateRoute(info) {
return updateRoute.call(this, info);
}
syncWeatherToggle(enabled) {
return syncWeatherToggle.call(this, enabled);
}
setVisionMode(mode, options) {
return setVisionMode.call(this, mode, options);
}
cycleVisionMode(direction) {
return cycleVisionMode.call(this, direction);
}
clearPredictiveRoute() {
return clearPredictiveRoute.call(this);
}
updateContext(info, heading) {
return updateContext.call(this, info, heading);
}
showBriefPage(index, options) {
return showBriefPage.call(this, index, options);
}
setBriefAutoRotate(enabled) {
return setBriefAutoRotate.call(this, enabled);
}
startBriefRotation(options) {
return startBriefRotation.call(this, options);
}
stopBriefRotation() {
return stopBriefRotation.call(this);
}
updateLocalPosition(info) {
return updateLocalPosition.call(this, info);
}
maybeRefreshRegionalBrief(info) {
return maybeRefreshRegionalBrief.call(this, info);
}
renderRegionalBriefStatus(status, info) {
return renderRegionalBriefStatus.call(this, status, info);
}
renderRegionalBrief(payload, info) {
return renderRegionalBrief.call(this, payload, info);
}
renderCockpitSignals() {
return renderCockpitSignals.call(this);
}
pushCockpitSignal(key, tone, title, detail, target) {
return pushCockpitSignal.call(this, key, tone, title, detail, target);
}
updateCockpitSignals(snapshot, unknownCount) {
return updateCockpitSignals.call(this, snapshot, unknownCount);
}
scheduleContextLayout() {
return scheduleContextLayout.call(this);
}
setContextCollapsed(collapsed) {
return setContextCollapsed.call(this, collapsed);
}
setSignalCollapsed(collapsed, options) {
return setSignalCollapsed.call(this, collapsed, options);
}
syncContextLayout() {
return syncContextLayout.call(this);
}
syncSignalLayout() {
return syncSignalLayout.call(this);
}
onKeyDown(event) {
return onKeyDown.call(this, event);
}
handleSignalClick(event) {
return handleSignalClick.call(this, event);
}
/** Revoke new work before the shell awaits layer restoration. */
stop() {
if (this.destroyed) return;
this.destroyed = true;
this.regionalBriefAbort?.abort();
this.regionalBriefAbort = null;
this.regionalBriefRequestToken += 1;
this.stopBriefRotation();
if (this.contextLayoutFrame !== null)
cancelAnimationFrame(this.contextLayoutFrame);
this.contextLayoutFrame = null;
for (const removeListener of this._listenerRemovers.splice(0))
removeListener?.();
}
dispose() {
if (this.disposed) return;
this.disposed = true;
this.stop();
this.exit({ restoreTracking: false });
}
}
+211
View File
@@ -0,0 +1,211 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { CockpitViewController } from './cockpitController.js';
function environment() {
const original = { document: globalThis.document, window: globalThis.window };
const document = Object.assign(new EventTarget(), {
getElementById: () => null,
querySelectorAll: () => [],
body: { classList: { remove() {} } },
});
const window = Object.assign(new EventTarget(), { clearTimeout, setTimeout });
globalThis.document = document;
globalThis.window = window;
const callbacks = new Set();
const event = {
addEventListener(fn) {
callbacks.add(fn);
return () => callbacks.delete(fn);
},
};
const viewer = {
scene: {
preUpdate: event,
screenSpaceCameraController: { enableInputs: false },
},
trackedEntityChanged: event,
entities: { contains: () => false },
};
return {
viewer,
callbacks,
restore() {
Object.assign(globalThis, original);
},
};
}
test('Cockpit subscriptions are released once and retained actions cannot restart it', () => {
const env = environment();
try {
const owner = new CockpitViewController(env.viewer, { services: {} });
assert.equal(env.callbacks.size, 2);
owner.dispose();
owner.dispose();
assert.equal(env.callbacks.size, 0);
assert.equal(owner.enter(), false);
assert.equal(owner.navigateContext(1), false);
assert.equal(owner.toggleTrackedTr3b(), false);
assert.equal(owner.update(), false);
assert.equal(owner.active, false);
} finally {
env.restore();
}
});
test('disposing an active Cockpit releases the supplied render owner without retracking', () => {
const env = environment();
try {
const released = [];
const owner = new CockpitViewController(env.viewer, {
services: { releaseContinuousRender: (reason) => released.push(reason) },
});
owner.active = true;
owner.dispose();
owner.dispose();
assert.deepEqual(released, ['cockpit']);
assert.equal(
env.viewer.scene.screenSpaceCameraController.enableInputs,
true,
);
assert.equal(env.viewer.trackedEntity, undefined);
assert.equal(owner.active, false);
} finally {
env.restore();
}
});
test('aircraft identity is read from the supplied layer instances', () => {
const env = environment();
try {
const civilian = {
icao24: 'abc123',
layerId: 'flights',
callsign: 'CIVIL',
};
const military = {
icao24: 'def456',
layerId: 'military',
callsign: 'MILITARY',
};
const owner = new CockpitViewController(env.viewer, {
services: {
flightsLayer: { getTrackedInfo: () => civilian },
militaryFlightsLayer: { getTrackedInfo: () => military },
},
});
env.viewer.trackedEntity = { gevTrackedId: 'military:def456' };
assert.deepEqual(owner.readAircraftInfo(), military);
env.viewer.trackedEntity = { gevTrackedId: 'flights:abc123' };
assert.deepEqual(owner.readAircraftInfo(), civilian);
owner.dispose();
} finally {
env.restore();
}
});
test('an obsolete regional briefing cannot publish after Cockpit disposal', async () => {
const env = environment();
try {
let finish;
let signal;
const owner = new CockpitViewController(env.viewer, {
services: {
regionalDistanceM: () => Infinity,
fetchRegionalBrief: (_lat, _lon, options) => {
signal = options.signal;
return new Promise((resolve) => {
finish = resolve;
});
},
releaseContinuousRender() {},
},
});
const published = [];
owner.renderRegionalBrief = (payload) => published.push(payload);
owner.active = true;
owner.maybeRefreshRegionalBrief({
layerId: 'flights',
icao24: 'abc123',
latitude: 30,
longitude: -97,
});
owner.dispose();
assert.equal(signal.aborted, true);
finish({ articles: [] });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(published, []);
assert.equal(owner.regionalBriefAbort, null);
} finally {
env.restore();
}
});
test('stopping Cockpit revokes camera actions before deferred disposal releases rendering', () => {
const env = environment();
try {
const released = [];
const owner = new CockpitViewController(env.viewer, {
services: {
releaseContinuousRender: (reason) => released.push(reason),
},
});
owner.active = true;
owner.stop();
assert.equal(env.callbacks.size, 0);
assert.equal(owner.update(), false);
assert.equal(owner.navigateContext(1), false);
assert.deepEqual(released, []);
owner.dispose();
assert.deepEqual(released, ['cockpit']);
assert.equal(owner.active, false);
} finally {
env.restore();
}
});
test('stopped Cockpit cannot rearm briefing rotation or request a fresh region', () => {
const env = environment();
try {
const frames = new Map();
let next = 0;
let fetches = 0;
let paints = 0;
env.viewer.scene.screenSpaceCameraController.enableInputs = true;
window.setTimeout = (callback) => {
frames.set(++next, callback);
return next;
};
window.clearTimeout = (id) => frames.delete(id);
const owner = new CockpitViewController(env.viewer, {
services: {
regionalDistanceM: () => Infinity,
fetchRegionalBrief: () => {
fetches++;
return Promise.resolve({ articles: [] });
},
releaseContinuousRender() {},
},
});
owner.active = true;
owner.showBriefPage = () => {
paints++;
};
owner.setBriefAutoRotate(true);
assert.equal(frames.size, 1);
const queued = [...frames.values()][0];
owner.stop();
assert.equal(frames.size, 0);
queued();
owner.setBriefAutoRotate(true);
owner.startBriefRotation();
owner.maybeRefreshRegionalBrief({ latitude: 30, longitude: -97 });
assert.equal(frames.size, 0);
assert.equal(paints, 0);
assert.equal(fetches, 0);
owner.dispose();
} finally {
env.restore();
}
});
+128
View File
@@ -0,0 +1,128 @@
/** Move the existing Display groups between their two presentation surfaces. */
export class CockpitDisplayPortal {
constructor({ standardPanel, cockpitPanel, groups, layout }) {
this.standardPanel = standardPanel;
this.cockpitPanel = cockpitPanel;
this.layout = layout;
this.active = false;
this.destroyed = false;
this.stopped = false;
this.restoreOwner = null;
this.generation = 0;
this.frames = new Set();
this.listeners = new AbortController();
this.records = groups.flatMap(([name, group]) => {
const slot = cockpitPanel?.querySelector(
`[data-cockpit-display-slot="${name}"]`,
);
if (!group || !slot || !group.parentNode) return [];
const anchor = document.createComment(`cockpit-display-home:${name}`);
group.before(anchor);
return [{ name, group, slot, anchor }];
});
this.standardScrollTop = standardPanel?.scrollTop || 0;
this.cockpitScrollTop = cockpitPanel?.scrollTop || 0;
const options = { passive: true, signal: this.listeners.signal };
standardPanel?.addEventListener(
'scroll',
() => {
if (!this.active) this.standardScrollTop = standardPanel.scrollTop;
},
options,
);
cockpitPanel?.addEventListener(
'scroll',
() => {
if (this.active) this.cockpitScrollTop = cockpitPanel.scrollTop;
},
options,
);
window.addEventListener(
'gev:cockpit-mode-changed',
(event) => {
this.setActive(event?.detail?.active === true);
},
{ signal: this.listeners.signal },
);
this.setActive(document.body.classList.contains('cockpit-mode'));
}
_frame(generation, callback) {
const frame = requestAnimationFrame(() => {
this.frames.delete(frame);
if (!this.stopped && generation === this.generation) callback();
});
this.frames.add(frame);
}
_cancelFrames() {
for (const frame of this.frames) cancelAnimationFrame(frame);
this.frames.clear();
this.generation += 1;
}
setActive(active, { settle = true } = {}) {
if (this.stopped) return;
const nextActive = active === true;
if (this.active === nextActive) return;
this._cancelFrames();
const generation = this.generation;
const focusedRecord = this.records.find((record) =>
record.group.contains(document.activeElement),
);
const focusedElement = focusedRecord ? document.activeElement : null;
this.restoreOwner = nextActive ? 'cockpit' : 'standard';
this.active = nextActive;
for (const record of this.records) {
if (nextActive) record.slot.append(record.group);
else if (record.anchor.parentNode) record.anchor.after(record.group);
}
this.cockpitPanel?.classList.toggle(
'uses-shared-display-controls',
nextActive,
);
const restoreScroll = () => {
if (nextActive && this.cockpitPanel)
this.cockpitPanel.scrollTop = this.cockpitScrollTop;
if (!nextActive && this.standardPanel)
this.standardPanel.scrollTop = this.standardScrollTop;
};
if (!settle) {
restoreScroll();
this.restoreOwner = null;
return;
}
this._frame(generation, () => {
restoreScroll();
focusedElement?.focus?.({ preventScroll: true });
this._frame(generation, () => {
restoreScroll();
this.restoreOwner = null;
});
});
this.layout();
}
stop() {
if (this.stopped) return;
this.stopped = true;
this._cancelFrames();
this.listeners.abort();
}
destroy() {
if (this.destroyed) return;
this.stop();
this.destroyed = true;
this.active = false;
for (const record of this.records) {
if (record.anchor.parentNode) record.anchor.after(record.group);
record.anchor.remove();
}
this.cockpitPanel?.classList.remove('uses-shared-display-controls');
if (this.standardPanel)
this.standardPanel.scrollTop = this.standardScrollTop;
this.records = [];
this.restoreOwner = null;
}
}
+202
View File
@@ -0,0 +1,202 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { CockpitDisplayPortal } from './cockpitDisplayPortal.js';
function fixture() {
const prior = Object.fromEntries(
['document', 'window', 'requestAnimationFrame', 'cancelAnimationFrame'].map(
(key) => [key, globalThis[key]],
),
);
const frames = new Map();
let nextFrame = 0;
let focusCalls = 0;
let layouts = 0;
class Node extends EventTarget {
constructor(name) {
super();
this.name = name;
this.children = [];
this.parentNode = null;
this.scrollTop = 0;
this.classList = { toggle() {}, remove() {} };
}
append(node) {
node.remove();
this.children.push(node);
node.parentNode = this;
}
remove() {
if (this.parentNode)
this.parentNode.children.splice(
this.parentNode.children.indexOf(this),
1,
);
this.parentNode = null;
}
before(node) {
node.remove();
const parent = this.parentNode;
parent.children.splice(parent.children.indexOf(this), 0, node);
node.parentNode = parent;
}
after(node) {
node.remove();
const parent = this.parentNode;
parent.children.splice(parent.children.indexOf(this) + 1, 0, node);
node.parentNode = parent;
}
contains(node) {
return (
this === node || this.children.some((child) => child.contains(node))
);
}
focus() {
focusCalls += 1;
document.activeElement = this;
}
}
const standard = new Node('standard');
standard.scrollTop = 71;
const cockpit = new Node('cockpit');
cockpit.scrollTop = 19;
const group = new Node('group');
const sibling = new Node('sibling');
const slot = new Node('slot');
standard.append(group);
standard.append(sibling);
cockpit.append(slot);
cockpit.querySelector = () => slot;
globalThis.document = {
createComment: (name) => new Node(name),
activeElement: group,
body: { classList: { contains: () => false } },
};
globalThis.window = new EventTarget();
globalThis.requestAnimationFrame = (fn) => {
const id = ++nextFrame;
frames.set(id, fn);
return id;
};
globalThis.cancelAnimationFrame = (id) => frames.delete(id);
const create = () =>
new CockpitDisplayPortal({
standardPanel: standard,
cockpitPanel: cockpit,
groups: [['hud', group]],
layout: () => {
layouts += 1;
},
});
return {
standard,
cockpit,
group,
sibling,
slot,
frames,
create,
get focusCalls() {
return focusCalls;
},
get layouts() {
return layouts;
},
flush() {
while (frames.size) {
const batch = [...frames.values()];
frames.clear();
batch.forEach((fn) => fn());
}
},
restore() {
Object.assign(globalThis, prior);
},
};
}
test('Display groups return to their exact home with independent scroll positions', () => {
const f = fixture();
try {
const owner = f.create();
owner.setActive(true);
assert.equal(f.group.parentNode, f.slot);
f.cockpit.scrollTop = 0;
f.flush();
assert.equal(f.cockpit.scrollTop, 19);
assert.equal(f.focusCalls, 1);
owner.setActive(false);
f.standard.scrollTop = 0;
f.flush();
assert.equal(f.standard.scrollTop, 71);
owner.destroy();
assert.deepEqual(f.standard.children, [f.group, f.sibling]);
assert.equal(f.frames.size, 0);
} finally {
f.restore();
}
});
test('superseded portal frames cannot restore obsolete focus or scroll', () => {
const f = fixture();
try {
const owner = f.create();
owner.setActive(true);
const obsolete = [...f.frames.values()];
owner.setActive(false);
f.cockpit.scrollTop = 93;
obsolete.forEach((callback) => callback());
assert.equal(f.focusCalls, 0);
assert.equal(f.cockpit.scrollTop, 93);
f.flush();
assert.equal(f.focusCalls, 1);
assert.equal(owner.restoreOwner, null);
owner.destroy();
} finally {
f.restore();
}
});
test('disposal restores groups and revokes mode listeners and queued focus work', () => {
const f = fixture();
try {
const owner = f.create();
window.dispatchEvent(
new CustomEvent('gev:cockpit-mode-changed', { detail: { active: true } }),
);
assert.equal(f.group.parentNode, f.slot);
const obsolete = [...f.frames.values()];
owner.destroy();
owner.destroy();
obsolete.forEach((callback) => callback());
window.dispatchEvent(
new CustomEvent('gev:cockpit-mode-changed', { detail: { active: true } }),
);
owner.setActive(true);
assert.deepEqual(f.standard.children, [f.group, f.sibling]);
assert.equal(f.frames.size, 0);
assert.equal(f.focusCalls, 0);
assert.equal(f.layouts, 1);
} finally {
f.restore();
}
});
test('a stopped portal waits for cleanup without running queued focus work', () => {
const f = fixture();
try {
const owner = f.create();
owner.setActive(true);
const queued = [...f.frames.values()];
owner.stop();
queued.forEach((callback) => callback());
owner.setActive(false);
assert.equal(f.group.parentNode, f.slot);
assert.equal(f.frames.size, 0);
assert.equal(f.focusCalls, 0);
owner.destroy();
assert.deepEqual(f.standard.children, [f.group, f.sibling]);
} finally {
f.restore();
}
});
+70
View File
@@ -0,0 +1,70 @@
/** Route Cockpit keys while preserving the active disclosure and form-control owner. */
export function onKeyDown(event) {
if (this.destroyed) return false;
if (event.repeat || event.isComposing) return;
if (event.key === 'Escape' && this.active) {
// The credit lightbox owns Escape while its Close control or links hold
// focus. Its target handler closes the overlay and restores attribution
// focus; Cockpit must stay active behind it.
if (event.target?.closest?.('.cesium-credit-lightbox')) return;
if (
document
.getElementById('context-radio-dock')
?.classList.contains('disclosure-open')
)
return;
if (
document.querySelector('#cockpit-utility-controls [aria-expanded="true"]')
)
return;
if (this.context?.contains(event.target) && !this.contextCollapsed) {
event.preventDefault();
event.stopImmediatePropagation();
this.setContextCollapsed(true);
if (
event.target === this.contextToggle ||
this.contextToggle?.contains?.(event.target)
) {
this.contextToggle?.blur?.();
} else {
this.contextToggle?.focus({ preventScroll: true });
}
return;
}
if (this.signalStream?.contains(event.target) && !this.signalCollapsed) {
event.preventDefault();
event.stopImmediatePropagation();
this.setSignalCollapsed(true, { user: true });
if (
event.target === this.signalToggle ||
this.signalToggle?.contains?.(event.target)
) {
this.signalToggle?.blur?.();
} else {
this.signalToggle?.focus({ preventScroll: true });
}
return;
}
event.preventDefault();
event.stopImmediatePropagation();
this.exit();
return;
}
if (event.target?.closest?.('input, textarea, select, [contenteditable]'))
return;
const key = event.key?.toLowerCase();
if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) {
if (!this.active) {
const cockpitAttempt = !!(
this.readAircraftInfo() && this.viewer.trackedEntity?.position
);
if (!cockpitAttempt) return;
}
event.preventDefault();
event.stopImmediatePropagation();
if (!this.active && !this.isEntryAllowed()) return;
const changed = this.active ? this.exit() : this.enter();
return;
}
}
+260
View File
@@ -0,0 +1,260 @@
/** Instrument values, routes and vision controls for the Cockpit controller. */
import {
COCKPIT_VISION_MODES,
normalizeCockpitVisionMode,
} from '../cockpitVisionPolicy.js';
import {
altitudeRulerCurveInset,
altitudeRulerTicks,
bearingBetweenCoordinates,
cockpitAltitudeDisplayFt,
cockpitUiUpdateDue,
compassDivisions,
formatAltitudeRulerTick,
formatCompassDivision,
formatSpeedRulerTick,
normalizeHeading,
relativeBearing,
speedRulerTicks,
} from '../cockpitMath.js';
import {
COCKPIT_CONTEXT_UPDATE_MS,
setCockpitRollingValue,
} from './cockpitPresentation.js';
export function updateHud(
info,
nowMs = performance.now(),
forceContext = false,
) {
this.lastAircraftInfo = info;
const heading = normalizeHeading(this.heading ?? info.track ?? 0);
if (this.callsign) {
this.callsign.textContent =
info.callsign || info.registration || info.icao24 || 'AIRCRAFT';
}
const speedKt = Number.isFinite(info.velocityMps)
? info.velocityMps * 1.94384
: null;
setCockpitRollingValue(this.speed, formatSpeedRulerTick(speedKt), speedKt, {
immediate: forceContext,
});
if (this.speedRim)
this.speedRim.classList.toggle('unavailable', speedKt === null);
if (this.speedRimValue)
this.speedRimValue.textContent = formatSpeedRulerTick(speedKt);
const speedTicks = speedRulerTicks(speedKt, this.speedRimTicks.length);
this.speedRimTicks.forEach((element, index) => {
const tick = speedTicks[index];
element.hidden = !tick;
if (!tick) return;
element.style.setProperty('--slot', tick.slot.toFixed(4));
element.style.setProperty('--depth', tick.depth.toFixed(4));
element.style.setProperty(
'--curve',
altitudeRulerCurveInset(tick.slot).toFixed(5),
);
element.classList.toggle('major', tick.major);
const label = element.querySelector('b');
if (label) label.textContent = formatSpeedRulerTick(tick.valueKt);
});
const altitudeFt = cockpitAltitudeDisplayFt(info.altitudeM, info.onGround);
if (this.altitude) {
const displayedAltitudeFt = Number.isFinite(altitudeFt)
? Math.round(altitudeFt)
: null;
setCockpitRollingValue(
this.altitude,
displayedAltitudeFt !== null
? displayedAltitudeFt.toLocaleString('en-US')
: '-----',
displayedAltitudeFt,
{ immediate: forceContext },
);
}
if (this.altitudeRim)
this.altitudeRim.classList.toggle('unavailable', altitudeFt === null);
if (this.altitudeRimValue) {
this.altitudeRimValue.textContent = formatAltitudeRulerTick(altitudeFt);
}
const altitudeTicks = altitudeRulerTicks(
altitudeFt,
this.altitudeRimTicks.length,
);
this.altitudeRimTicks.forEach((element, index) => {
const tick = altitudeTicks[index];
element.hidden = !tick;
if (!tick) return;
element.style.setProperty('--slot', tick.slot.toFixed(4));
element.style.setProperty('--depth', tick.depth.toFixed(4));
element.style.setProperty(
'--curve',
altitudeRulerCurveInset(tick.slot).toFixed(5),
);
element.classList.toggle('major', tick.major);
const label = element.querySelector('b');
if (label) label.textContent = formatAltitudeRulerTick(tick.valueFt);
});
setCockpitRollingValue(
this.headingValue,
String(Math.round(heading) % 360).padStart(3, '0'),
heading,
{ circularRange: 360, immediate: forceContext },
);
if (this.compassTape) {
const divisions = compassDivisions(heading);
const signature = divisions.join(',');
if (signature !== this.lastCompassSignature) {
this.lastCompassSignature = signature;
this.compassTape.innerHTML = divisions
.map((division, index) => {
const slot = index - 3;
return `<span class="${slot === 0 ? 'active' : ''}" style="--slot:${slot};--depth:${Math.abs(slot)}">${formatCompassDivision(division)}</span>`;
})
.join('');
}
}
if (this.clock)
this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z';
if (this.position) {
const lat = Number.isFinite(info.latitude)
? `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`
: '--';
const lon = Number.isFinite(info.longitude)
? `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`
: '--';
this.position.textContent = `${lat} · ${lon}`;
}
if (this.aircraftMeta) {
const feedState = this.surfaceAcquiring
? 'ACQUIRING SURFACE'
: this.surfaceFallback
? 'SURFACE FALLBACK'
: info.stale
? 'STALE FEED'
: 'LIVE TRACK';
this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`;
}
this.updateRoute(info);
if (
forceContext ||
cockpitUiUpdateDue(
nowMs,
this.lastContextUpdateMs,
COCKPIT_CONTEXT_UPDATE_MS,
)
) {
this.lastContextUpdateMs = nowMs;
this.updateLocalPosition(info);
this.maybeRefreshRegionalBrief(info);
this.updateContext(info, heading);
}
if (this.hud) this.hud.dataset.layer = info.layerId || 'flights';
}
export function updateRoute(info) {
const origin = info?.route?.origin;
const destination = info?.route?.destination;
const validDestination =
Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
const routeLabel = (airport) =>
[airport?.code, airport?.name].filter(Boolean).join(' · ') || 'UNKNOWN';
if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin);
if (this.routeTo) this.routeTo.textContent = routeLabel(destination);
if (this.routeStatus) {
this.routeStatus.textContent = validDestination
? 'ARROW · ESTIMATED DIRECTION'
: 'ROUTE DATA UNAVAILABLE';
}
if (this.route) this.route.hidden = !origin && !destination;
if (
!validDestination ||
!Number.isFinite(info?.longitude) ||
!Number.isFinite(info?.latitude)
) {
this.clearPredictiveRoute();
return;
}
const destinationBearing = bearingBetweenCoordinates(
info.latitude,
info.longitude,
destination.lat,
destination.lon,
);
const relative = relativeBearing(
destinationBearing,
this.heading ?? info.track ?? 0,
);
if (!Number.isFinite(destinationBearing) || !Number.isFinite(relative)) {
this.clearPredictiveRoute();
return;
}
if (this.routeDirection) {
const displayedRelative = Math.max(-120, Math.min(120, relative));
this.routeDirection.hidden = false;
this.routeDirection.style.setProperty(
'--route-angle',
`${displayedRelative.toFixed(2)}deg`,
);
}
if (this.routeDirectionLabel) {
this.routeDirectionLabel.textContent = `DEST ${String(Math.round(destinationBearing)).padStart(3, '0')}°`;
}
}
export function syncWeatherToggle(enabled) {
if (!this.weatherToggle) return;
const active = !!enabled;
this.weatherToggle.setAttribute('aria-pressed', String(active));
this.weatherToggle.setAttribute(
'aria-label',
`${active ? 'Disable' : 'Enable'} cockpit weather effects`,
);
this.weatherToggle.title = `${active ? 'Disable' : 'Enable'} cockpit weather effects`;
if (this.weatherState) this.weatherState.textContent = active ? 'ON' : 'OFF';
}
export function setVisionMode(mode, { revealParameters = false } = {}) {
const next = normalizeCockpitVisionMode(mode);
this.visionMode = next;
const inherited = String(
this.getInheritedVisionLabel?.() || 'NORMAL',
).toUpperCase();
const labels = {
optical: inherited,
crt: 'CRT',
nvg: 'NVG',
thermal: 'FLIR',
noir: 'NOIR',
};
const names = {
optical: inherited,
crt: 'CRT',
nvg: 'Night vision',
thermal: 'Thermal',
noir: 'Noir',
};
if (this.visionCurrent) {
this.visionCurrent.dataset.cockpitVision = next;
this.visionCurrent.setAttribute(
'aria-label',
`Current cockpit vision style: ${names[next]}. Activate for next style.`,
);
this.visionCurrent.title = `Current style: ${names[next]} — click for next`;
}
if (this.visionCurrentLabel)
this.visionCurrentLabel.textContent = labels[next];
this.onVisionChange?.(next, this.active, { revealParameters });
}
export function cycleVisionMode(direction = 1) {
const modes = COCKPIT_VISION_MODES;
const currentIndex = Math.max(0, modes.indexOf(this.visionMode));
const step = direction < 0 ? -1 : 1;
const nextIndex = (currentIndex + step + modes.length) % modes.length;
this.setVisionMode(modes[nextIndex], { revealParameters: true });
}
export function clearPredictiveRoute() {
if (this.routeDirection) this.routeDirection.hidden = true;
}
+213
View File
@@ -0,0 +1,213 @@
/** Measure Cockpit panels against visible HUD and viewport obstacles. */
import {
resolveCockpitUtilityAnchor,
resolveCockpitUtilityLayout,
} from '../cockpitUtilityLayout.js';
import {
COCKPIT_UTILITY_REC_GAP_PX,
COCKPIT_UTILITY_SIGNAL_GAP_PX,
COCKPIT_UTILITY_MIN_TOP_PX,
COCKPIT_UTILITY_MIN_TOP_RATIO,
COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX,
isRenderedOnScreen,
} from './cockpitPresentation.js';
export function scheduleContextLayout() {
if (this.destroyed) return false;
const contextVisible = this.context && !this.context.hidden;
const signalVisible = this.signalStream && !this.signalStream.hidden;
if ((!contextVisible && !signalVisible) || this.contextLayoutFrame !== null)
return;
this.contextLayoutFrame = requestAnimationFrame(() => {
this.contextLayoutFrame = null;
if (this.destroyed) return;
this.syncContextLayout();
this.syncSignalLayout();
});
}
export function setContextCollapsed(collapsed) {
const wasCollapsed = this.contextCollapsed;
this.contextCollapsed = Boolean(collapsed);
if (this.context)
this.context.dataset.collapsed = String(this.contextCollapsed);
if (this.contextToggle) {
const expanded = !this.contextCollapsed;
this.contextToggle.setAttribute('aria-expanded', String(expanded));
this.contextToggle.setAttribute(
'aria-label',
`${expanded ? 'Collapse' : 'Expand'} Contact panel`,
);
this.contextToggle.title = `${expanded ? 'Collapse' : 'Expand'} contact panel`;
const icon = this.contextToggle.querySelector('.material-symbols-outlined');
if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right';
}
if (this.active && wasCollapsed && !this.contextCollapsed) {
window.dispatchEvent(new CustomEvent('gev:cockpit-context-expanded'));
}
this.scheduleContextLayout();
}
export function setSignalCollapsed(collapsed, { user = false } = {}) {
const wasCollapsed = this.signalCollapsed;
this.signalCollapsed = Boolean(collapsed);
if (user) this.signalUserCollapsed = this.signalCollapsed;
if (this.signalStream)
this.signalStream.dataset.collapsed = String(this.signalCollapsed);
if (this.signalToggle) {
const expanded = !this.signalCollapsed;
this.signalToggle.setAttribute('aria-expanded', String(expanded));
this.signalToggle.setAttribute(
'aria-label',
`${expanded ? 'Collapse' : 'Expand'} cockpit briefing panel`,
);
this.signalToggle.title = `${expanded ? 'Collapse' : 'Expand'} briefing panel`;
const icon = this.signalToggle.querySelector('.material-symbols-outlined');
if (icon)
icon.textContent = expanded ? 'right_panel_close' : 'right_panel_open';
}
if (this.signalCollapsed) this.stopBriefRotation();
else this.startBriefRotation({ reset: true });
if (this.active && wasCollapsed && !this.signalCollapsed) {
window.dispatchEvent(new CustomEvent('gev:cockpit-signal-expanded'));
}
this.scheduleContextLayout();
}
export function syncContextLayout() {
if (!this.context || this.context.hidden) return;
if (window.matchMedia('(max-width: 760px)').matches) {
this.context.dataset.layoutMode = 'compact-bottom';
this.context.style.removeProperty('--cockpit-context-left');
this.context.style.removeProperty('--cockpit-context-top');
this.context.style.removeProperty('--cockpit-context-max-height');
return;
}
const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04));
this.context.dataset.layoutMode = 'bottom-left';
this.context.style.setProperty(
'--cockpit-context-left',
`${desktopInset.toFixed(1)}px`,
);
this.context.style.removeProperty('--cockpit-context-top');
this.context.style.removeProperty('--cockpit-context-max-height');
}
export function syncSignalLayout() {
if (!this.signalStream || this.signalStream.hidden) return;
const utilityControls = document.getElementById('cockpit-utility-controls');
if (window.matchMedia('(max-width: 760px)').matches) {
this.signalStream.dataset.layoutMode = 'compact-top';
utilityControls?.classList.remove('layout-primary-only');
utilityControls
?.querySelectorAll('.cockpit-utility-control')
.forEach((control) => {
const hiddenSibling = Boolean(
utilityControls.querySelector(
'.cockpit-utility-control.is-expanded',
) && !control.classList.contains('is-expanded'),
);
control.setAttribute('aria-hidden', String(hiddenSibling));
});
this.hud?.style.removeProperty('--cockpit-utility-top');
this.hud?.style.removeProperty('--cockpit-utility-max-height');
this.hud?.style.removeProperty('--cockpit-utility-expanded-max-height');
this.signalStream.style.removeProperty('--cockpit-signal-right');
this.signalStream.style.removeProperty('--cockpit-signal-top');
this.signalStream.style.removeProperty('--cockpit-signal-max-height');
return;
}
const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04));
this.signalStream.dataset.layoutMode = 'bottom-right';
this.signalStream.style.setProperty(
'--cockpit-signal-right',
`${desktopInset.toFixed(1)}px`,
);
this.signalStream.style.removeProperty('--cockpit-signal-top');
this.signalStream.style.removeProperty('--cockpit-signal-max-height');
const signalBounds = this.signalStream.getBoundingClientRect();
const utilityBounds = utilityControls?.getBoundingClientRect();
if (utilityBounds) {
const expandedControl = utilityControls?.querySelector(
'.cockpit-utility-control.is-expanded',
);
const collapsedControl = utilityControls?.querySelector(
'.cockpit-utility-control:not(.is-expanded)',
);
const collapsedLauncher = collapsedControl?.querySelector(
'.cockpit-utility-launcher',
);
const expandedHeight = expandedControl
? Math.max(
expandedControl.scrollHeight,
expandedControl.getBoundingClientRect().height,
)
: 0;
const collapsedHeight = collapsedLauncher
? Math.max(
COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX,
collapsedLauncher.scrollHeight,
)
: 0;
// Cockpit owns this anchor outright. The strip used to inherit the left
// accordion's committed top, which is solved against left-lane obstacles
// and dropped the strip straight through the briefing card below it.
// The readout only anchors the strip while it is genuinely on screen:
// the Minimal variant drops it with `display:none`, but HUD Off hides the
// whole Intel HUD with `visibility`/`opacity`, which keeps its rect.
const recReadout = document.querySelector('#intel-hud .hud-top-right');
const recBounds = isRenderedOnScreen(recReadout)
? recReadout.getBoundingClientRect()
: null;
const utilityAnchor = resolveCockpitUtilityAnchor({
recBottom: recBounds ? recBounds.bottom : 0,
signalTop: signalBounds.top,
stripHeight: utilityBounds.height,
viewportHeight: window.innerHeight,
collapsedHeight,
recGap: COCKPIT_UTILITY_REC_GAP_PX,
signalGap: COCKPIT_UTILITY_SIGNAL_GAP_PX,
minTopFloor: COCKPIT_UTILITY_MIN_TOP_PX,
minTopRatio: COCKPIT_UTILITY_MIN_TOP_RATIO,
});
const availableHeight = utilityAnchor.maxHeight;
this.hud?.style.setProperty(
'--cockpit-utility-top',
`${utilityAnchor.top.toFixed(1)}px`,
);
this.hud?.style.setProperty(
'--cockpit-utility-max-height',
`${availableHeight.toFixed(2)}px`,
);
const utilityLayout =
expandedControl && collapsedControl
? resolveCockpitUtilityLayout({
availableHeight,
expandedHeight,
collapsedHeight,
})
: { primaryOnly: false, expandedMaxHeight: availableHeight };
utilityControls?.classList.toggle(
'layout-primary-only',
utilityLayout.primaryOnly,
);
this.hud?.style.setProperty(
'--cockpit-utility-expanded-max-height',
`${utilityLayout.expandedMaxHeight.toFixed(2)}px`,
);
utilityControls
?.querySelectorAll('.cockpit-utility-control')
.forEach((control) => {
const hiddenSibling =
utilityLayout.primaryOnly && control === collapsedControl;
control.setAttribute('aria-hidden', String(hiddenSibling));
if (hiddenSibling && control.contains(document.activeElement)) {
expandedControl
?.querySelector('.cockpit-utility-glyph')
?.focus({ preventScroll: true });
}
});
}
}
+188
View File
@@ -0,0 +1,188 @@
/** Pure formatting and fixed presentation constants used by Cockpit components. */
export const COCKPIT_HEADING_SLEW_DPS = 28;
export const COCKPIT_FORWARD_OFFSET_M = 7;
export const COCKPIT_UP_OFFSET_M = 2.6;
export const COCKPIT_MIN_GROUND_CLEARANCE_M = 12;
export const COCKPIT_VIEW_PITCH_DEG = -4;
export const COCKPIT_CAMERA_UPDATE_MS = 50;
export const COCKPIT_HUD_UPDATE_MS = 100;
export const COCKPIT_CONTEXT_UPDATE_MS = 250;
export const COCKPIT_UTILITY_REC_GAP_PX = 12;
export const COCKPIT_UTILITY_SIGNAL_GAP_PX = 8;
export const COCKPIT_UTILITY_MIN_TOP_PX = 96;
export const COCKPIT_UTILITY_MIN_TOP_RATIO = 0.12;
export const COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX = 50;
export const COCKPIT_GROUND_PROBE_MS = 500;
export const COCKPIT_GROUND_WAIT_TIMEOUT_MS = 5000;
export const COCKPIT_BRIEF_ROTATE_MS = 9000;
export const COCKPIT_BRIEF_CYCLE_OFF_HELP =
'Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.';
export const COCKPIT_BRIEF_CYCLE_ON_HELP =
'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.';
export const COCKPIT_REGIONAL_REFRESH_MS = 5 * 60_000;
export const COCKPIT_REGIONAL_REFRESH_DISTANCE_M = 25_000;
export const COCKPIT_BRIEF_PAGES = [
{
id: 'signals',
kicker: 'LIVE SIGNALS',
subtitle: 'OBSERVED / MAPPED PINGS',
source: 'SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS',
},
{
id: 'news',
kicker: 'REGIONAL NEWS',
subtitle: 'LATEST LOCATION-MATCHED REPORTING',
source: 'GOOGLE NEWS RSS · LOCATION QUERY · RECENT',
},
{
id: 'local',
kicker: 'LOCAL INFO',
subtitle: 'PLACE / CONDITIONS / POSITION',
source: 'OPENSTREETMAP · OPEN-METEO · UTC',
},
];
export function isRenderedOnScreen(element) {
if (!element) return false;
for (let node = element; node instanceof Element; node = node.parentElement) {
const style = getComputedStyle(node);
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
Number(style.opacity) === 0
) {
return false;
}
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
export function formatCockpitBriefAge(value) {
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return 'TIME UNKNOWN';
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
if (minutes < 60) return `${minutes}M AGO`;
const hours = Math.round(minutes / 60);
return hours < 48 ? `${hours}H AGO` : `${Math.round(hours / 24)}D AGO`;
}
export function formatCockpitWindDirection(value) {
if (!Number.isFinite(value)) return 'DIR UNKNOWN';
const labels = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
const normalized = ((value % 360) + 360) % 360;
return `${labels[Math.round(normalized / 45) % labels.length]} · ${Math.round(normalized)}°`;
}
export function setCockpitRollingValue(
element,
text,
numericValue,
{ circularRange = null, immediate = false } = {},
) {
if (!element) return;
const nextText = String(text);
const previousText = element.dataset.rollingText;
const previousValue = Number(element.dataset.rollingValue);
const nowMs = performance.now();
const lastRollMs = Number(element.dataset.rollingAt);
if (
!immediate &&
previousText !== undefined &&
previousText !== nextText &&
Number.isFinite(lastRollMs) &&
nowMs - lastRollMs < 220
) {
return;
}
element.dataset.rollingText = nextText;
element.dataset.rollingAt = String(nowMs);
if (Number.isFinite(numericValue))
element.dataset.rollingValue = String(numericValue);
else delete element.dataset.rollingValue;
element.setAttribute('aria-label', nextText);
if (immediate || previousText === undefined || previousText === nextText) {
if (
previousText !== nextText ||
!element.querySelector('.cockpit-roll-token')
) {
element.replaceChildren(
...Array.from(nextText, (character) => {
const token = document.createElement('span');
token.className = 'cockpit-roll-token';
token.setAttribute('aria-hidden', 'true');
token.textContent = character;
return token;
}),
);
}
return;
}
let delta =
Number.isFinite(numericValue) && Number.isFinite(previousValue)
? numericValue - previousValue
: 0;
if (Number.isFinite(circularRange) && circularRange > 0) {
const halfRange = circularRange / 2;
if (delta > halfRange) delta -= circularRange;
else if (delta < -halfRange) delta += circularRange;
}
const direction = delta < 0 ? 'down' : 'up';
const width = Math.max(previousText.length, nextText.length);
const from = previousText.padStart(width, ' ');
const to = nextText.padStart(width, ' ');
const fragment = document.createDocumentFragment();
for (let index = 0; index < width; index += 1) {
const previousCharacter = from[index];
const nextCharacter = to[index];
const token = document.createElement('span');
token.className = 'cockpit-roll-token';
token.setAttribute('aria-hidden', 'true');
if (
previousCharacter === nextCharacter ||
!/\d/.test(previousCharacter) ||
!/\d/.test(nextCharacter)
) {
token.textContent = nextCharacter === ' ' ? '\u00a0' : nextCharacter;
fragment.append(token);
continue;
}
token.classList.add('is-rolling', `roll-${direction}`);
const track = document.createElement('span');
track.className = 'cockpit-roll-track';
const first = document.createElement('span');
const second = document.createElement('span');
first.textContent = direction === 'up' ? previousCharacter : nextCharacter;
second.textContent = direction === 'up' ? nextCharacter : previousCharacter;
track.append(first, second);
token.append(track);
fragment.append(token);
}
element.replaceChildren(fragment);
}
+198
View File
@@ -0,0 +1,198 @@
/** Reconcile actionable signal rows without disturbing native keyboard focus. */
export function renderCockpitSignals() {
if (!this.signalList) return;
const existing = [...this.signalList.children];
const focusedEntry = existing.find((entry) =>
entry.contains(document.activeElement),
);
const entriesByKey = new Map();
for (const entry of existing) {
const key = entry.dataset.signalKey;
if (!entriesByKey.has(key)) entriesByKey.set(key, []);
entriesByKey.get(key).push(entry);
}
const setText = (element, value) => {
if (element.textContent !== value) element.textContent = value;
};
const entries = this.signalItems.map((item) => {
// Identity includes the action target: a reused status key must never
// silently turn a focused flight button into a different selection.
const key = JSON.stringify([
item.key,
Boolean(item.target),
item.target?.layerId || '',
String(item.target?.id ?? ''),
]);
let entry = entriesByKey.get(key)?.shift();
if (!entry) {
entry = document.createElement('li');
entry.dataset.signalKey = key;
const time = document.createElement('time');
const body = document.createElement('div');
const heading = document.createElement(item.target ? 'button' : 'strong');
if (item.target) {
heading.type = 'button';
heading.className = 'cockpit-signal-target';
const label = document.createElement('span');
label.className = 'cockpit-signal-target-label';
const rule = document.createElement('span');
rule.className = 'cockpit-signal-target-rule';
rule.setAttribute('aria-hidden', 'true');
const chevron = document.createElement('span');
chevron.className =
'material-symbols-outlined cockpit-signal-target-chevron';
chevron.setAttribute('aria-hidden', 'true');
chevron.textContent = 'chevron_right';
heading.append(label, rule, chevron);
}
body.append(heading, document.createElement('span'));
entry.append(time, body);
}
const [time, body] = entry.children;
const [heading, copy] = body.children;
const className = item.target ? `${item.tone} actionable` : item.tone;
if (entry.className !== className) entry.className = className;
setText(time, new Date(item.timestamp).toISOString().slice(11, 19) + 'Z');
if (item.target) {
heading.dataset.signalLayer = item.target.layerId;
heading.dataset.signalId = item.target.id;
const label = `Select flight ${item.title}`;
if (heading.getAttribute('aria-label') !== label)
heading.setAttribute('aria-label', label);
setText(heading.children[0], item.title);
} else {
setText(heading, item.title);
}
setText(copy, item.detail);
return entry;
});
const retainedFocus = entries.includes(focusedEntry) ? focusedEntry : null;
if (focusedEntry && !retainedFocus) {
// A departed contact cannot remain selectable. Continue from the stable
// briefing footer instead of dropping keyboard traversal to the page top.
(this.briefTabs?.[this.briefPageIndex] || this.signalToggle)?.focus({
preventScroll: true,
});
}
for (const entry of existing) if (!entries.includes(entry)) entry.remove();
// Ordinary insertBefore moves disconnect an element and lose its focus.
// Reorder the other rows around the focused row, which stays connected.
const focusIndex = retainedFocus ? entries.indexOf(retainedFocus) : -1;
if (retainedFocus) {
let anchor = retainedFocus;
for (let index = focusIndex - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (entry.nextElementSibling !== anchor)
this.signalList.insertBefore(entry, anchor);
anchor = entry;
}
}
let anchor = retainedFocus
? retainedFocus.nextElementSibling
: this.signalList.firstElementChild;
for (let index = focusIndex + 1; index < entries.length; index += 1) {
const entry = entries[index];
if (entry === anchor) anchor = anchor.nextElementSibling;
else this.signalList.insertBefore(entry, anchor);
}
this.scheduleContextLayout();
}
export function pushCockpitSignal(key, tone, title, detail, target = null) {
if (!this.signalList || !title || !detail) return;
const signature = `${title}|${detail}|${target?.layerId || ''}|${target?.id || ''}`;
if (this.signalSignatures.get(key) === signature) return;
this.signalSignatures.set(key, signature);
this.signalItems.unshift({
key,
tone,
title,
detail,
target,
timestamp: Date.now(),
});
this.signalItems = this.signalItems.slice(0, 5);
this.renderCockpitSignals();
}
export function updateCockpitSignals(snapshot, unknownCount) {
const previous = new Map(this.signalItems.map((item) => [item.key, item]));
const contacts = [];
const subject = snapshot.subject;
if (['flights', 'military'].includes(subject?.layerId) && subject?.id) {
contacts.push({
key: `flight:${subject.layerId}:${subject.id}`,
tone: 'track',
title: subject.label || subject.id,
detail: `${subject.layerId === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · CURRENT`,
target: { layerId: subject.layerId, id: String(subject.id) },
distanceM: -1,
});
}
for (const cohort of snapshot.cohorts) {
if (!['flights', 'military'].includes(cohort.id)) continue;
for (const item of cohort.nearest) {
const id = item.icao24 || item.id;
if (!id) continue;
contacts.push({
key: `flight:${cohort.id}:${id}`,
tone: cohort.id === 'military' ? 'nearby military' : 'nearby',
// `id` above is IDENTITY (the ICAO hex). Display must not reuse it:
// the row's own `id` already carries the layer's label convention
// (callsign → registration → hex), so a callsign-less enriched
// contact reads as its registration here too. Same helper the
// Context panel's nearest list uses.
title: this.services.formatAwarenessLabel(item),
detail: `${cohort.id === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · ${
Number.isFinite(item.distanceM)
? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM`
: 'DISTANCE UNKNOWN'
}`,
target: { layerId: cohort.id, id: String(id) },
distanceM: item.distanceM ?? Infinity,
});
}
}
contacts.sort((a, b) => a.distanceM - b.distanceM);
const nextItems = contacts.slice(0, 5).map((item) => ({
...item,
timestamp:
previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(),
}));
if (unknownCount) {
const sources = snapshot.cohorts
.filter((cohort) => cohort.count === null)
.map((cohort) => cohort.source)
.join(' · ');
nextItems.splice(4, Math.max(0, nextItems.length - 4), {
key: 'input-status',
tone: 'warning',
title: `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN`,
detail: sources || 'SOURCE STATUS UNAVAILABLE',
target: null,
timestamp:
previous.get('input-status')?.timestamp ||
snapshot.evaluatedAt ||
Date.now(),
});
}
this.signalItems = nextItems;
this.signalSignatures.clear();
this.renderCockpitSignals();
}
export function handleSignalClick(event) {
if (this.destroyed) return;
const target = event.target.closest(
'button[data-signal-layer][data-signal-id]',
);
if (!target) return;
event.preventDefault();
this.services.militaryAwarenessLayer.focusTarget?.(
target.dataset.signalLayer,
target.dataset.signalId,
{ origin: 'user' },
);
}
+254
View File
@@ -0,0 +1,254 @@
/** Tracking identity, entry, exit and contact navigation for the Cockpit controller. */
import {
normalizeHeading,
resolveTrackedAircraftInfo,
} from '../cockpitMath.js';
export function readAircraftInfo() {
// In cockpit mode the controller takes the entity off `viewer.trackedEntity`
// (see update()), so the cockpit's own handle is the tracked identity there.
const trackedEntity = this.viewer?.trackedEntity || this.trackedEntity;
return resolveTrackedAircraftInfo({
civilian: this.services.flightsLayer.getTrackedInfo?.() || null,
military: this.services.militaryFlightsLayer.getTrackedInfo?.() || null,
trackedId: trackedEntity?.gevTrackedId || '',
});
}
export function dispatchCockpitModeChanged(active, info = null) {
const subjectId = active
? String(info?.icao24 || '')
.trim()
.toLowerCase() || null
: null;
const layerId =
active && ['flights', 'military'].includes(info?.layerId)
? info.layerId
: null;
window.dispatchEvent(
new CustomEvent('gev:cockpit-mode-changed', {
detail: { active: active === true, subjectId, layerId },
}),
);
}
export function toggleTrackedTr3b() {
if (this.destroyed) return false;
const info = this.readAircraftInfo();
const icao24 = String(info?.icao24 || '').trim();
if (!icao24) return false;
this.services.toggleTr3b(icao24);
const layer =
info.layerId === 'military'
? this.services.militaryFlightsLayer
: this.services.flightsLayer;
layer.refreshTr3b?.(icao24);
this._tr3bSignature = null; // force the chip to repaint on the next sync
this.syncTr3bToggle(info);
return true;
}
export function syncTr3bToggle(info) {
if (!this.tr3bToggle) return;
const icao24 = String(info?.icao24 || '').trim();
const converted = !!icao24 && this.services.isTr3b(icao24);
const signature = icao24 ? `${icao24}:${converted ? 1 : 0}` : '';
if (this._tr3bSignature === signature) return;
this._tr3bSignature = signature;
this.tr3bToggle.hidden = !icao24;
this.tr3bToggle.setAttribute('aria-pressed', converted ? 'true' : 'false');
this.tr3bToggle.title = converted
? 'Restore real aircraft'
: 'Reclassify as TR-3B';
}
export function syncEntry() {
if (this.destroyed) return false;
if (this.active) return;
const info = this.readAircraftInfo();
const trackedContact = !!(info && this.viewer.trackedEntity?.position);
this.syncTr3bToggle(trackedContact ? info : null);
const available = !!(this.isEntryAllowed() && trackedContact);
// Change-only DOM writes: this runs on a preUpdate cadence, and
// unconditional `hidden` assignments invalidate style/layout every frame
// even when nothing changed. (perf item 9)
if (this._entryAvailable === available) return;
this._entryAvailable = available;
if (this.entry) this.entry.hidden = !available;
if (this.mapViewButton) this.mapViewButton.hidden = true;
if (this.resetGlobeButton) this.resetGlobeButton.hidden = true;
}
export function navigateContext(direction, options = {}) {
if (this.destroyed) return false;
const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';
const wasActive = this.active;
if (wasActive) this.contextNavigationDeadlineMs = performance.now() + 1500;
const navigationOptions = wasActive
? { ...options, aircraftOnly: true }
: options;
const changed = Boolean(
this.services.militaryAwarenessLayer?.[method]?.(navigationOptions),
);
if (!changed) {
this.contextNavigationDeadlineMs = 0;
return false;
}
if (wasActive) this._adoptTrackedEntity(performance.now());
return true;
}
export function _adoptTrackedEntity(nowMs, suppliedInfo = null) {
const nextEntity = this.viewer.trackedEntity;
if (
!this.active ||
!nextEntity?.position ||
nextEntity === this.trackedEntity
)
return false;
const info = suppliedInfo || this.readAircraftInfo();
if (!info) return false;
if (this.trackedEntity && this.viewer.entities.contains(this.trackedEntity)) {
this.trackedEntity.show = this.trackedEntityWasShown;
}
this.trackedEntity = nextEntity;
this.trackedEntityWasShown = nextEntity.show;
nextEntity.show = false;
this.viewer.trackedEntity = undefined;
this.cockpitAnchorValid = false;
this.heading = normalizeHeading(info.track ?? 0);
this.lastFrameMs = nowMs;
this.lastHudUpdateMs = 0;
this.lastContextUpdateMs = 0;
this.lastCameraUpdateMs = 0;
this.contextNavigationDeadlineMs = 0;
this.dispatchCockpitModeChanged(true, info);
return true;
}
export function enter() {
if (this.destroyed) return false;
if (this.active) return false;
if (!this.isEntryAllowed()) return false;
const info = this.readAircraftInfo();
const entity = this.viewer.trackedEntity;
if (!info || !entity?.position) return false;
this.entryFocusOrigin =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
// Retire deferred navigation before cancelFlight can run its callbacks.
this.onCameraTakeover?.();
this.viewer.camera.cancelFlight();
this.trackedEntity = entity;
this.trackedEntityWasShown = entity.show;
entity.show = false;
this.heading = normalizeHeading(info.track ?? 0);
this.lastFrameMs = performance.now();
this.lastCameraUpdateMs = 0;
this.lastHudUpdateMs = 0;
this.lastContextUpdateMs = 0;
this.contextNavigationDeadlineMs = 0;
this.lastGroundProbeMs = 0;
this.surfaceWaitStartedMs = performance.now();
this.surfaceAcquiring = false;
this.surfaceFallback = false;
this.lastCompassSignature = '';
this.cockpitAnchorValid = false;
this.lastCameraUpdateMs = 0;
this.active = true;
// Cockpit animates the camera from preUpdate every frame — preUpdate only
// runs on rendered frames, so idle mode would freeze the cockpit solid.
// (perf wave 2)
this.services.holdContinuousRender('cockpit');
this.viewer.trackedEntity = undefined;
this.viewer.scene.screenSpaceCameraController.enableInputs = false;
document.body.classList.add('cockpit-mode');
// Activation writes entry/quick/map visibility directly, bypassing
// syncEntry's change-only cache — invalidate it so the exit-path
// syncEntry re-applies every write (notably re-hiding mapViewButton).
this._entryAvailable = undefined;
if (this.entry) this.entry.hidden = true;
if (this.tr3bToggle) {
this.tr3bToggle.hidden = true;
this._tr3bSignature = null;
}
if (this.mapViewButton) this.mapViewButton.hidden = false;
if (this.resetGlobeButton) this.resetGlobeButton.hidden = false;
if (this.hud) this.hud.hidden = false;
if (this.signalStream) this.signalStream.hidden = false;
this.hud?.classList.add('signals-active');
this.signalItems = [];
this.signalSignatures.clear();
this.showBriefPage(0);
this.startBriefRotation();
const trackLabel =
info.callsign || info.registration || info.icao24 || 'AIRCRAFT';
const trackHeading = String(
Math.round(normalizeHeading(info.track ?? 0)),
).padStart(3, '0');
this.pushCockpitSignal(
'track',
'track',
'TRACK ACQUIRED',
`${trackLabel} · COURSE ${trackHeading}°`,
);
this.updateHud(info, performance.now(), true);
this.setVisionMode(this.visionMode);
this.scheduleContextLayout();
this.mapViewButton?.focus({ preventScroll: true });
this.onEntered?.();
this.dispatchCockpitModeChanged(true, info);
return true;
}
export function exit({ restoreTracking = true } = {}) {
if (!this.active) return false;
const entity = this.trackedEntity;
this.active = false;
this.services.releaseContinuousRender('cockpit');
this.trackedEntity = null;
this.heading = null;
this.cockpitAnchorValid = false;
this.surfaceWaitStartedMs = 0;
this.surfaceAcquiring = false;
this.surfaceFallback = false;
this.lastHudUpdateMs = 0;
this.lastContextUpdateMs = 0;
this.contextNavigationDeadlineMs = 0;
this.lastCompassSignature = '';
this.stopBriefRotation();
this.regionalBriefAbort?.abort();
this.regionalBriefAbort = null;
this.regionalBriefRequestToken += 1;
this.regionalBriefSubjectId = null;
document.body.classList.remove('cockpit-mode');
this.onExited?.();
this.hud?.style.removeProperty('--cockpit-utility-top');
this.hud?.style.removeProperty('--cockpit-utility-max-height');
if (this.hud) this.hud.hidden = true;
if (this.route) this.route.hidden = true;
this.clearPredictiveRoute();
this.setVisionMode('optical');
if (this.signalStream) this.signalStream.hidden = true;
this.hud?.classList.remove('signals-active');
this.viewer.scene.screenSpaceCameraController.enableInputs = true;
if (entity && this.viewer.entities.contains(entity))
entity.show = this.trackedEntityWasShown;
this.trackedEntityWasShown = true;
this.dispatchCockpitModeChanged(false);
if (restoreTracking && entity && this.viewer.entities.contains(entity)) {
this.viewer.trackedEntity = entity;
this.restoreTrackingFrame(entity);
}
this.syncEntry();
const restoreTarget =
this.entryFocusOrigin === this.entry
? this.entry
: this.entry || this.entryFocusOrigin;
this.entryFocusOrigin = null;
if (!this.destroyed && restoreTarget?.isConnected && !restoreTarget.hidden) {
restoreTarget.focus({ preventScroll: true });
}
return true;
}