refactor: separate Cockpit controller and presentation components

This commit is contained in:
Sameh Khamis committed 2026-09-13 02:33:38 -07:00
1 parent 43db5c19bb
commit 8075c70e6e
24 files changed
+2258 -1928

No files matched your search

+3
View File
@@ -13,6 +13,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased] ## [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 terminal cleanup.
- Separate Context controls, mode transitions and layer restoration; release tab listeners and suppress late panel/search feedback after disposal. - 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. - Separate camera-panel controls, frame loading, calibration editing and status display; cancel stale image and calibration work on camera changes or disposal.
+10
View File
@@ -310,3 +310,13 @@ subscriptions. Composition supplies the manager, installations search and
explicit visual/panel actions. `ui/context/policy` exposes the existing pure explicit visual/panel actions. `ui/context/policy` exposes the existing pure
mode and restoration rules. No source transport or renderer is imported by mode and restoration rules. No source transport or renderer is imported by
these components; initial state and action results retain their existing shape. 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.
+10
View File
@@ -1,5 +1,15 @@
# God's Eye View Current State # 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.
## Context coordination ## Context coordination
Context controls own Contacts/Space Missions state, entry and exit transactions, Context controls own Contacts/Space Missions state, entry and exit transactions,
+5 -1
View File
@@ -133,6 +133,10 @@
"./ui/radio": "./src/ui/radio.js", "./ui/radio": "./src/ui/radio.js",
"./ui/cctv": "./src/ui/cctv.js", "./ui/cctv": "./src/ui/cctv.js",
"./ui/context": "./src/ui/context.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"
} }
} }
+29
View File
@@ -387,5 +387,34 @@
"src/ui/panelRailGeometry.js" "src/ui/panelRailGeometry.js"
], ],
"external": [] "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"
]
} }
} }
+6 -12
View File
@@ -1,3 +1,5 @@
import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js';
import { CockpitViewController } from './ui/cockpitController.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@@ -29,11 +31,7 @@ function ordered(source, needles, label) {
} }
test('Cockpit takeover invalidates deferred work before camera cancellation', () => { test('Cockpit takeover invalidates deferred work before camera cancellation', () => {
const enter = body( const enter = cockpitEnter.toString();
ui,
/enter\(\) \{([\s\S]*?)\n \}\n\n exit\(/,
'Cockpit enter',
);
ordered(enter, [ ordered(enter, [
'if (!info || !entity?.position) return false;', 'if (!info || !entity?.position) return false;',
'this.onCameraTakeover?.();', 'this.onCameraTakeover?.();',
@@ -131,18 +129,14 @@ test('voice Cockpit next/previous shares the manual Context navigation path', ()
// is what stamps. Divergence here is how a voice-only camera path escapes // is what stamps. Divergence here is how a voice-only camera path escapes
// the arbiter. // the arbiter.
assert.match( assert.match(
ui, CockpitViewController.toString(),
/this\._listen\(this\.contextPrevious, 'click', \(\) => this\.navigateContext\(-1, \{ origin: 'user' \}\)\);/, /this\._listen\(this\.contextPrevious, 'click', \(\) => this\.navigateContext\(-1, \{ origin: 'user' \}\)\);/,
); );
assert.match( assert.match(
ui, CockpitViewController.toString(),
/this\._listen\(this\.contextNext, 'click', \(\) => this\.navigateContext\(1, \{ origin: 'user' \}\)\);/, /this\._listen\(this\.contextNext, 'click', \(\) => this\.navigateContext\(1, \{ origin: 'user' \}\)\);/,
); );
const funnel = body( const funnel = navigateContext.toString();
ui,
/navigateContext\(direction, options = \{\}\) \{([\s\S]*?)\n \}\n\n \/\*\* Adopt/,
'Cockpit Context navigation funnel',
);
ordered(funnel, [ ordered(funnel, [
"const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';", "const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';",
'const navigationOptions = wasActive ? { ...options, aircraftOnly: true } : options;', 'const navigationOptions = wasActive ? { ...options, aircraftOnly: true } : options;',
+73 -70
View File
@@ -1,3 +1,11 @@
import { isRenderedOnScreen } from './ui/cockpitPresentation.js';
import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js';
import { enter as cockpitEnter, exit as cockpitExit, readAircraftInfo, _adoptTrackedEntity } from './ui/cockpitTrackingController.js';
import { setVisionMode, cycleVisionMode } from './ui/cockpitInstruments.js';
import { updateContext as cockpitContext } from './ui/cockpitContext.js';
import { syncSignalLayout, setContextCollapsed, setSignalCollapsed } from './ui/cockpitLayout.js';
import { setBriefAutoRotate } from './ui/cockpitBriefing.js';
import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js';
import { _handleContextLayerChange } from './ui/contextLayerChanges.js'; import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
import { clearSelectedLayers } from './ui/contextActions.js'; import { clearSelectedLayers } from './ui/contextActions.js';
import { _selectContextMode } from './ui/contextTransactions.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', () => { test('Cockpit vision cycle exposes exactly five real visual styles without NONE', () => {
assert.match(ui, /const modes = COCKPIT_VISION_MODES;/); assert.match(cycleVisionMode.toString(), /const modes = COCKPIT_VISION_MODES;/);
assert.match(ui, /const labels = \{ optical: inherited, crt: 'CRT', nvg: 'NVG', thermal: 'FLIR', noir: 'NOIR' \};/); assert.match(setVisionMode.toString(), /const labels = \{ optical: inherited, crt: 'CRT', nvg: 'NVG', thermal: 'FLIR', noir: 'NOIR' \};/);
assert.doesNotMatch(ui, /none: 'NONE'/); assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/);
assert.match(ui, /getInheritedVisionLabel: \(\) => \([\s\S]*?STYLE_STATUS_LABELS\[this\.activeStyle\]/); assert.match(ui, /getInheritedVisionLabel: \(\) => \([\s\S]*?STYLE_STATUS_LABELS\[this\.activeStyle\]/);
assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/); assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/);
assert.match(ui, /const target = applyCockpitVisionStageIntensities\(this\.stages, next, this\._cockpitVisionRestore\);/); 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', () => { 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'); assert.ok(keydown, 'Cockpit keyboard handler is missing');
const escapeIndex = keydown[1].indexOf("event.key === 'Escape'"); const escapeIndex = keydown.indexOf("event.key === 'Escape'");
const formGuardIndex = keydown[1].indexOf("closest?.('input, textarea, select, [contenteditable]')"); 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'); 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 enter = cockpitEnter.toString();
const exit = ui.match(/\n exit\(\{ restoreTracking = true \} = \{\}\) \{([\s\S]*?)\n \}\n\n update\(\)/); const exit = cockpitExit.toString();
assert.ok(enter && exit, 'Cockpit entry/exit methods are missing'); assert.ok(enter && exit, 'Cockpit entry/exit methods are missing');
assert.match(enter[1], /activeElement/); assert.match(enter, /activeElement/);
assert.match(enter[1], /mapViewButton.*focus|focus.*mapViewButton/s); assert.match(enter, /mapViewButton.*focus|focus.*mapViewButton/s);
assert.match(exit[1], /focus\(\{ preventScroll: true \}\)/); assert.match(exit, /focus\(\{ preventScroll: true \}\)/);
assert.match(exit[1], /this\.restoreTrackingFrame\(entity\)/); assert.match(exit, /this\.restoreTrackingFrame\(entity\)/);
assert.doesNotMatch(exit[1], /applyTrackedCameraFrame/); assert.doesNotMatch(exit, /applyTrackedCameraFrame/);
assert.match( assert.match(
ui, ui,
/restoreTrackingFrame: \(entity\) => \{[\s\S]*?gevTrackedId[\s\S]*?flightsLayer\.refocusTrackedById[\s\S]*?militaryFlightsLayer\.refocusTrackedById/, /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', () => { 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.ok(keydown, 'Cockpit keyboard handler is missing');
assert.match(keydown[1], /document\.getElementById\('context-radio-dock'\)\?\.classList\.contains\('disclosure-open'\)/); assert.match(keydown, /document\.getElementById\('context-radio-dock'\)\?\.classList\.contains\('disclosure-open'\)/);
assert.match(keydown[1], /#cockpit-utility-controls \[aria-expanded="true"\]/); assert.match(keydown, /#cockpit-utility-controls \[aria-expanded="true"\]/);
assert.match( assert.match(
keydown[1], keydown,
/const cockpitAttempt = !!\(this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/, /const cockpitAttempt = !!\(this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\);[\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', () => { 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'); assert.ok(updateContext, 'Cockpit updateContext is missing');
const body = updateContext[1]; const body = updateContext;
// ui.js cannot be imported under node (Cesium's `mgrs` dependency), so the // Pin the real presentation owner to the readout policy: PREVIOUS/NEXT
// decision lives in cockpitMath.resolveCockpitContextReadout and this pins // remains reachable unless no Context snapshot exists at all.
// ui.js to it. The panel hosts PREVIOUS/NEXT: the only reason it may hide is
// that no snapshot exists at all.
assert.match(body, /resolveCockpitContextReadout\(\{ snapshot, info \}\)/); assert.match(body, /resolveCockpitContextReadout\(\{ snapshot, info \}\)/);
assert.match(body, /if \(!readout\.visible\) \{[\s\S]*?this\.context\.hidden = true;/); assert.match(body, /if \(!readout\.visible\) \{[\s\S]*?this\.context\.hidden = true;/);
assert.equal( 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', () => { 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.ok(read, 'readAircraftInfo is missing');
assert.match(read[1], /resolveTrackedAircraftInfo\(\{/); assert.match(read, /resolveTrackedAircraftInfo\(\{/);
assert.match(read[1], /gevTrackedId/); assert.match(read, /gevTrackedId/);
// In cockpit mode the controller moves the entity off viewer.trackedEntity, // In cockpit mode the controller moves the entity off viewer.trackedEntity,
// so its own handle is the tracked identity there. // 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', () => { 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, /\.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(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(syncSignalLayout.toString(), /resolveCockpitUtilityLayout\(\{ availableHeight, expandedHeight, collapsedHeight \}\)/);
assert.match(ui, /setAttribute\('aria-hidden', String\(hiddenSibling\)\)/); assert.match(syncSignalLayout.toString(), /setAttribute\('aria-hidden', String\(hiddenSibling\)\)/);
}); });
test('Display orders 3D above Celestial, Clean UI below it, and Parameters below Detection', () => { 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', () => { test('Cockpit Display portal retains both scroll owners across round trips', () => {
assert.match(ui, /this\._standardDisplayScrollTop = this\._ppToggles\?\.scrollTop \|\| 0/); const portal = CockpitDisplayPortal.toString();
assert.match(ui, /this\._cockpitDisplayScrollTop = this\._cockpitDisplayPanel\?\.scrollTop \|\| 0/); assert.match(portal, /this\.standardScrollTop = standardPanel\?\.scrollTop \|\| 0/);
assert.match(ui, /if \(!this\._cockpitDisplayPortalActive\)[\s\S]*?this\._standardDisplayScrollTop = this\._ppToggles\?\.scrollTop/); assert.match(portal, /this\.cockpitScrollTop = cockpitPanel\?\.scrollTop \|\| 0/);
assert.match(ui, /if \(this\._cockpitDisplayPortalActive\)[\s\S]*?this\._cockpitDisplayScrollTop = this\._cockpitDisplayPanel\?\.scrollTop/); assert.match(portal, /if \(!this\.active\)[\s\S]*?this\.standardScrollTop = standardPanel\.scrollTop/);
assert.match(ui, /this\._cockpitDisplayPanel\.scrollTop = this\._cockpitDisplayScrollTop[\s\S]*?this\._ppToggles\.scrollTop = this\._standardDisplayScrollTop/); 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', () => { 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\);/, /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', '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'/, /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', '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'/, /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', 'Live Signals expansion must notify only on a collapsed-to-expanded transition',
); );
assert.match( assert.match(
ui, setSignalCollapsed.toString(),
/setSignalCollapsed\(collapsed, \{ user = false \} = \{\}\)[\s\S]*?if \(user\) this\.signalUserCollapsed = this\.signalCollapsed/, /setSignalCollapsed\(collapsed, \{ user = false \} = \{\}\)[\s\S]*?if \(user\) this\.signalUserCollapsed = this\.signalCollapsed/,
); );
assert.match( 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(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/);
assert.match(controls, /for \(const button of elements\.resetButtons\)[\s\S]*?this\.bind\(button, 'click', onReset\)/, 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'); 'both reset controls delegate to the same supplied reset action');
assert.match(ui, /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/); assert.match(cockpitEnter.toString(), /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/);
assert.match(ui, /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/); 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', () => { test('Location navigation releases immediate routes before flight and deferred routes after resolution', () => {
@@ -536,27 +543,27 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
'the right margin must not borrow the left accordion corridor: it is solved ' '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', + '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.ok(signalLayout, 'Cockpit signal layout method is missing');
assert.match( assert.match(
signalLayout[1], signalLayout,
/setProperty\('--cockpit-utility-top', `\$\{utilityAnchor\.top\.toFixed\(1\)\}px`\)/, /setProperty\('--cockpit-utility-top', `\$\{utilityAnchor\.top\.toFixed\(1\)\}px`\)/,
'Cockpit owns the utility strip anchor and republishes it every layout tick', 'Cockpit owns the utility strip anchor and republishes it every layout tick',
); );
assert.match( assert.match(
signalLayout[1], signalLayout,
/resolveCockpitUtilityAnchor\(\{[\s\S]*?recBottom: recBounds \? recBounds\.bottom : 0,[\s\S]*?signalTop: signalBounds\.top,[\s\S]*?stripHeight: utilityBounds\.height,/, /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', '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( assert.match(
signalLayout[1], signalLayout,
/const recBounds = isRenderedOnScreen\(recReadout\) \? recReadout\.getBoundingClientRect\(\) : null;/, /const recBounds = isRenderedOnScreen\(recReadout\) \? recReadout\.getBoundingClientRect\(\) : null;/,
'HUD Off retires the Intel HUD with visibility/opacity, which leaves the REC ' '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', + 'readout a rect — a rect test alone would anchor the strip to an invisible readout',
); );
assert.match( assert.match(
ui, isRenderedOnScreen.toString(),
/function isRenderedOnScreen\(element\) \{[\s\S]*?style\.display === 'none' \|\| style\.visibility === 'hidden' \|\| Number\(style\.opacity\) === 0[\s\S]*?rect\.width > 0 && rect\.height > 0;/, /function isRenderedOnScreen\(element\) \{[\s\S]*?style\.display === 'none' \|\| style\.visibility === 'hidden' \|\| Number\(style\.opacity\) === 0[\s\S]*?rect\.width > 0 && rect\.height > 0;/,
); );
assert.match( assert.match(
@@ -566,12 +573,12 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
+ 'readout keeps its rect until the transition ends', + 'readout keeps its rect until the transition ends',
); );
assert.doesNotMatch( assert.doesNotMatch(
signalLayout[1], signalLayout,
/Math\.max\(120,/, /Math\.max\(120,/,
'the 120px corridor floor can never be reached by a 107px strip and only hid the collision', '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, /const availableHeight = utilityAnchor\.maxHeight;/);
assert.match(signalLayout[1], /cockpit-utility-controls[\s\S]*?--cockpit-utility-max-height/); assert.match(signalLayout, /cockpit-utility-controls[\s\S]*?--cockpit-utility-max-height/);
assert.match( assert.match(
css, css,
/\.cockpit-utility-controls\s*\{[\s\S]*?top:\s*var\(--cockpit-utility-top, var\(--left-stack-safe-top, var\(--left-stack-top\)\)\)/, /\.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.doesNotMatch(ui, /\['presets',/);
assert.match( assert.match(
ui, CockpitDisplayPortal.toString(),
/group\.before\(anchor\)[\s\S]*?window\.addEventListener\('gev:cockpit-mode-changed', this\._cockpitDisplayModeHandler\)/, /group\.before\(anchor\)[\s\S]*?window\.addEventListener\('gev:cockpit-mode-changed'/,
); );
assert.match( assert.match(
ui, CockpitDisplayPortal.prototype.setActive.toString(),
/_setCockpitDisplayPortalActive\(active\) \{[\s\S]*?record\.slot\.append\(record\.group\)[\s\S]*?record\.anchor\.after\(record\.group\)/, /record\.slot\.append\(record\.group\)[\s\S]*?record\.anchor\.after\(record\.group\)/,
); );
assert.match( assert.match(
ui, CockpitDisplayPortal.prototype.destroy.toString(),
/window\.removeEventListener\('gev:cockpit-mode-changed', this\._cockpitDisplayModeHandler\)[\s\S]*?_setCockpitDisplayPortalActive\(false\)[\s\S]*?record\.anchor\.remove\(\)/, /setActive\(false, \{ settle: false \}\)[\s\S]*?listeners\.abort\(\)[\s\S]*?record\.anchor\.remove\(\)/,
); );
assert.doesNotMatch(ui, /_cycleCockpitHud|_cockpitModels3dToggle|_cockpitDetectionToggle/); assert.doesNotMatch(ui, /_cycleCockpitHud|_cockpitModels3dToggle|_cockpitDetectionToggle/);
assert.equal((html.match(/id="style-buttons"/g) || []).length, 1); 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(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(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(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-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*width:\s*100%/);
assert.match(css, /#cockpit-display-panel \.pp-toggle-group\s*\{[\s\S]*?min-width:\s*0;/); 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', () => { test('cockpit aircraft handoff invalidates the prior world-position anchor', () => {
const match = ui.match( const match = _adoptTrackedEntity.toString();
/_adoptTrackedEntity\(nowMs, suppliedInfo = null\) \{([\s\S]*?)\n \}\n\n setVisionMode/,
);
assert.ok(match, 'cockpit tracked-entity handoff block is missing'); assert.ok(match, 'cockpit tracked-entity handoff block is missing');
assert.match(match[1], /this\.viewer\.trackedEntity = undefined;/); assert.match(match, /this\.viewer\.trackedEntity = undefined;/);
assert.match(match[1], /this\.cockpitAnchorValid = false;/); assert.match(match, /this\.cockpitAnchorValid = false;/);
assert.match(match[1], /this\.heading = normalizeHeading\(info\.track \?\? 0\);/); assert.match(match, /this\.heading = normalizeHeading\(info\.track \?\? 0\);/);
assert.match(match[1], /this\.lastFrameMs = nowMs;/); assert.match(match, /this\.lastFrameMs = nowMs;/);
}); });
test('cockpit weather control is off before JavaScript restores an explicit opt-in', () => { 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="Next — nearest unvisited contact in the 250 km window"/);
assert.match(match[0], /aria-label="Collapse Contact panel"/); assert.match(match[0], /aria-label="Collapse Contact panel"/);
assert.doesNotMatch(match[0], />GLOBAL CONTEXT</); 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', () => { 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], />CYCLE OFF<\/button>/);
assert.match(match[0], /title="Cycle briefing pages automatically every 9 seconds \(Signals → News → Local\)\./); assert.match(match[0], /title="Cycle briefing pages automatically every 9 seconds \(Signals → News → Local\)\./);
const update = ui.match( const update = setBriefAutoRotate.toString();
/setBriefAutoRotate\(enabled\) \{([\s\S]*?)\n \}\n\n startBriefRotation/,
);
assert.ok(update, 'cockpit briefing cycle state updater is missing'); 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, /const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/);
assert.match(update[1], /setAttribute\('aria-label', label\)/); assert.match(update, /setAttribute\('aria-label', label\)/);
assert.match(update[1], /\.title = help;/); assert.match(update, /\.title = help;/);
assert.doesNotMatch(update[1], /setAttribute\('aria-label', help\)/); assert.doesNotMatch(update, /setAttribute\('aria-label', help\)/);
}); });
test('voice Cockpit entry honours a requested contact layer before it enters', () => { test('voice Cockpit entry honours a requested contact layer before it enters', () => {
+9 -13
View File
@@ -1,14 +1,10 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { afterEach } from 'node:test';
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js'; import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
import { CockpitViewController } from './ui/cockpitController.js';
const source = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); const previousDocument = globalThis.document;
const renderStart = source.indexOf(' renderCockpitSignals() {'); afterEach(() => { globalThis.document = previousDocument; });
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');
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an // Model native focus loss on removal AND on ordinary DOM moves. Keeping an
// object reference alone is insufficient if reconciliation disconnects it. // object reference alone is insufficient if reconciliation disconnects it.
@@ -84,8 +80,9 @@ function fixture() {
document.body = new Node('body'); document.body = new Node('body');
document.createElement = (tag) => new Node(tag); document.createElement = (tag) => new Node(tag);
document.activeElement = document.body; document.activeElement = document.body;
const controller = new (new Function('document', 'formatAwarenessLabel', globalThis.document = document;
`return class { ${source.slice(renderStart, renderEnd)} };`)(document, formatAwarenessLabel))(); const controller = Object.create(CockpitViewController.prototype);
controller.services = { formatAwarenessLabel };
controller.signalList = new Node('ol'); controller.signalList = new Node('ol');
controller.signalToggle = new Node('button'); controller.signalToggle = new Node('button');
controller.briefTabs = [new Node('button'), new Node('button'), 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'); const outside = new Node('button');
document.body.append(controller.signalToggle, controller.signalList, ...controller.briefTabs, display, radio, outside); document.body.append(controller.signalToggle, controller.signalList, ...controller.briefTabs, display, radio, outside);
const selected = []; const selected = [];
const click = new Function('militaryAwarenessLayer', `return ${clickSource};`)({ controller.services.militaryAwarenessLayer = { focusTarget: (...args) => selected.push(args) };
focusTarget: (...args) => selected.push(args), const click = event => controller.handleSignalClick(event);
});
const buttons = () => controller.signalList.querySelectorAll('button'); const buttons = () => controller.signalList.querySelectorAll('button');
const tab = () => { const tab = () => {
const nodes = document.body.querySelectorAll('button'); const nodes = document.body.querySelectorAll('button');
+2 -1
View File
@@ -1,3 +1,4 @@
import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js';
import { readFileSync as readRadioSource } from 'node:fs'; import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.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', () => { test('Cockpit Escape collapses Contact or Live Signals before exiting Cockpit', () => {
const onKeyDown = method('onKeyDown', 'enter'); const onKeyDown = cockpitKeyDown.toString();
assert.match( assert.match(
onKeyDown, onKeyDown,
/event\.target\?\.closest\?\.\('\.cesium-credit-lightbox'\)[\s\S]*?return;/, /event\.target\?\.closest\?\.\('\.cesium-credit-lightbox'\)[\s\S]*?return;/,
+47 -1831
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -0,0 +1,2 @@
export { CockpitViewController } from './cockpitController.js';
export { CockpitDisplayPortal } from './cockpitDisplayPortal.js';
+192
View File
@@ -0,0 +1,192 @@
/** 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 } = {}) {
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) {
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 (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);
}
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.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();
}
+225
View File
@@ -0,0 +1,225 @@
/** 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);
}
}
+134
View File
@@ -0,0 +1,134 @@
/** 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();
}
}
+259
View File
@@ -0,0 +1,259 @@
/** 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.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); }
dispose() {
if (this.destroyed) return;
this.destroyed = true;
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?.();
}
}
+99
View File
@@ -0,0 +1,99 @@
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(); }
});
+90
View File
@@ -0,0 +1,90 @@
/** 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.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.destroyed && 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.destroyed) 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();
}
destroy() {
if (this.destroyed) return;
this.setActive(false, { settle: false });
this.destroyed = true;
this._cancelFrames();
this.listeners.abort();
for (const record of this.records) record.anchor.remove();
this.records = [];
this.restoreOwner = null;
}
}
+106
View File
@@ -0,0 +1,106 @@
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() {} };
}
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(); }
});
+54
View File
@@ -0,0 +1,54 @@
/** 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;
}
}
+182
View File
@@ -0,0 +1,182 @@
/** Instrument values, routes and vision controls for the Cockpit controller. */
import { COCKPIT_VISION_MODES, normalizeCockpitVisionMode } from '../cockpitVisionPolicy.js';
import { altitudeRulerCurveInset, altitudeRulerTicks, bearingBetweenCoordinates, cockpitAltitudeDisplayFt, cockpitUiUpdateDue, compassDivisions, formatAltitudeRulerTick, formatCompassDivision, formatSpeedRulerTick, normalizeHeading, relativeBearing, speedRulerTicks } from '../cockpitMath.js';
import { COCKPIT_CONTEXT_UPDATE_MS, setCockpitRollingValue } from './cockpitPresentation.js';
export function updateHud(info, nowMs = performance.now(), forceContext = false) {
this.lastAircraftInfo = info;
const heading = normalizeHeading(this.heading ?? info.track ?? 0);
if (this.callsign) {
this.callsign.textContent = info.callsign || info.registration || info.icao24 || 'AIRCRAFT';
}
const speedKt = Number.isFinite(info.velocityMps) ? info.velocityMps * 1.94384 : null;
setCockpitRollingValue(
this.speed,
formatSpeedRulerTick(speedKt),
speedKt,
{ immediate: forceContext },
);
if (this.speedRim) this.speedRim.classList.toggle('unavailable', speedKt === null);
if (this.speedRimValue) this.speedRimValue.textContent = formatSpeedRulerTick(speedKt);
const speedTicks = speedRulerTicks(speedKt, this.speedRimTicks.length);
this.speedRimTicks.forEach((element, index) => {
const tick = speedTicks[index];
element.hidden = !tick;
if (!tick) return;
element.style.setProperty('--slot', tick.slot.toFixed(4));
element.style.setProperty('--depth', tick.depth.toFixed(4));
element.style.setProperty('--curve', altitudeRulerCurveInset(tick.slot).toFixed(5));
element.classList.toggle('major', tick.major);
const label = element.querySelector('b');
if (label) label.textContent = formatSpeedRulerTick(tick.valueKt);
});
const altitudeFt = cockpitAltitudeDisplayFt(info.altitudeM, info.onGround);
if (this.altitude) {
const displayedAltitudeFt = Number.isFinite(altitudeFt)
? Math.round(altitudeFt)
: null;
setCockpitRollingValue(
this.altitude,
displayedAltitudeFt !== null
? displayedAltitudeFt.toLocaleString('en-US')
: '-----',
displayedAltitudeFt,
{ immediate: forceContext },
);
}
if (this.altitudeRim) this.altitudeRim.classList.toggle('unavailable', altitudeFt === null);
if (this.altitudeRimValue) {
this.altitudeRimValue.textContent = formatAltitudeRulerTick(altitudeFt);
}
const altitudeTicks = altitudeRulerTicks(altitudeFt, this.altitudeRimTicks.length);
this.altitudeRimTicks.forEach((element, index) => {
const tick = altitudeTicks[index];
element.hidden = !tick;
if (!tick) return;
element.style.setProperty('--slot', tick.slot.toFixed(4));
element.style.setProperty('--depth', tick.depth.toFixed(4));
element.style.setProperty('--curve', altitudeRulerCurveInset(tick.slot).toFixed(5));
element.classList.toggle('major', tick.major);
const label = element.querySelector('b');
if (label) label.textContent = formatAltitudeRulerTick(tick.valueFt);
});
setCockpitRollingValue(
this.headingValue,
String(Math.round(heading) % 360).padStart(3, '0'),
heading,
{ circularRange: 360, immediate: forceContext },
);
if (this.compassTape) {
const divisions = compassDivisions(heading);
const signature = divisions.join(',');
if (signature !== this.lastCompassSignature) {
this.lastCompassSignature = signature;
this.compassTape.innerHTML = divisions
.map((division, index) => {
const slot = index - 3;
return `<span class="${slot === 0 ? 'active' : ''}" style="--slot:${slot};--depth:${Math.abs(slot)}">${formatCompassDivision(division)}</span>`;
})
.join('');
}
}
if (this.clock) this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z';
if (this.position) {
const lat = Number.isFinite(info.latitude)
? `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}` : '--';
const lon = Number.isFinite(info.longitude)
? `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}` : '--';
this.position.textContent = `${lat} · ${lon}`;
}
if (this.aircraftMeta) {
const feedState = this.surfaceAcquiring
? 'ACQUIRING SURFACE'
: (this.surfaceFallback ? 'SURFACE FALLBACK' : (info.stale ? 'STALE FEED' : 'LIVE TRACK'));
this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`;
}
this.updateRoute(info);
if (forceContext
|| cockpitUiUpdateDue(nowMs, this.lastContextUpdateMs, COCKPIT_CONTEXT_UPDATE_MS)) {
this.lastContextUpdateMs = nowMs;
this.updateLocalPosition(info);
this.maybeRefreshRegionalBrief(info);
this.updateContext(info, heading);
}
if (this.hud) this.hud.dataset.layer = info.layerId || 'flights';
}
export function updateRoute(info) {
const origin = info?.route?.origin;
const destination = info?.route?.destination;
const validDestination = Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
const routeLabel = (airport) => [airport?.code, airport?.name].filter(Boolean).join(' · ') || 'UNKNOWN';
if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin);
if (this.routeTo) this.routeTo.textContent = routeLabel(destination);
if (this.routeStatus) {
this.routeStatus.textContent = validDestination
? 'ARROW · ESTIMATED DIRECTION'
: 'ROUTE DATA UNAVAILABLE';
}
if (this.route) this.route.hidden = !origin && !destination;
if (!validDestination || !Number.isFinite(info?.longitude) || !Number.isFinite(info?.latitude)) {
this.clearPredictiveRoute();
return;
}
const destinationBearing = bearingBetweenCoordinates(
info.latitude,
info.longitude,
destination.lat,
destination.lon,
);
const relative = relativeBearing(destinationBearing, this.heading ?? info.track ?? 0);
if (!Number.isFinite(destinationBearing) || !Number.isFinite(relative)) {
this.clearPredictiveRoute();
return;
}
if (this.routeDirection) {
const displayedRelative = Math.max(-120, Math.min(120, relative));
this.routeDirection.hidden = false;
this.routeDirection.style.setProperty('--route-angle', `${displayedRelative.toFixed(2)}deg`);
}
if (this.routeDirectionLabel) {
this.routeDirectionLabel.textContent = `DEST ${String(Math.round(destinationBearing)).padStart(3, '0')}°`;
}
}
export function syncWeatherToggle(enabled) {
if (!this.weatherToggle) return;
const active = !!enabled;
this.weatherToggle.setAttribute('aria-pressed', String(active));
this.weatherToggle.setAttribute(
'aria-label',
`${active ? 'Disable' : 'Enable'} cockpit weather effects`,
);
this.weatherToggle.title = `${active ? 'Disable' : 'Enable'} cockpit weather effects`;
if (this.weatherState) this.weatherState.textContent = active ? 'ON' : 'OFF';
}
export function setVisionMode(mode, { revealParameters = false } = {}) {
const next = normalizeCockpitVisionMode(mode);
this.visionMode = next;
const inherited = String(this.getInheritedVisionLabel?.() || 'NORMAL').toUpperCase();
const labels = { optical: inherited, crt: 'CRT', nvg: 'NVG', thermal: 'FLIR', noir: 'NOIR' };
const names = { optical: inherited, crt: 'CRT', nvg: 'Night vision', thermal: 'Thermal', noir: 'Noir' };
if (this.visionCurrent) {
this.visionCurrent.dataset.cockpitVision = next;
this.visionCurrent.setAttribute('aria-label', `Current cockpit vision style: ${names[next]}. Activate for next style.`);
this.visionCurrent.title = `Current style: ${names[next]} — click for next`;
}
if (this.visionCurrentLabel) this.visionCurrentLabel.textContent = labels[next];
this.onVisionChange?.(next, this.active, { revealParameters });
}
export function cycleVisionMode(direction = 1) {
const modes = COCKPIT_VISION_MODES;
const currentIndex = Math.max(0, modes.indexOf(this.visionMode));
const step = direction < 0 ? -1 : 1;
const nextIndex = (currentIndex + step + modes.length) % modes.length;
this.setVisionMode(modes[nextIndex], { revealParameters: true });
}
export function clearPredictiveRoute() {
if (this.routeDirection) this.routeDirection.hidden = true;
}
+152
View File
@@ -0,0 +1,152 @@
/** 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;
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 });
}
});
}
}
+169
View File
@@ -0,0 +1,169 @@
/** 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);
}
+174
View File
@@ -0,0 +1,174 @@
/** 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' },
);
}
+226
View File
@@ -0,0 +1,226 @@
/** 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 (restoreTarget?.isConnected && !restoreTarget.hidden) {
restoreTarget.focus({ preventScroll: true });
}
return true;
}