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) =>