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; + }