From f279200af54f6ee4f536e435f251378a57eb2619 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 17:44:33 -0700 Subject: [PATCH] feat(ui): show and hide the Cockpit surfaces from a store The Cockpit controller publishes which surfaces are shown (the entry button and TR-3B chip, the HUD with its exit and reset buttons and briefing card, and the Contact card) and the views bind them. Focus moves to the exit button, and back to the entry, after Vue renders them. Layout scheduling counts the published presence as visible, so entry still lays out the cards. The Display portal restores only focus its move dropped. The Missions layer no longer hides the Contact card directly. --- docs/VUE-MIGRATION.md | 11 +- src/cockpitMarkup.test.mjs | 10 +- src/layers/launches/lifecycle.js | 1 - src/ui/applicationShell.js | 2 + src/ui/cockpitContext.js | 13 +- src/ui/cockpitContext.test.mjs | 14 +- src/ui/cockpitController.js | 20 +- src/ui/cockpitDisplayPortal.js | 10 +- src/ui/cockpitDisplayPortal.test.mjs | 16 ++ src/ui/cockpitInstruments.test.mjs | 11 +- src/ui/cockpitLayout.js | 8 +- src/ui/cockpitPresence.test.mjs | 257 ++++++++++++++++++++++++ src/ui/cockpitTrackingController.js | 82 ++++---- src/ui/stores/cockpit.js | 12 +- src/ui/views/CockpitView.vue | 14 +- src/ui/views/ContextView.vue | 7 +- src/ui/views/panels/CockpitBriefing.vue | 4 +- src/ui/views/panels/CockpitContext.vue | 4 +- 18 files changed, 422 insertions(+), 74 deletions(-) create mode 100644 src/ui/cockpitPresence.test.mjs diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index f88ade519..910ebb857 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -155,9 +155,14 @@ a feature supplying only its accent. before the focused row is removed, so a keyed list reorders several times a second without dropping keyboard focus. The instruments publish on every HUD update (about 10 Hz); in a dev build their Vue patch costs - about 0.5 ms, close to the DOM writes it replaced. The controls keep the - controller's listeners, and the cards' visibility, collapse and layout - stay with the controller. `CockpitUtilityControls` renders the Display + about 0.5 ms, close to the DOM writes it replaced. The controller also + publishes which surfaces are shown: the entry button and TR-3B chip in + the Context panel, the HUD with its exit and reset buttons and briefing + card, and the Contact card. Focus moves to the exit button, and back to + the entry, only after Vue renders them. The controls keep the + controller's listeners, and the cards' collapse and layout stay with the + controller, which reads their visibility when it lays them out. + `CockpitUtilityControls` renders the Display and Radio launchers, which popover is open and the compact Radio controls from the `gev-radio` store that RadioControls publishes to; the Display popover's slots stay static for the Cockpit display portal). diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index a00a1a20e..b806bcaea 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -251,10 +251,10 @@ test('the Contact panel never hides itself out from under its own NEXT button', assert.match(body, /resolveCockpitContextReadout\(\{ snapshot, info \}\)/); assert.match( body, - /if \(!readout\.visible\) \{[\s\S]*?this\.context\.hidden = true;/, + /if \(!readout\.visible\) \{[\s\S]*?this\.showContextCard\(false\);/, ); assert.equal( - (body.match(/this\.context\.hidden = true/g) || []).length, + (body.match(/this\.showContextCard\(false\)/g) || []).length, 1, 'the panel has exactly one hide path, and it is the no-snapshot standby case', ); @@ -815,14 +815,14 @@ test('Reset releases Contact camera ownership through its selection-preserving r ); assert.match( cockpitEnter.toString(), - /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/, + /Object\.assign\(this\.presence, \{\s*active: true,/, ); assert.match( readRadioSource( - new URL('./ui/cockpitTrackingController.js', import.meta.url), + new URL('./ui/views/CockpitView.vue', import.meta.url), 'utf8', ), - /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/, + /id="cockpit-reset-globe"[^>]*:hidden="!active"/, ); }); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 53401e08e..ca36a87ec 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -201,7 +201,6 @@ export function createLifecycle({ if (layerState._dataSource) layerState._dataSource.show = true; parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); parts.selection.focusFullGlobe(layerState._viewer); - document.getElementById('cockpit-context')?.setAttribute('hidden', ''); if (layerState._selectedLaunchId) parts.selection.setSelectedMission( layerState._selectedLaunchId, diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 937106347..ce958b81f 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -376,6 +376,8 @@ export class StyleManager extends ShellFacade { }, operations: { publishCockpit: { + presence: (view) => + useCockpitStore(applicationPinia()).publishPresence(view), brief: (view) => useCockpitStore(applicationPinia()).publishBrief(view), context: (view) => diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index e7296b210..1158c632b 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -38,6 +38,13 @@ export function publishContextView() { }); } +/** Show or hide the Contact card; the HUD marks it with `context-active`. */ +export function showContextCard(visible) { + if (this.presence.context === visible) return; + this.presence.context = visible; + this.publishPresenceView(); +} + export function updateContext(info, heading) { if (!this.context) return; const view = this.contextView; @@ -46,8 +53,7 @@ export function updateContext(info, heading) { 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.showContextCard(false); this.contextLayoutStamp = null; this.pushCockpitSignal( 'context-status', @@ -58,8 +64,7 @@ export function updateContext(info, heading) { return; } - this.context.hidden = false; - this.hud?.classList.add('context-active'); + this.showContextCard(true); const installationCoverage = snapshot.cohorts.find( (cohort) => cohort.id === 'military-installations', )?.coverage; diff --git a/src/ui/cockpitContext.test.mjs b/src/ui/cockpitContext.test.mjs index 6d36cc75d..7b0ad20f3 100644 --- a/src/ui/cockpitContext.test.mjs +++ b/src/ui/cockpitContext.test.mjs @@ -6,7 +6,11 @@ import assert from 'node:assert/strict'; import { createPinia } from 'pinia'; import { createApp, nextTick } from 'vue'; import { setLocale, t as translate } from '../i18n/index.js'; -import { publishContextView, updateContext } from './cockpitContext.js'; +import { + publishContextView, + showContextCard, + updateContext, +} from './cockpitContext.js'; import { createContextState } from './cockpitContextState.js'; import { syncWeatherToggle } from './cockpitInstruments.js'; import { useCockpitStore } from './stores/cockpit.js'; @@ -26,11 +30,13 @@ function fixture(t, snapshot = null) { // The Contact presenter needs only this much of the controller. const owner = { context: $('#cockpit-context'), - hud: document.createElement('section'), + presence: { context: false }, contextView: createContextState(), contextLayoutStamp: null, publishContext: (view) => store.publishContext(view), publishContextView, + publishPresenceView: () => store.publishPresence({ ...owner.presence }), + showContextCard, pushCockpitSignal: (...args) => signals.push(args), updateCockpitSignals() {}, scheduleContextLayout: () => layouts.push(true), @@ -119,7 +125,7 @@ test('a snapshot fills the card and keeps classes other code sets', async (t) => const card = $('#cockpit-context'); assert.equal(card.hidden, false); assert.equal(card.dataset.state, 'uncertain'); - assert.equal(owner.hud.classList.contains('context-active'), true); + assert.equal(owner.presence.context, true); assert.match( text('#cockpit-context-subject'), /^RCH123 ยท 250\u00a0KM AIR\/SEA WINDOW$/, @@ -193,7 +199,7 @@ test('without a snapshot the card stands by, hidden', async (t) => { updateContext.call(owner, INFO, 0); await nextTick(); assert.equal($('#cockpit-context').hidden, true); - assert.equal(owner.hud.classList.contains('context-active'), false); + assert.equal(owner.presence.context, false); assert.equal(signals.at(-1)[2], 'CONTEXT STANDBY'); }); diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index 77021f217..8b62e3b51 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -5,6 +5,7 @@ import { toggleTrackedTr3b, syncTr3bToggle, syncEntry, + publishPresenceView, navigateContext, _adoptTrackedEntity, enter, @@ -21,7 +22,11 @@ import { cycleVisionMode, clearPredictiveRoute, } from './cockpitInstruments.js'; -import { publishContextView, updateContext } from './cockpitContext.js'; +import { + publishContextView, + showContextCard, + updateContext, +} from './cockpitContext.js'; import { createContextState } from './cockpitContextState.js'; import { createBriefState } from './cockpitBriefPages.js'; import { @@ -139,12 +144,19 @@ export class CockpitViewController { this.briefPageIndex = 0; this.briefAutoRotateEnabled = false; // Cockpit's views render what these receive. + this.publishPresence = publish.presence ?? (() => {}); this.publishBrief = publish.brief ?? (() => {}); this.publishContext = publish.context ?? (() => {}); this.publishInstruments = publish.instruments ?? (() => {}); this.publishRoute = publish.route ?? (() => {}); this.publishVision = publish.vision ?? (() => {}); this.publishSignals = publish.signals ?? (() => {}); + this.presence = { + active: false, + entryAvailable: false, + tr3b: null, + context: false, + }; this.instruments = createInstrumentState(); this.routeView = createRouteState(); this.routeSignature = null; @@ -260,6 +272,9 @@ export class CockpitViewController { toggleTrackedTr3b() { return toggleTrackedTr3b.call(this); } + publishPresenceView() { + return publishPresenceView.call(this); + } syncTr3bToggle(info) { return syncTr3bToggle.call(this, info); } @@ -308,6 +323,9 @@ export class CockpitViewController { publishContextView() { return publishContextView.call(this); } + showContextCard(visible) { + return showContextCard.call(this, visible); + } updateContext(info, heading) { return updateContext.call(this, info, heading); } diff --git a/src/ui/cockpitDisplayPortal.js b/src/ui/cockpitDisplayPortal.js index 1012c087d..fb0ce3285 100644 --- a/src/ui/cockpitDisplayPortal.js +++ b/src/ui/cockpitDisplayPortal.js @@ -100,7 +100,15 @@ export class CockpitDisplayPortal { } this._frame(generation, () => { restoreScroll(); - focusedElement?.focus?.({ preventScroll: true }); + // Restore only the focus the move dropped: Cockpit entry hands focus to + // its exit button in the meantime, and that wins. + const current = document.activeElement; + if ( + !current || + current === document.body || + focusedRecord?.group.contains(current) + ) + focusedElement?.focus?.({ preventScroll: true }); this._frame(generation, () => { restoreScroll(); this.restoreOwner = null; diff --git a/src/ui/cockpitDisplayPortal.test.mjs b/src/ui/cockpitDisplayPortal.test.mjs index c9837891c..3e3c57733 100644 --- a/src/ui/cockpitDisplayPortal.test.mjs +++ b/src/ui/cockpitDisplayPortal.test.mjs @@ -137,6 +137,22 @@ test('Display groups return to their exact home with independent scroll position } }); +test('focus moved elsewhere before the frame is not taken back', () => { + const f = fixture(); + try { + const owner = f.create(); + owner.setActive(true); + // Cockpit entry focuses its exit button before the portal's frame runs. + document.activeElement = f.sibling; + f.flush(); + assert.equal(f.focusCalls, 0); + assert.equal(document.activeElement === f.sibling, true); + owner.destroy(); + } finally { + f.restore(); + } +}); + test('superseded portal frames cannot restore obsolete focus or scroll', () => { const f = fixture(); try { diff --git a/src/ui/cockpitInstruments.test.mjs b/src/ui/cockpitInstruments.test.mjs index edc4d9008..8bcdbbcc8 100644 --- a/src/ui/cockpitInstruments.test.mjs +++ b/src/ui/cockpitInstruments.test.mjs @@ -63,7 +63,7 @@ function fixture(t) { const ticks = (kind) => [ ...document.querySelectorAll(`[data-${kind}-rim-tick]`), ]; - return { $, text, ticks, owner, published }; + return { $, text, ticks, owner, published, store }; } const INFO = Object.freeze({ @@ -296,11 +296,11 @@ test('an emergency and a military track restyle the HUD', async (t) => { assert.equal(text('#cockpit-aircraft-meta').startsWith('COMMERCIAL'), true); }); -test('re-renders keep the visibility and classes the controller sets on the HUD', async (t) => { - const { $, owner } = fixture(t); +test('re-renders keep the visibility and classes the controller publishes on the HUD', async (t) => { + const { $, owner, store } = fixture(t); const hud = $('#cockpit-hud'); - hud.hidden = false; - hud.classList.add('signals-active'); + store.publishPresence({ active: true, context: false }); + hud.classList.add('voice-highlight'); hud.style.setProperty('--cockpit-utility-top', '120px'); $('#cockpit-speed-rim').classList.add('external'); updateHud.call(owner, INFO, 1000, true); @@ -309,6 +309,7 @@ test('re-renders keep the visibility and classes the controller sets on the HUD' await nextTick(); assert.equal(hud.hidden, false); assert.equal(hud.classList.contains('signals-active'), true); + assert.equal(hud.classList.contains('voice-highlight'), true); assert.equal(hud.style.getPropertyValue('--cockpit-utility-top'), '120px'); assert.equal($('#cockpit-speed-rim').classList.contains('external'), true); }); diff --git a/src/ui/cockpitLayout.js b/src/ui/cockpitLayout.js index 074f6cff3..4f0759d6a 100644 --- a/src/ui/cockpitLayout.js +++ b/src/ui/cockpitLayout.js @@ -16,8 +16,12 @@ import { export function scheduleContextLayout() { if (this.destroyed) return false; - const contextVisible = this.context && !this.context.hidden; - const signalVisible = this.signalStream && !this.signalStream.hidden; + // The published presence shows the cards on Vue's next flush, before this + // frame runs, so it counts as visible already. + const contextVisible = + this.context && (this.presence?.context || !this.context.hidden); + const signalVisible = + this.signalStream && (this.presence?.active || !this.signalStream.hidden); if ((!contextVisible && !signalVisible) || this.contextLayoutFrame !== null) return; this.contextLayoutFrame = requestAnimationFrame(() => { diff --git a/src/ui/cockpitPresence.test.mjs b/src/ui/cockpitPresence.test.mjs new file mode 100644 index 000000000..f9ddcd24b --- /dev/null +++ b/src/ui/cockpitPresence.test.mjs @@ -0,0 +1,257 @@ +// src/ui/cockpitPresence.test.mjs โ€” the Cockpit entry, TR-3B chip, HUD, exit +// controls and Contact card show what the Cockpit controller publishes, and +// focus moves once they render, over happy-dom. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPinia } from 'pinia'; +import { createApp, defineComponent, h, nextTick } from 'vue'; +import { CockpitViewController } from './cockpitController.js'; +import { useCockpitStore } from './stores/cockpit.js'; +import CockpitView from './views/CockpitView.vue'; +import ContextView from './views/ContextView.vue'; + +const INFO = Object.freeze({ + icao24: 'abc123', + callsign: 'UAL123', + track: 92, + layerId: 'flights', +}); + +function fixture(t) { + document.body.replaceChildren(); + const host = document.body.appendChild(document.createElement('div')); + const pinia = createPinia(); + const app = createApp( + defineComponent(() => () => [h(ContextView), h(CockpitView)]), + ).use(pinia); + app.mount(host); + const store = useCockpitStore(pinia); + // A browser does not focus a hidden control; happy-dom does, so refuse + // here the way a browser would. + const hiddenFocus = []; + for (const id of ['cockpit-entry', 'map-view-switch']) { + const button = document.getElementById(id); + const focus = button.focus.bind(button); + button.focus = (options) => { + if (button.hidden) hiddenFocus.push(id); + else focus(options); + }; + } + const converted = new Set(); + const listeners = () => ({ addEventListener: () => () => {} }); + const entity = { position: {}, show: true }; + const viewer = { + trackedEntity: entity, + camera: { cancelFlight() {} }, + entities: { contains: (candidate) => candidate === entity }, + scene: { + preUpdate: listeners(), + screenSpaceCameraController: { enableInputs: true }, + }, + trackedEntityChanged: listeners(), + }; + const cockpit = new CockpitViewController(viewer, { + services: { + flightsLayer: { getTrackedInfo: () => INFO, refreshTr3b() {} }, + militaryFlightsLayer: { getTrackedInfo: () => null }, + isTr3b: (icao24) => converted.has(icao24), + toggleTr3b: (icao24) => converted.delete(icao24) || converted.add(icao24), + holdContinuousRender() {}, + releaseContinuousRender() {}, + }, + publish: { + presence: (view) => store.publishPresence(view), + }, + }); + // Entry and exit drive the HUD, briefing and route; their own tests cover + // those, so this one keeps only what shows and hides. + Object.assign(cockpit, { + readAircraftInfo: () => INFO, + updateHud() {}, + setVisionMode() {}, + showBriefPage() {}, + startBriefRotation() {}, + stopBriefRotation() {}, + pushCockpitSignal() {}, + scheduleContextLayout() {}, + clearPredictiveRoute() {}, + }); + t.after(() => { + cockpit.stop(); + app.unmount(); + document.body.classList.remove('cockpit-mode'); + }); + const $ = (selector) => document.querySelector(selector); + return { $, cockpit, store, converted, hiddenFocus }; +} + +const shown = ($) => ({ + entry: !$('#cockpit-entry').hidden, + tr3b: !$('#tr3b-toggle').hidden, + hud: !$('#cockpit-hud').hidden, + exit: !$('#map-view-switch').hidden, + reset: !$('#cockpit-reset-globe').hidden, + briefing: !$('#cockpit-signal-stream').hidden, +}); + +test('before anything is tracked, every Cockpit surface is hidden', (t) => { + const { $ } = fixture(t); + assert.deepEqual(shown($), { + entry: false, + tr3b: false, + hud: false, + exit: false, + reset: false, + briefing: false, + }); + assert.equal($('#cockpit-context').hidden, true); +}); + +test('a tracked aircraft shows the entry and a TR-3B chip that names its action', async (t) => { + const { $, cockpit } = fixture(t); + cockpit.syncEntry(); + await nextTick(); + assert.deepEqual(shown($), { + entry: true, + tr3b: true, + hud: false, + exit: false, + reset: false, + briefing: false, + }); + const chip = $('#tr3b-toggle'); + assert.equal(chip.getAttribute('aria-pressed'), 'false'); + assert.equal(chip.title, 'Reclassify as TR-3B'); + chip.click(); + await nextTick(); + assert.equal(chip.getAttribute('aria-pressed'), 'true'); + assert.equal(chip.title, 'Restore real aircraft'); + // The static accessible name survives the re-render. + assert.equal( + chip.getAttribute('aria-label'), + 'Reclassify tracked contact as TR-3B', + ); +}); + +test('entering shows the HUD and focuses the exit once it renders; exiting returns focus to the entry', async (t) => { + const { $, cockpit } = fixture(t); + cockpit.syncEntry(); + await nextTick(); + const entry = $('#cockpit-entry'); + entry.focus(); + entry.click(); + assert.equal(cockpit.active, true); + await nextTick(); + await Promise.resolve(); + assert.deepEqual(shown($), { + entry: false, + tr3b: false, + hud: true, + exit: true, + reset: true, + briefing: true, + }); + const hud = $('#cockpit-hud'); + assert.equal(hud.classList.contains('signals-active'), true); + assert.equal(document.activeElement?.id, 'map-view-switch'); + + $('#map-view-switch').click(); + assert.equal(cockpit.active, false); + await nextTick(); + await Promise.resolve(); + assert.deepEqual(shown($), { + entry: true, + tr3b: true, + hud: false, + exit: false, + reset: false, + briefing: false, + }); + assert.equal(hud.classList.contains('signals-active'), false); + assert.equal(document.activeElement?.id, 'cockpit-entry'); +}); + +test('exiting leaves focus alone when the entry is no longer available', async (t) => { + const { $, cockpit, hiddenFocus } = fixture(t); + cockpit.syncEntry(); + await nextTick(); + $('#cockpit-entry').focus(); + cockpit.enter(); + await nextTick(); + await Promise.resolve(); + cockpit.isEntryAllowed = () => false; + cockpit.exit(); + await nextTick(); + await Promise.resolve(); + assert.equal($('#cockpit-entry').hidden, true); + assert.notEqual(document.activeElement?.id, 'cockpit-entry'); + assert.deepEqual(hiddenFocus, []); +}); + +test('a second entry hides the entry again and focuses the exit', async (t) => { + const { $, cockpit, hiddenFocus } = fixture(t); + cockpit.syncEntry(); + await nextTick(); + for (let pass = 0; pass < 2; pass += 1) { + $('#cockpit-entry').focus(); + cockpit.enter(); + await nextTick(); + await Promise.resolve(); + assert.equal($('#cockpit-entry').hidden, true); + assert.equal($('#tr3b-toggle').hidden, true); + assert.equal(document.activeElement?.id, 'map-view-switch'); + cockpit.exit(); + await nextTick(); + await Promise.resolve(); + assert.equal($('#cockpit-entry').hidden, false); + assert.equal($('#tr3b-toggle').hidden, false); + assert.equal(document.activeElement?.id, 'cockpit-entry'); + } + assert.deepEqual(hiddenFocus, []); +}); + +test('disposing an active Cockpit hides the HUD and leaves the entry hidden', async (t) => { + const { $, cockpit } = fixture(t); + cockpit.syncEntry(); + await nextTick(); + cockpit.enter(); + await nextTick(); + cockpit.dispose(); + await nextTick(); + await Promise.resolve(); + assert.equal($('#cockpit-hud').hidden, true); + assert.equal($('#map-view-switch').hidden, true); + assert.equal($('#cockpit-entry').hidden, true); + assert.notEqual(document.activeElement?.id, 'cockpit-entry'); +}); + +test('the Contact card and the HUD mark show and hide together', async (t) => { + const { $, cockpit } = fixture(t); + const hud = $('#cockpit-hud'); + hud.classList.add('voice-highlight'); + cockpit.showContextCard(true); + await nextTick(); + assert.equal($('#cockpit-context').hidden, false); + assert.equal(hud.classList.contains('context-active'), true); + cockpit.showContextCard(false); + await nextTick(); + assert.equal($('#cockpit-context').hidden, true); + assert.equal(hud.classList.contains('context-active'), false); + assert.equal(hud.classList.contains('voice-highlight'), true); +}); + +test('entering asks for a panel layout before Vue shows the cards', async (t) => { + const { cockpit } = fixture(t); + delete cockpit.scheduleContextLayout; + const laid = []; + cockpit.syncContextLayout = () => laid.push('context'); + cockpit.syncSignalLayout = () => laid.push('signals'); + cockpit.syncEntry(); + await nextTick(); + cockpit.enter(); + assert.notEqual(cockpit.contextLayoutFrame, null); + await new Promise((resolve) => requestAnimationFrame(resolve)); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.deepEqual(laid, ['context', 'signals']); +}); diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index 602f5a70d..988860509 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -49,18 +49,27 @@ export function toggleTrackedTr3b() { return true; } +/** + * Publish which Cockpit surfaces are shown: the entry button, the TR-3B chip, + * the HUD with its exit and reset buttons and briefing card, and the Contact + * card. The views bind these; nothing else writes their visibility. + */ +export function publishPresenceView() { + const presence = this.presence; + this.publishPresence({ + ...presence, + tr3b: presence.tr3b && { ...presence.tr3b }, + }); +} + 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 - ? t('Restore real aircraft') - : t('Reclassify as TR-3B'); + this.presence.tr3b = icao24 ? { converted } : null; + this.publishPresenceView(); } export function syncEntry() { @@ -70,14 +79,12 @@ export function syncEntry() { 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; + // Change-only publishes: this runs on a preUpdate cadence, and a fresh + // presence every frame would re-render the views even when nothing + // changed. (perf item 9) + if (this.presence.entryAvailable === available) return; + this.presence.entryAvailable = available; + this.publishPresenceView(); } export function navigateContext(direction, options = {}) { @@ -164,20 +171,15 @@ export function enter() { 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'); + // Activation hides the entry and TR-3B chip; clearing the chip's signature + // lets the exit-path syncEntry publish it again. + this._tr3bSignature = null; + Object.assign(this.presence, { + active: true, + entryAvailable: false, + tr3b: null, + }); + this.publishPresenceView(); this.signalItems = []; this.signalSignatures.clear(); this.showBriefPage(0); @@ -199,7 +201,11 @@ export function enter() { this.updateHud(info, performance.now(), true); this.setVisionMode(this.getInitialVisionMode()); this.scheduleContextLayout(); - this.mapViewButton?.focus({ preventScroll: true }); + // The exit button shows when Vue flushes the presence; focus it after. + Promise.resolve().then(() => { + if (this.active && !this.destroyed) + this.mapViewButton?.focus({ preventScroll: true }); + }); this.onEntered?.(); this.dispatchCockpitModeChanged(true, info); return true; @@ -228,12 +234,11 @@ export function exit({ restoreTracking = true } = {}) { this.onExited?.(); this.hud?.style.removeProperty('--cockpit-utility-top'); this.hud?.style.removeProperty('--cockpit-utility-max-height'); - if (this.hud) this.hud.hidden = true; + this.presence.active = false; + this.publishPresenceView(); this.routeView.visible = false; 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; @@ -249,8 +254,15 @@ export function exit({ restoreTracking = true } = {}) { ? this.entry : this.entry || this.entryFocusOrigin; this.entryFocusOrigin = null; - if (!this.destroyed && restoreTarget?.isConnected && !restoreTarget.hidden) { - restoreTarget.focus({ preventScroll: true }); - } + // The entry shows when Vue flushes the presence; read it after. + Promise.resolve().then(() => { + if ( + !this.destroyed && + !this.active && + restoreTarget?.isConnected && + !restoreTarget.hidden + ) + restoreTarget.focus({ preventScroll: true }); + }); return true; } diff --git a/src/ui/stores/cockpit.js b/src/ui/stores/cockpit.js index a3c767339..c28f98074 100644 --- a/src/ui/stores/cockpit.js +++ b/src/ui/stores/cockpit.js @@ -3,13 +3,21 @@ import { markRaw, shallowRef } from 'vue'; /** * Cockpit content as its views render it. The Cockpit controller presents - * the briefing card's pages, cycle toggle, regional news and local + * which Cockpit surfaces are shown, the briefing card's pages, cycle toggle, regional news and local * conditions, the Contact card's readouts and weather toggle, the * instruments, the flight plan, the vision selector and the signal rows, * and publishes them * here; it keeps the controls' listeners. */ export const useCockpitStore = defineStore('gev-cockpit', () => { + /** Which Cockpit surfaces are shown; null until the controller first syncs. */ + const presence = shallowRef(null); + + /** @param {?object} next A fresh presentation of the shown surfaces. */ + function publishPresence(next) { + presence.value = next && markRaw(next); + } + /** The presented briefing; null until the controller first presents it. */ const brief = shallowRef(null); @@ -59,6 +67,8 @@ export const useCockpitStore = defineStore('gev-cockpit', () => { } return { + presence, + publishPresence, brief, publishBrief, context, diff --git a/src/ui/views/CockpitView.vue b/src/ui/views/CockpitView.vue index 229d72921..1c0af4be9 100644 --- a/src/ui/views/CockpitView.vue +++ b/src/ui/views/CockpitView.vue @@ -16,9 +16,11 @@ import CockpitUtilityControls from './panels/CockpitUtilityControls.vue'; // The Cockpit HUD. The Cockpit controller presents the instruments, the // destination arrow and the vision selector on every HUD update; until it -// does, they show the state they start in. The HUD's visibility, classes -// and layout stay with the controller, and the controls keep its listeners. -const { instruments, route, vision } = storeToRefs(useCockpitStore()); +// does, they show the state they start in. The controller also publishes +// which surfaces are shown; the HUD's layout stays with it, and the controls +// keep its listeners. +const { presence, instruments, route, vision } = storeToRefs(useCockpitStore()); +const active = computed(() => Boolean(presence.value?.active)); const initialInstruments = createInstrumentState(); const initialRoute = createRouteState(); const initialVision = createVisionState(); @@ -41,7 +43,7 @@ const tickStyle = (tick) =>