diff --git a/CHANGELOG.md b/CHANGELOG.md
index d3b655b91..5bae06ad2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md
index bea9fd09a..2c89f7ff4 100644
--- a/docs/CODE-BOUNDARIES.md
+++ b/docs/CODE-BOUNDARIES.md
@@ -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.
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index d2d624a2b..a383d4012 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -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,
diff --git a/package.json b/package.json
index 4cca89785..b8d871123 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
}
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 77e87bc49..29b3d5138 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -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"
]
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 3ebec17a5..43373f43e 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -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"]
}
}
diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs
index 6e75e8e26..2808adfa0 100644
--- a/scripts/qa-ui-disposal.mjs
+++ b/scripts/qa-ui-disposal.mjs
@@ -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',
diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs
index e92ec2a56..3ef065443 100644
--- a/src/cameraHandoff.test.mjs
+++ b/src/cameraHandoff.test.mjs
@@ -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');
diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs
index af93a0c65..adfea26bc 100644
--- a/src/cockpitMarkup.test.mjs
+++ b/src/cockpitMarkup.test.mjs
@@ -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', () => {
diff --git a/src/cockpitMath.js b/src/cockpitMath.js
index fc3e2135f..a0bd83676 100644
--- a/src/cockpitMath.js
+++ b/src/cockpitMath.js
@@ -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
diff --git a/src/cockpitSignalFocus.test.mjs b/src/cockpitSignalFocus.test.mjs
index 56d5c9d33..57e9ecd6f 100644
--- a/src/cockpitSignalFocus.test.mjs
+++ b/src/cockpitSignalFocus.test.mjs
@@ -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');
diff --git a/src/cockpitUtilityLayout.js b/src/cockpitUtilityLayout.js
index dea0a77a3..0d6c09327 100644
--- a/src/cockpitUtilityLayout.js
+++ b/src/cockpitUtilityLayout.js
@@ -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({
),
};
}
-
diff --git a/src/cockpitVisionPolicy.js b/src/cockpitVisionPolicy.js
index 40602df2e..c49aa35da 100644
--- a/src/cockpitVisionPolicy.js
+++ b/src/cockpitVisionPolicy.js
@@ -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',
diff --git a/src/data/cockpitDetectionSubjects.test.mjs b/src/data/cockpitDetectionSubjects.test.mjs
index 5b43167ab..12727e0b1 100644
--- a/src/data/cockpitDetectionSubjects.test.mjs
+++ b/src/data/cockpitDetectionSubjects.test.mjs
@@ -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 [
diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs
index c44be1b97..cbcd4f591 100644
--- a/src/panelEscape.test.mjs
+++ b/src/panelEscape.test.mjs
@@ -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;/,
diff --git a/src/ui.js b/src/ui.js
index 72e5f8a4c..caae0a196 100644
--- a/src/ui.js
+++ b/src/ui.js
@@ -1,3 +1,4 @@
+import { CockpitViewController, CockpitDisplayPortal } from './ui/cockpit.js';
import { ContextControls } from './ui/context.js';
import { CctvControls } from './ui/cctv.js';
import { RadioControls } from './ui/radio.js';
@@ -10,13 +11,8 @@ import { bindApplicationShortcuts, createStyleParameters } from './ui/visualInpu
import { layoutLeftPanelRail, layoutRightPanelRail } from './ui/panelRails.js';
import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js';
import * as Cesium from 'cesium';
-import {
- BLOOM_INTENSITY_DEFAULT,
- BLOOM_SCALE_VERSION,
- clampBloomIntensity,
- decodeBloomIntensity,
-} from './bloom.js';
-import { LOCATIONS, CITY_POIS, GLOBE_VIEW, flyToGlobeView, flyToPresetLocation, flyToPOI, searchAndFlyTo } from './locations.js';
+import { BLOOM_SCALE_VERSION, clampBloomIntensity, decodeBloomIntensity } from './bloom.js';
+import { CITY_POIS, GLOBE_VIEW, flyToGlobeView, flyToPresetLocation, flyToPOI, searchAndFlyTo } from './locations.js';
import { interruptCameraMotion } from './cameraVerbs.js';
import {
aircraftTrackingTarget,
@@ -70,31 +66,11 @@ import aisLiveVesselsLayer from './data/aisLiveVessels.js';
import militaryAwarenessLayer from './data/militaryAwareness.js';
import militaryInstallationsLayer from './data/militaryInstallations.js';
import rocketLaunchesLayer from './data/rocketLaunches.js';
-import {
- aggregateLayerLoading,
- canPresentDeferredStatusNotice,
- createGlobalStatusNotice,
- createLoadingFeedbackState,
- createTrafficSyncFeedbackState,
- presentGlobalLoadingStatus,
- presentGlobalStatusNotice,
- presentLoadingFeedback,
- reduceLoadingFeedback,
- reduceTrafficSyncFeedback,
-} from './loadingFeedback.js';
+import { aggregateLayerLoading, canPresentDeferredStatusNotice, createGlobalStatusNotice, createLoadingFeedbackState, createTrafficSyncFeedbackState, presentGlobalLoadingStatus, reduceLoadingFeedback, reduceTrafficSyncFeedback } from './loadingFeedback.js';
import { setSplitFlapText } from './splitFlap.js';
import { cockpitEntryAllowed } from './contextModePolicy.js';
-import {
- resolveCockpitUtilityAnchor,
- resolveCockpitUtilityLayout,
-} from './cockpitUtilityLayout.js';
-import {
- applyCockpitVisionStageIntensities,
- captureCockpitVisionBaseline,
- COCKPIT_VISION_MODES,
- normalizeCockpitVisionMode,
-} from './cockpitVisionPolicy.js';
+import { applyCockpitVisionStageIntensities, captureCockpitVisionBaseline, normalizeCockpitVisionMode } from './cockpitVisionPolicy.js';
import {
applyContactsDetection,
shareCacheNeedsHeal,
@@ -141,28 +117,6 @@ import {
regionalDistanceM,
weatherCodeLabel,
} from './data/regionalBrief.js';
-import {
- altitudeRulerCurveInset,
- altitudeRulerTicks,
- bearingBetweenCoordinates,
- cockpitAnchorCorrectionStep,
- cockpitAltitudeDisplayFt,
- cockpitGroundSafeHeight,
- cockpitSurfaceWaitExpired,
- cockpitUiUpdateDue,
- compassDivisions,
- formatAltitudeRulerTick,
- formatCockpitContextScope,
- formatCompassDivision,
- formatSpeedRulerTick,
- normalizeHeading,
- relativeBearing,
- resolveCockpitContextReadout,
- resolveHudRailLayout,
- resolveTrackedAircraftInfo,
- slewHeading,
- speedRulerTicks,
-} from './cockpitMath.js';
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
@@ -196,49 +150,9 @@ const DETECTION_ALLOCATION_STORAGE_KEY = 'gev:detection-allocation:v1';
/** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */
const PANEL_Z_BASE = 100;
const PANEL_Z_MAX = 139;
-const COCKPIT_HEADING_SLEW_DPS = 28;
-const COCKPIT_FORWARD_OFFSET_M = 7;
-const COCKPIT_UP_OFFSET_M = 2.6;
-const COCKPIT_MIN_GROUND_CLEARANCE_M = 12;
-const COCKPIT_VIEW_PITCH_DEG = -4;
-const COCKPIT_CAMERA_UPDATE_MS = 50;
-const COCKPIT_HUD_UPDATE_MS = 100;
-const COCKPIT_CONTEXT_UPDATE_MS = 250;
+
const COCKPIT_LAYOUT_SETTLE_MS = 240;
-// Cockpit's Display/Radio strip hangs off the REC readout it shares the right
-// margin with, and never touches the briefing card underneath it.
-const COCKPIT_UTILITY_REC_GAP_PX = 12;
-const COCKPIT_UTILITY_SIGNAL_GAP_PX = 8;
-const COCKPIT_UTILITY_MIN_TOP_PX = 96;
-const COCKPIT_UTILITY_MIN_TOP_RATIO = 0.12;
-const COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX = 50;
-const COCKPIT_GROUND_PROBE_MS = 500;
-const COCKPIT_GROUND_WAIT_TIMEOUT_MS = 5000;
-const COCKPIT_BRIEF_ROTATE_MS = 9000;
-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.';
-const COCKPIT_BRIEF_CYCLE_ON_HELP = 'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.';
-const COCKPIT_REGIONAL_REFRESH_MS = 5 * 60_000;
-const COCKPIT_REGIONAL_REFRESH_DISTANCE_M = 25_000;
-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',
- },
-];
+
/**
* Fixed UI regions that can occupy the left accordion's vertical lane.
* Rectangles are filtered at runtime for visibility and horizontal overlap,
@@ -270,27 +184,6 @@ const LEFT_STACK_OBSTACLE_SELECTOR = [
'#pp-toggles',
'#param-slider-panel',
].join(', ');
-/**
- * Whether an element currently occupies screen space a layout must respect.
- *
- * A rect alone is not enough: `visibility: hidden` and `opacity: 0` (how the
- * Intel HUD retires as a whole) leave the rect intact, so anything anchoring to
- * a HUD readout must walk the ancestors as well.
- *
- * @param {Element|null|undefined} element - Candidate element.
- * @returns {boolean} True when the element is painted and has area.
- */
-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;
-}
/**
* Fixed UI regions that can occupy the right control lane. Runtime rectangle
* filtering keeps the rail clear of whichever HUD variant is currently
@@ -350,1634 +243,6 @@ const STYLE_STATUS_LABELS = {
* - Intel HUD lifecycle and variant switching.
*/
-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`;
-}
-
-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)}°`;
-}
-
-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);
-}
-
-class CockpitViewController {
- constructor(viewer, {
- onVisionChange = null,
- onCameraTakeover = null,
- isEntryAllowed = null,
- onEntered = null,
- onExited = null,
- getInheritedVisionLabel = null,
- restoreTrackingFrame = null,
- } = {}) {
- this.viewer = viewer;
- 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) => {
- const target = event.target.closest('button[data-signal-layer][data-signal-id]');
- if (!target) return;
- event.preventDefault();
- militaryAwarenessLayer.focusTarget?.(
- target.dataset.signalLayer,
- target.dataset.signalId,
- { origin: 'user' },
- );
- });
- 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));
- }
-
- 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';
- }
-
- 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: flightsLayer.getTrackedInfo?.() || null,
- military: militaryFlightsLayer.getTrackedInfo?.() || null,
- trackedId: trackedEntity?.gevTrackedId || '',
- });
- }
-
- 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 },
- }));
- }
-
- /**
- * TR-3B Easter egg: flip the tracked contact between its real silhouette and
- * the black triangle. The registry owns the session state; the owning layer
- * re-derives what renders.
- * @returns {boolean} True when a tracked contact was converted/restored.
- */
- toggleTrackedTr3b() {
- const info = this.readAircraftInfo();
- const icao24 = String(info?.icao24 || '').trim();
- if (!icao24) return false;
- toggleTr3b(icao24);
- const layer = info.layerId === 'military' ? militaryFlightsLayer : flightsLayer;
- layer.refreshTr3b?.(icao24);
- this._tr3bSignature = null; // force the chip to repaint on the next sync
- this.syncTr3bToggle(info);
- return true;
- }
-
- /**
- * Show the 🛸 chip whenever a contact is tracked (its own gate — converting
- * does not depend on the cockpit entry policy) and mirror the conversion
- * state. Change-only DOM writes: this runs on the preUpdate cadence.
- * @param {object|null} info Tracked-aircraft descriptor, or null.
- */
- syncTr3bToggle(info) {
- if (!this.tr3bToggle) return;
- const icao24 = String(info?.icao24 || '').trim();
- const converted = !!icao24 && 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';
- }
-
- syncEntry() {
- 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;
- }
-
- /**
- * Navigate Contacts through one Cockpit-owned funnel. A short grace window
- * covers feed-refresh handoffs where the old source releases before the new
- * source publishes its entity; trackedEntityChanged adopts synchronously as
- * soon as the replacement exists, so the normal path does not wait.
- */
- navigateContext(direction, options = {}) {
- 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(militaryAwarenessLayer?.[method]?.(navigationOptions));
- if (!changed) {
- this.contextNavigationDeadlineMs = 0;
- return false;
- }
- if (wasActive) this._adoptTrackedEntity(performance.now());
- return true;
- }
-
- /** Adopt a newly selected aircraft without ever leaving Cockpit. */
- _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;
- }
-
- 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 });
- }
-
- 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 });
- }
-
- clearPredictiveRoute() {
- if (this.routeDirection) this.routeDirection.hidden = true;
- }
-
- onKeyDown(event) {
- 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;
- }
- }
-
- enter() {
- 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)
- 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;
- }
-
- exit({ restoreTracking = true } = {}) {
- if (!this.active) return false;
- const entity = this.trackedEntity;
- this.active = false;
- 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 (restoreTarget?.isConnected && !restoreTarget.hidden) {
- restoreTarget.focus({ preventScroll: true });
- }
- return true;
- }
-
- update() {
- 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 = cachedGroundFloor(info.latitude, info.longitude);
- if (info.onGround === true) {
- const groundPoint = [{ lat: info.latitude, lon: info.longitude }];
- warmGroundFloor(groundPoint);
- const meshFloorM = cachedMeshFloor(info.latitude, info.longitude);
- if (meshFloorPreferred() && !Number.isFinite(meshFloorM)) {
- if (cockpitUiUpdateDue(nowMs, this.lastGroundProbeMs, COCKPIT_GROUND_PROBE_MS)) {
- this.lastGroundProbeMs = nowMs;
- const viewerCartographic = this.viewer.camera.positionCartographic;
- sampleMeshFloorCells(this.viewer.scene, groundPoint, {
- excludeObjects: [this.trackedEntity],
- viewerLat: Cesium.Math.toDegrees(viewerCartographic.latitude),
- viewerLon: Cesium.Math.toDegrees(viewerCartographic.longitude),
- });
- }
- cockpitFloorM = 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 - 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 = 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);
- }
- }
-
- 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 `${formatCompassDivision(division)}`;
- })
- .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';
- }
-
- 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')}°`;
- }
- }
-
- updateContext(info, heading) {
- if (!this.context) return;
- const snapshot = 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 = 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();
- }
- }
-
- scheduleContextLayout() {
- 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;
- this.syncContextLayout();
- this.syncSignalLayout();
- });
- }
-
- showBriefPage(index, { manual = false } = {}) {
- 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();
- }
-
- setBriefAutoRotate(enabled) {
- 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();
- }
-
- startBriefRotation({ reset = false } = {}) {
- if (reset) this.stopBriefRotation();
- if (!this.briefAutoRotateEnabled
- || this.briefTimer
- || !this.active
- || this.signalCollapsed
- || document.hidden) return;
- this.briefTimer = window.setTimeout(() => {
- this.briefTimer = null;
- 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);
- }
-
- stopBriefRotation() {
- if (this.briefTimer) window.clearTimeout(this.briefTimer);
- this.briefTimer = null;
- }
-
- 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}`;
- }
-
- maybeRefreshRegionalBrief(info) {
- 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 = 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);
- 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;
- });
- }
-
- 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);
- }
-
- 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 = 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();
- }
-
- 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();
- }
-
- 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();
- }
-
- 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: 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();
- }
-
- 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();
- }
-
- 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();
- }
-
- 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');
- }
-
- 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 });
- }
- });
- }
- }
-
- dispose() {
- this.exit({ restoreTracking: false });
- 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?.();
- }
-}
-
export class StyleManager {
/**
* @param {Cesium.Viewer} viewer - The CesiumJS viewer instance.
@@ -2113,8 +378,7 @@ export class StyleManager {
this._cockpitUtilityControls = document.getElementById('cockpit-utility-controls');
this._cockpitDisplayToggleBtn = document.getElementById('cockpit-display-toggle-btn');
this._cockpitDisplayPanel = document.getElementById('cockpit-display-panel');
- this._cockpitDisplayPortalRecords = [];
- this._cockpitDisplayPortalActive = false;
+ this._cockpitDisplayPortal = null;
this._cockpitDisplayModeHandler = null;
this._cockpitRadioToggleBtn = document.getElementById('cockpit-radio-toggle-btn');
this._cockpitRadioPanel = document.getElementById('cockpit-radio-panel');
@@ -2225,6 +489,25 @@ export class StyleManager {
/** Pre-Contacts detection state, restored on deactivation (see _syncContactsDetection). */
this._contactsDetectionRestore = null;
this.cockpitView = new CockpitViewController(viewer, {
+ services: {
+ flightsLayer,
+ militaryFlightsLayer,
+ isTr3b,
+ toggleTr3b,
+ militaryAwarenessLayer,
+ formatAwarenessLabel,
+ cachedGroundFloor,
+ cachedMeshFloor,
+ GROUND_FLOOR_LIFT_M,
+ meshFloorPreferred,
+ warmGroundFloor,
+ sampleMeshFloorCells,
+ holdContinuousRender,
+ releaseContinuousRender,
+ fetchRegionalBrief,
+ regionalDistanceM,
+ weatherCodeLabel,
+ },
onVisionChange: (mode, active, options) => this._setCockpitVision(mode, active, options),
onCameraTakeover: () => this._stampNavigation({ cancelPendingSelection: false }),
getInheritedVisionLabel: () => (
@@ -6691,40 +4974,21 @@ export class StyleManager {
* @returns {void}
*/
_initCockpitDisplayPortal() {
- const definitions = [
- ['hud', this._hudBtn?.closest('.pp-toggle-group')],
- ['detection', this._detectionBtn?.closest('.pp-toggle-group')],
- ['parameters', this._sliderPanel],
- ['models3d', this._models3dBtn?.closest('.pp-toggle-group')],
- ];
- this._cockpitDisplayPortalRecords = definitions.flatMap(([name, group]) => {
- const slot = this._cockpitDisplayPanel?.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._cockpitDisplayPortal?.destroy();
+ this._cockpitDisplayPortal = new CockpitDisplayPortal({
+ standardPanel: this._ppToggles,
+ cockpitPanel: this._cockpitDisplayPanel,
+ groups: [
+ ['hud', this._hudBtn?.closest('.pp-toggle-group')],
+ ['detection', this._detectionBtn?.closest('.pp-toggle-group')],
+ ['parameters', this._sliderPanel],
+ ['models3d', this._models3dBtn?.closest('.pp-toggle-group')],
+ ],
+ layout: () => {
+ this._layoutRightPanels();
+ this.cockpitView?.scheduleContextLayout();
+ },
});
- this._standardDisplayScrollTop = this._ppToggles?.scrollTop || 0;
- this._cockpitDisplayScrollTop = this._cockpitDisplayPanel?.scrollTop || 0;
- this._standardDisplayScrollHandler = () => {
- if (!this._cockpitDisplayPortalActive) {
- this._standardDisplayScrollTop = this._ppToggles?.scrollTop || 0;
- }
- };
- this._cockpitDisplayScrollHandler = () => {
- if (this._cockpitDisplayPortalActive) {
- this._cockpitDisplayScrollTop = this._cockpitDisplayPanel?.scrollTop || 0;
- }
- };
- this._ppToggles?.addEventListener('scroll', this._standardDisplayScrollHandler, { passive: true });
- this._cockpitDisplayPanel?.addEventListener('scroll', this._cockpitDisplayScrollHandler, { passive: true });
- this._cockpitDisplayModeHandler = (event) => {
- this._setCockpitDisplayPortalActive(event?.detail?.active === true);
- };
- window.addEventListener('gev:cockpit-mode-changed', this._cockpitDisplayModeHandler);
- this._setCockpitDisplayPortalActive(document.body.classList.contains('cockpit-mode'));
}
/**
@@ -6733,49 +4997,10 @@ export class StyleManager {
* @param {boolean} active Whether Cockpit owns the Display control groups.
* @returns {void}
*/
- _setCockpitDisplayPortalActive(active) {
- const nextActive = active === true;
- if (this._cockpitDisplayPortalActive === nextActive) return;
- const focusedRecord = this._cockpitDisplayPortalRecords.find((record) => (
- record.group.contains(document.activeElement)
- ));
- const focusedElement = focusedRecord ? document.activeElement : null;
- this._displayPortalScrollRestoreOwner = nextActive ? 'cockpit' : 'standard';
- this._cockpitDisplayPortalActive = nextActive;
- for (const record of this._cockpitDisplayPortalRecords) {
- if (nextActive) {
- record.slot.append(record.group);
- } else if (record.anchor.parentNode) {
- record.anchor.after(record.group);
- }
- }
- this._cockpitDisplayPanel?.classList.toggle('uses-shared-display-controls', nextActive);
- requestAnimationFrame(() => {
- if (nextActive && this._cockpitDisplayPanel) {
- this._cockpitDisplayPanel.scrollTop = this._cockpitDisplayScrollTop;
- }
- if (!nextActive && this._ppToggles) {
- this._ppToggles.scrollTop = this._standardDisplayScrollTop;
- }
- focusedElement?.focus?.({ preventScroll: true });
- // Portal movement can trigger one more adaptive-layout/clamp pass after
- // the first frame. Reapply the owning surface's saved position once the
- // new layout has fully settled.
- requestAnimationFrame(() => {
- if (nextActive && this._cockpitDisplayPanel) {
- this._cockpitDisplayPanel.scrollTop = this._cockpitDisplayScrollTop;
- }
- if (!nextActive && this._ppToggles) {
- this._ppToggles.scrollTop = this._standardDisplayScrollTop;
- }
- if (this._displayPortalScrollRestoreOwner === (nextActive ? 'cockpit' : 'standard')) {
- this._displayPortalScrollRestoreOwner = null;
- }
- });
- });
- this._layoutRightPanels();
- this.cockpitView?.scheduleContextLayout();
- }
+ _setCockpitDisplayPortalActive(active) { this._cockpitDisplayPortal?.setActive(active); }
+ get _cockpitDisplayPortalActive() { return this._cockpitDisplayPortal?.active ?? false; }
+ get _displayPortalScrollRestoreOwner() { return this._cockpitDisplayPortal?.restoreOwner ?? null; }
+ get _standardDisplayScrollTop() { return this._cockpitDisplayPortal?.standardScrollTop ?? 0; }
/**
* Syncs the HUD toggle button active class and HUD layout row visibility
@@ -6905,6 +5130,8 @@ export class StyleManager {
this._locationControls?.destroy();
this._cctvControls?.destroy();
this._radioControls?.destroy();
+ this.cockpitView?.stop();
+ this._cockpitDisplayPortal?.stop();
this._visualEffects.stop();
this._styleParameters?.destroy();
for (const control of this._panelDisclosureControls || []) control.destroy();
@@ -6968,17 +5195,8 @@ export class StyleManager {
this._irFogWasEnabled = null;
}
this.cockpitView?.dispose();
- if (this._cockpitDisplayModeHandler) {
- window.removeEventListener('gev:cockpit-mode-changed', this._cockpitDisplayModeHandler);
- this._cockpitDisplayModeHandler = null;
- }
- this._setCockpitDisplayPortalActive(false);
- this._ppToggles?.removeEventListener('scroll', this._standardDisplayScrollHandler);
- this._cockpitDisplayPanel?.removeEventListener('scroll', this._cockpitDisplayScrollHandler);
- this._standardDisplayScrollHandler = null;
- this._cockpitDisplayScrollHandler = null;
- for (const record of this._cockpitDisplayPortalRecords) record.anchor.remove();
- this._cockpitDisplayPortalRecords = [];
+ this._cockpitDisplayPortal?.destroy();
+ this._cockpitDisplayPortal = null;
this._contextControls.disconnect();
this._dataManagerUnsubscribe?.();
this._dataManagerUnsubscribe = null;
diff --git a/src/ui/cockpit.js b/src/ui/cockpit.js
new file mode 100644
index 000000000..1813dd24e
--- /dev/null
+++ b/src/ui/cockpit.js
@@ -0,0 +1,2 @@
+export { CockpitViewController } from './cockpitController.js';
+export { CockpitDisplayPortal } from './cockpitDisplayPortal.js';
diff --git a/src/ui/cockpitBriefing.js b/src/ui/cockpitBriefing.js
new file mode 100644
index 000000000..c467ed865
--- /dev/null
+++ b/src/ui/cockpitBriefing.js
@@ -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();
+}
diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js
new file mode 100644
index 000000000..c33ba3c15
--- /dev/null
+++ b/src/ui/cockpitCamera.js
@@ -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);
+ }
+}
diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js
new file mode 100644
index 000000000..70dac99f7
--- /dev/null
+++ b/src/ui/cockpitContext.js
@@ -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();
+ }
+}
diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js
new file mode 100644
index 000000000..b5f9077a8
--- /dev/null
+++ b/src/ui/cockpitController.js
@@ -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 });
+ }
+}
diff --git a/src/ui/cockpitController.test.mjs b/src/ui/cockpitController.test.mjs
new file mode 100644
index 000000000..c700bb537
--- /dev/null
+++ b/src/ui/cockpitController.test.mjs
@@ -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();
+ }
+});
diff --git a/src/ui/cockpitDisplayPortal.js b/src/ui/cockpitDisplayPortal.js
new file mode 100644
index 000000000..8c653c6f6
--- /dev/null
+++ b/src/ui/cockpitDisplayPortal.js
@@ -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;
+ }
+}
diff --git a/src/ui/cockpitDisplayPortal.test.mjs b/src/ui/cockpitDisplayPortal.test.mjs
new file mode 100644
index 000000000..c9837891c
--- /dev/null
+++ b/src/ui/cockpitDisplayPortal.test.mjs
@@ -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();
+ }
+});
diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js
new file mode 100644
index 000000000..aed87f483
--- /dev/null
+++ b/src/ui/cockpitInput.js
@@ -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;
+ }
+}
diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js
new file mode 100644
index 000000000..5cb0a558a
--- /dev/null
+++ b/src/ui/cockpitInstruments.js
@@ -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 `${formatCompassDivision(division)}`;
+ })
+ .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;
+}
diff --git a/src/ui/cockpitLayout.js b/src/ui/cockpitLayout.js
new file mode 100644
index 000000000..cb6af30e1
--- /dev/null
+++ b/src/ui/cockpitLayout.js
@@ -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 });
+ }
+ });
+ }
+}
diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js
new file mode 100644
index 000000000..c77d9cb5b
--- /dev/null
+++ b/src/ui/cockpitPresentation.js
@@ -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);
+}
diff --git a/src/ui/cockpitSignals.js b/src/ui/cockpitSignals.js
new file mode 100644
index 000000000..b8d6fe18b
--- /dev/null
+++ b/src/ui/cockpitSignals.js
@@ -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' },
+ );
+}
diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js
new file mode 100644
index 000000000..4170333a8
--- /dev/null
+++ b/src/ui/cockpitTrackingController.js
@@ -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;
+}