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