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.
This commit is contained in:
Sameh Khamis committed 2026-10-31 17:44:33 -07:00
1 parent 484243fe4b
commit f279200af5
18 files changed
+418 -70

No files matched your search

+8 -3
View File
@@ -155,9 +155,14 @@ a feature supplying only its accent.
before the focused row is removed, so a keyed list reorders several times before the focused row is removed, so a keyed list reorders several times
a second without dropping keyboard focus. The instruments publish on a second without dropping keyboard focus. The instruments publish on
every HUD update (about 10 Hz); in a dev build their Vue patch costs 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 about 0.5 ms, close to the DOM writes it replaced. The controller also
controller's listeners, and the cards' visibility, collapse and layout publishes which surfaces are shown: the entry button and TR-3B chip in
stay with the controller. `CockpitUtilityControls` renders the Display 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 and Radio launchers, which popover is open and the compact Radio
controls from the `gev-radio` store that RadioControls publishes to; the controls from the `gev-radio` store that RadioControls publishes to; the
Display popover's slots stay static for the Cockpit display portal). Display popover's slots stay static for the Cockpit display portal).
+5 -5
View File
@@ -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, /resolveCockpitContextReadout\(\{ snapshot, info \}\)/);
assert.match( assert.match(
body, body,
/if \(!readout\.visible\) \{[\s\S]*?this\.context\.hidden = true;/, /if \(!readout\.visible\) \{[\s\S]*?this\.showContextCard\(false\);/,
); );
assert.equal( assert.equal(
(body.match(/this\.context\.hidden = true/g) || []).length, (body.match(/this\.showContextCard\(false\)/g) || []).length,
1, 1,
'the panel has exactly one hide path, and it is the no-snapshot standby case', '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( assert.match(
cockpitEnter.toString(), cockpitEnter.toString(),
/if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/, /Object\.assign\(this\.presence, \{\s*active: true,/,
); );
assert.match( assert.match(
readRadioSource( readRadioSource(
new URL('./ui/cockpitTrackingController.js', import.meta.url), new URL('./ui/views/CockpitView.vue', import.meta.url),
'utf8', 'utf8',
), ),
/if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/, /id="cockpit-reset-globe"[^>]*:hidden="!active"/,
); );
}); });
-1
View File
@@ -201,7 +201,6 @@ export function createLifecycle({
if (layerState._dataSource) layerState._dataSource.show = true; if (layerState._dataSource) layerState._dataSource.show = true;
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
parts.selection.focusFullGlobe(layerState._viewer); parts.selection.focusFullGlobe(layerState._viewer);
document.getElementById('cockpit-context')?.setAttribute('hidden', '');
if (layerState._selectedLaunchId) if (layerState._selectedLaunchId)
parts.selection.setSelectedMission( parts.selection.setSelectedMission(
layerState._selectedLaunchId, layerState._selectedLaunchId,
+2
View File
@@ -376,6 +376,8 @@ export class StyleManager extends ShellFacade {
}, },
operations: { operations: {
publishCockpit: { publishCockpit: {
presence: (view) =>
useCockpitStore(applicationPinia()).publishPresence(view),
brief: (view) => brief: (view) =>
useCockpitStore(applicationPinia()).publishBrief(view), useCockpitStore(applicationPinia()).publishBrief(view),
context: (view) => context: (view) =>
+9 -4
View File
@@ -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) { export function updateContext(info, heading) {
if (!this.context) return; if (!this.context) return;
const view = this.contextView; const view = this.contextView;
@@ -46,8 +53,7 @@ export function updateContext(info, heading) {
const trackedId = info.icao24 || info.id; const trackedId = info.icao24 || info.id;
const readout = resolveCockpitContextReadout({ snapshot, info }); const readout = resolveCockpitContextReadout({ snapshot, info });
if (!readout.visible) { if (!readout.visible) {
this.context.hidden = true; this.showContextCard(false);
this.hud?.classList.remove('context-active');
this.contextLayoutStamp = null; this.contextLayoutStamp = null;
this.pushCockpitSignal( this.pushCockpitSignal(
'context-status', 'context-status',
@@ -58,8 +64,7 @@ export function updateContext(info, heading) {
return; return;
} }
this.context.hidden = false; this.showContextCard(true);
this.hud?.classList.add('context-active');
const installationCoverage = snapshot.cohorts.find( const installationCoverage = snapshot.cohorts.find(
(cohort) => cohort.id === 'military-installations', (cohort) => cohort.id === 'military-installations',
)?.coverage; )?.coverage;
+10 -4
View File
@@ -6,7 +6,11 @@ import assert from 'node:assert/strict';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import { createApp, nextTick } from 'vue'; import { createApp, nextTick } from 'vue';
import { setLocale, t as translate } from '../i18n/index.js'; 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 { createContextState } from './cockpitContextState.js';
import { syncWeatherToggle } from './cockpitInstruments.js'; import { syncWeatherToggle } from './cockpitInstruments.js';
import { useCockpitStore } from './stores/cockpit.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. // The Contact presenter needs only this much of the controller.
const owner = { const owner = {
context: $('#cockpit-context'), context: $('#cockpit-context'),
hud: document.createElement('section'), presence: { context: false },
contextView: createContextState(), contextView: createContextState(),
contextLayoutStamp: null, contextLayoutStamp: null,
publishContext: (view) => store.publishContext(view), publishContext: (view) => store.publishContext(view),
publishContextView, publishContextView,
publishPresenceView: () => store.publishPresence({ ...owner.presence }),
showContextCard,
pushCockpitSignal: (...args) => signals.push(args), pushCockpitSignal: (...args) => signals.push(args),
updateCockpitSignals() {}, updateCockpitSignals() {},
scheduleContextLayout: () => layouts.push(true), 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'); const card = $('#cockpit-context');
assert.equal(card.hidden, false); assert.equal(card.hidden, false);
assert.equal(card.dataset.state, 'uncertain'); assert.equal(card.dataset.state, 'uncertain');
assert.equal(owner.hud.classList.contains('context-active'), true); assert.equal(owner.presence.context, true);
assert.match( assert.match(
text('#cockpit-context-subject'), text('#cockpit-context-subject'),
/^RCH123 · 250\u00a0KM AIR\/SEA WINDOW$/, /^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); updateContext.call(owner, INFO, 0);
await nextTick(); await nextTick();
assert.equal($('#cockpit-context').hidden, true); 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'); assert.equal(signals.at(-1)[2], 'CONTEXT STANDBY');
}); });
+19 -1
View File
@@ -5,6 +5,7 @@ import {
toggleTrackedTr3b, toggleTrackedTr3b,
syncTr3bToggle, syncTr3bToggle,
syncEntry, syncEntry,
publishPresenceView,
navigateContext, navigateContext,
_adoptTrackedEntity, _adoptTrackedEntity,
enter, enter,
@@ -21,7 +22,11 @@ import {
cycleVisionMode, cycleVisionMode,
clearPredictiveRoute, clearPredictiveRoute,
} from './cockpitInstruments.js'; } from './cockpitInstruments.js';
import { publishContextView, updateContext } from './cockpitContext.js'; import {
publishContextView,
showContextCard,
updateContext,
} from './cockpitContext.js';
import { createContextState } from './cockpitContextState.js'; import { createContextState } from './cockpitContextState.js';
import { createBriefState } from './cockpitBriefPages.js'; import { createBriefState } from './cockpitBriefPages.js';
import { import {
@@ -139,12 +144,19 @@ export class CockpitViewController {
this.briefPageIndex = 0; this.briefPageIndex = 0;
this.briefAutoRotateEnabled = false; this.briefAutoRotateEnabled = false;
// Cockpit's views render what these receive. // Cockpit's views render what these receive.
this.publishPresence = publish.presence ?? (() => {});
this.publishBrief = publish.brief ?? (() => {}); this.publishBrief = publish.brief ?? (() => {});
this.publishContext = publish.context ?? (() => {}); this.publishContext = publish.context ?? (() => {});
this.publishInstruments = publish.instruments ?? (() => {}); this.publishInstruments = publish.instruments ?? (() => {});
this.publishRoute = publish.route ?? (() => {}); this.publishRoute = publish.route ?? (() => {});
this.publishVision = publish.vision ?? (() => {}); this.publishVision = publish.vision ?? (() => {});
this.publishSignals = publish.signals ?? (() => {}); this.publishSignals = publish.signals ?? (() => {});
this.presence = {
active: false,
entryAvailable: false,
tr3b: null,
context: false,
};
this.instruments = createInstrumentState(); this.instruments = createInstrumentState();
this.routeView = createRouteState(); this.routeView = createRouteState();
this.routeSignature = null; this.routeSignature = null;
@@ -260,6 +272,9 @@ export class CockpitViewController {
toggleTrackedTr3b() { toggleTrackedTr3b() {
return toggleTrackedTr3b.call(this); return toggleTrackedTr3b.call(this);
} }
publishPresenceView() {
return publishPresenceView.call(this);
}
syncTr3bToggle(info) { syncTr3bToggle(info) {
return syncTr3bToggle.call(this, info); return syncTr3bToggle.call(this, info);
} }
@@ -308,6 +323,9 @@ export class CockpitViewController {
publishContextView() { publishContextView() {
return publishContextView.call(this); return publishContextView.call(this);
} }
showContextCard(visible) {
return showContextCard.call(this, visible);
}
updateContext(info, heading) { updateContext(info, heading) {
return updateContext.call(this, info, heading); return updateContext.call(this, info, heading);
} }
+8
View File
@@ -100,6 +100,14 @@ export class CockpitDisplayPortal {
} }
this._frame(generation, () => { this._frame(generation, () => {
restoreScroll(); restoreScroll();
// 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 }); focusedElement?.focus?.({ preventScroll: true });
this._frame(generation, () => { this._frame(generation, () => {
restoreScroll(); restoreScroll();
+16
View File
@@ -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', () => { test('superseded portal frames cannot restore obsolete focus or scroll', () => {
const f = fixture(); const f = fixture();
try { try {
+6 -5
View File
@@ -63,7 +63,7 @@ function fixture(t) {
const ticks = (kind) => [ const ticks = (kind) => [
...document.querySelectorAll(`[data-${kind}-rim-tick]`), ...document.querySelectorAll(`[data-${kind}-rim-tick]`),
]; ];
return { $, text, ticks, owner, published }; return { $, text, ticks, owner, published, store };
} }
const INFO = Object.freeze({ 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); 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) => { test('re-renders keep the visibility and classes the controller publishes on the HUD', async (t) => {
const { $, owner } = fixture(t); const { $, owner, store } = fixture(t);
const hud = $('#cockpit-hud'); const hud = $('#cockpit-hud');
hud.hidden = false; store.publishPresence({ active: true, context: false });
hud.classList.add('signals-active'); hud.classList.add('voice-highlight');
hud.style.setProperty('--cockpit-utility-top', '120px'); hud.style.setProperty('--cockpit-utility-top', '120px');
$('#cockpit-speed-rim').classList.add('external'); $('#cockpit-speed-rim').classList.add('external');
updateHud.call(owner, INFO, 1000, true); 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(); await nextTick();
assert.equal(hud.hidden, false); assert.equal(hud.hidden, false);
assert.equal(hud.classList.contains('signals-active'), true); 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(hud.style.getPropertyValue('--cockpit-utility-top'), '120px');
assert.equal($('#cockpit-speed-rim').classList.contains('external'), true); assert.equal($('#cockpit-speed-rim').classList.contains('external'), true);
}); });
+6 -2
View File
@@ -16,8 +16,12 @@ import {
export function scheduleContextLayout() { export function scheduleContextLayout() {
if (this.destroyed) return false; if (this.destroyed) return false;
const contextVisible = this.context && !this.context.hidden; // The published presence shows the cards on Vue's next flush, before this
const signalVisible = this.signalStream && !this.signalStream.hidden; // 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) if ((!contextVisible && !signalVisible) || this.contextLayoutFrame !== null)
return; return;
this.contextLayoutFrame = requestAnimationFrame(() => { this.contextLayoutFrame = requestAnimationFrame(() => {
+257
View File
@@ -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']);
});
+44 -32
View File
@@ -49,18 +49,27 @@ export function toggleTrackedTr3b() {
return true; 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) { export function syncTr3bToggle(info) {
if (!this.tr3bToggle) return;
const icao24 = String(info?.icao24 || '').trim(); const icao24 = String(info?.icao24 || '').trim();
const converted = !!icao24 && this.services.isTr3b(icao24); const converted = !!icao24 && this.services.isTr3b(icao24);
const signature = icao24 ? `${icao24}:${converted ? 1 : 0}` : ''; const signature = icao24 ? `${icao24}:${converted ? 1 : 0}` : '';
if (this._tr3bSignature === signature) return; if (this._tr3bSignature === signature) return;
this._tr3bSignature = signature; this._tr3bSignature = signature;
this.tr3bToggle.hidden = !icao24; this.presence.tr3b = icao24 ? { converted } : null;
this.tr3bToggle.setAttribute('aria-pressed', converted ? 'true' : 'false'); this.publishPresenceView();
this.tr3bToggle.title = converted
? t('Restore real aircraft')
: t('Reclassify as TR-3B');
} }
export function syncEntry() { export function syncEntry() {
@@ -70,14 +79,12 @@ export function syncEntry() {
const trackedContact = !!(info && this.viewer.trackedEntity?.position); const trackedContact = !!(info && this.viewer.trackedEntity?.position);
this.syncTr3bToggle(trackedContact ? info : null); this.syncTr3bToggle(trackedContact ? info : null);
const available = !!(this.isEntryAllowed() && trackedContact); const available = !!(this.isEntryAllowed() && trackedContact);
// Change-only DOM writes: this runs on a preUpdate cadence, and // Change-only publishes: this runs on a preUpdate cadence, and a fresh
// unconditional `hidden` assignments invalidate style/layout every frame // presence every frame would re-render the views even when nothing
// even when nothing changed. (perf item 9) // changed. (perf item 9)
if (this._entryAvailable === available) return; if (this.presence.entryAvailable === available) return;
this._entryAvailable = available; this.presence.entryAvailable = available;
if (this.entry) this.entry.hidden = !available; this.publishPresenceView();
if (this.mapViewButton) this.mapViewButton.hidden = true;
if (this.resetGlobeButton) this.resetGlobeButton.hidden = true;
} }
export function navigateContext(direction, options = {}) { export function navigateContext(direction, options = {}) {
@@ -164,20 +171,15 @@ export function enter() {
this.viewer.trackedEntity = undefined; this.viewer.trackedEntity = undefined;
this.viewer.scene.screenSpaceCameraController.enableInputs = false; this.viewer.scene.screenSpaceCameraController.enableInputs = false;
document.body.classList.add('cockpit-mode'); document.body.classList.add('cockpit-mode');
// Activation writes entry/quick/map visibility directly, bypassing // Activation hides the entry and TR-3B chip; clearing the chip's signature
// syncEntry's change-only cache — invalidate it so the exit-path // lets the exit-path syncEntry publish it again.
// 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; this._tr3bSignature = null;
} Object.assign(this.presence, {
if (this.mapViewButton) this.mapViewButton.hidden = false; active: true,
if (this.resetGlobeButton) this.resetGlobeButton.hidden = false; entryAvailable: false,
if (this.hud) this.hud.hidden = false; tr3b: null,
if (this.signalStream) this.signalStream.hidden = false; });
this.hud?.classList.add('signals-active'); this.publishPresenceView();
this.signalItems = []; this.signalItems = [];
this.signalSignatures.clear(); this.signalSignatures.clear();
this.showBriefPage(0); this.showBriefPage(0);
@@ -199,7 +201,11 @@ export function enter() {
this.updateHud(info, performance.now(), true); this.updateHud(info, performance.now(), true);
this.setVisionMode(this.getInitialVisionMode()); this.setVisionMode(this.getInitialVisionMode());
this.scheduleContextLayout(); this.scheduleContextLayout();
// 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.mapViewButton?.focus({ preventScroll: true });
});
this.onEntered?.(); this.onEntered?.();
this.dispatchCockpitModeChanged(true, info); this.dispatchCockpitModeChanged(true, info);
return true; return true;
@@ -228,12 +234,11 @@ export function exit({ restoreTracking = true } = {}) {
this.onExited?.(); this.onExited?.();
this.hud?.style.removeProperty('--cockpit-utility-top'); this.hud?.style.removeProperty('--cockpit-utility-top');
this.hud?.style.removeProperty('--cockpit-utility-max-height'); 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.routeView.visible = false;
this.clearPredictiveRoute(); this.clearPredictiveRoute();
this.setVisionMode('optical'); this.setVisionMode('optical');
if (this.signalStream) this.signalStream.hidden = true;
this.hud?.classList.remove('signals-active');
this.viewer.scene.screenSpaceCameraController.enableInputs = true; this.viewer.scene.screenSpaceCameraController.enableInputs = true;
if (entity && this.viewer.entities.contains(entity)) if (entity && this.viewer.entities.contains(entity))
entity.show = this.trackedEntityWasShown; entity.show = this.trackedEntityWasShown;
@@ -249,8 +254,15 @@ export function exit({ restoreTracking = true } = {}) {
? this.entry ? this.entry
: this.entry || this.entryFocusOrigin; : this.entry || this.entryFocusOrigin;
this.entryFocusOrigin = null; this.entryFocusOrigin = null;
if (!this.destroyed && restoreTarget?.isConnected && !restoreTarget.hidden) { // 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 }); restoreTarget.focus({ preventScroll: true });
} });
return true; return true;
} }
+11 -1
View File
@@ -3,13 +3,21 @@ import { markRaw, shallowRef } from 'vue';
/** /**
* Cockpit content as its views render it. The Cockpit controller presents * 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 * conditions, the Contact card's readouts and weather toggle, the
* instruments, the flight plan, the vision selector and the signal rows, * instruments, the flight plan, the vision selector and the signal rows,
* and publishes them * and publishes them
* here; it keeps the controls' listeners. * here; it keeps the controls' listeners.
*/ */
export const useCockpitStore = defineStore('gev-cockpit', () => { 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. */ /** The presented briefing; null until the controller first presents it. */
const brief = shallowRef(null); const brief = shallowRef(null);
@@ -59,6 +67,8 @@ export const useCockpitStore = defineStore('gev-cockpit', () => {
} }
return { return {
presence,
publishPresence,
brief, brief,
publishBrief, publishBrief,
context, context,
+8 -6
View File
@@ -16,9 +16,11 @@ import CockpitUtilityControls from './panels/CockpitUtilityControls.vue';
// The Cockpit HUD. The Cockpit controller presents the instruments, the // The Cockpit HUD. The Cockpit controller presents the instruments, the
// destination arrow and the vision selector on every HUD update; until it // 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 // does, they show the state they start in. The controller also publishes
// and layout stay with the controller, and the controls keep its listeners. // which surfaces are shown; the HUD's layout stays with it, and the controls
const { instruments, route, vision } = storeToRefs(useCockpitStore()); // keep its listeners.
const { presence, instruments, route, vision } = storeToRefs(useCockpitStore());
const active = computed(() => Boolean(presence.value?.active));
const initialInstruments = createInstrumentState(); const initialInstruments = createInstrumentState();
const initialRoute = createRouteState(); const initialRoute = createRouteState();
const initialVision = createVisionState(); const initialVision = createVisionState();
@@ -41,7 +43,7 @@ const tickStyle = (tick) =>
<template> <template>
<!-- Aircraft first-person view. It can be entered from the visible view switcher. --> <!-- Aircraft first-person view. It can be entered from the visible view switcher. -->
<section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden data-i18n-aria-label :data-layer="hud.layer ?? undefined"> <section id="cockpit-hud" aria-label="Aircraft cockpit view" :hidden="!active" v-class-toggle="{ 'signals-active': active, 'context-active': Boolean(presence?.context) }" data-i18n-aria-label :data-layer="hud.layer ?? undefined">
<!-- One optical coordinate system: every targeting mark shares this center. <!-- One optical coordinate system: every targeting mark shares this center.
Decorative only; telemetry and interactive panels remain separate. --> Decorative only; telemetry and interactive panels remain separate. -->
<svg class="cockpit-frame-lines" viewBox="0 0 1200 720" preserveAspectRatio="none" aria-hidden="true" focusable="false"> <svg class="cockpit-frame-lines" viewBox="0 0 1200 720" preserveAspectRatio="none" aria-hidden="true" focusable="false">
@@ -186,11 +188,11 @@ const tickStyle = (tick) =>
</section> </section>
<nav id="view-switcher" aria-label="View switcher" data-i18n-aria-label> <nav id="view-switcher" aria-label="View switcher" data-i18n-aria-label>
<button id="cockpit-reset-globe" type="button" aria-label="Reset cockpit to full globe view" title="Exit cockpit and return to full globe view" hidden data-i18n-aria-label data-i18n-title> <button id="cockpit-reset-globe" type="button" aria-label="Reset cockpit to full globe view" title="Exit cockpit and return to full globe view" :hidden="!active" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">public</span> <span class="material-symbols-outlined" aria-hidden="true">public</span>
<span data-i18n>RESET</span> <span data-i18n>RESET</span>
</button> </button>
<button id="map-view-switch" type="button" aria-label="Exit cockpit view" aria-keyshortcuts="Escape C" title="Exit cockpit view" hidden data-i18n-aria-label data-i18n-title> <button id="map-view-switch" type="button" aria-label="Exit cockpit view" aria-keyshortcuts="Escape C" title="Exit cockpit view" :hidden="!active" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">close_fullscreen</span> <span class="material-symbols-outlined" aria-hidden="true">close_fullscreen</span>
<span data-i18n>EXIT COCKPIT</span> <span data-i18n>EXIT COCKPIT</span>
</button> </button>
+5 -2
View File
@@ -1,6 +1,8 @@
<script setup> <script setup>
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js';
import { vClassToggle } from '../rail/classToggle.js'; import { vClassToggle } from '../rail/classToggle.js';
import { useCockpitStore } from '../stores/cockpit.js';
import { useContextStore } from '../stores/context.js'; import { useContextStore } from '../stores/context.js';
import AwarenessPanel from './panels/AwarenessPanel.vue'; import AwarenessPanel from './panels/AwarenessPanel.vue';
import MissionDetail from './panels/MissionDetail.vue'; import MissionDetail from './panels/MissionDetail.vue';
@@ -10,6 +12,7 @@ import RadioPanel from './panels/RadioPanel.vue';
// ContextControls presents the mode switch and the nearby sites search and // ContextControls presents the mode switch and the nearby sites search and
// publishes them; until it does, the panel shows its initial markup. // publishes them; until it does, the panel shows its initial markup.
const { view: ctx } = storeToRefs(useContextStore()); const { view: ctx } = storeToRefs(useContextStore());
const { presence: cockpit } = storeToRefs(useCockpitStore());
</script> </script>
<template> <template>
@@ -64,12 +67,12 @@ const { view: ctx } = storeToRefs(useContextStore());
</div> </div>
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" :hidden="!ctx?.flightsActive"> <section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" :hidden="!ctx?.flightsActive">
<div class="global-context-actions" aria-label="Contact Context actions" data-i18n-aria-label> <div class="global-context-actions" aria-label="Contact Context actions" data-i18n-aria-label>
<button id="cockpit-entry" type="button" hidden> <button id="cockpit-entry" type="button" :hidden="!cockpit?.entryAvailable">
<span class="material-symbols-outlined" aria-hidden="true">flight</span> <span class="material-symbols-outlined" aria-hidden="true">flight</span>
<span data-i18n>COCKPIT</span> <span data-i18n>COCKPIT</span>
</button> </button>
<button id="installations-search-btn" class="panel-layer-toggle" type="button" :aria-disabled="ctx && String(ctx.installationsBusy)" :aria-busy="ctx && String(ctx.installationsBusy)" data-i18n>SEARCH NEARBY SITES</button> <button id="installations-search-btn" class="panel-layer-toggle" type="button" :aria-disabled="ctx && String(ctx.installationsBusy)" :aria-busy="ctx && String(ctx.installationsBusy)" data-i18n>SEARCH NEARBY SITES</button>
<button id="tr3b-toggle" class="tr3b-toggle" type="button" aria-pressed="false" hidden aria-label="Reclassify tracked contact as TR-3B" title="Reclassify as TR-3B" data-i18n-aria-label data-i18n-title>🛸</button> <button id="tr3b-toggle" class="tr3b-toggle" type="button" :aria-pressed="String(!!cockpit?.tr3b?.converted)" :hidden="!cockpit?.tr3b" aria-label="Reclassify tracked contact as TR-3B" :title="cockpit?.tr3b?.converted ? t('Restore real aircraft') : t('Reclassify as TR-3B')" data-i18n-aria-label>🛸</button>
</div> </div>
<AwarenessPanel /> <AwarenessPanel />
</section> </section>
+2 -2
View File
@@ -10,7 +10,7 @@ import CockpitSignalList from './CockpitSignalList.vue';
// cycle toggle, regional news and local conditions; until it does, the card // cycle toggle, regional news and local conditions; until it does, the card
// shows the state it starts in. The controls keep the controller's // shows the state it starts in. The controls keep the controller's
// listeners. The route card and the signal rows render themselves. // listeners. The route card and the signal rows render themselves.
const { brief } = storeToRefs(useCockpitStore()); const { presence, brief } = storeToRefs(useCockpitStore());
const initial = createBriefState(); const initial = createBriefState();
const b = computed(() => brief.value ?? initial); const b = computed(() => brief.value ?? initial);
// The card's page and the region's answer, as state hooks on the card. // The card's page and the region's answer, as state hooks on the card.
@@ -32,7 +32,7 @@ function leaveSignals() {
</script> </script>
<template> <template>
<aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" hidden data-i18n-aria-label v-bind="streamState" ref="stream"> <aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" :hidden="!presence?.active" data-i18n-aria-label v-bind="streamState" ref="stream">
<CockpitRoute /> <CockpitRoute />
<header class="cockpit-signal-header"> <header class="cockpit-signal-header">
<div class="cockpit-brief-heading"> <div class="cockpit-brief-heading">
+2 -2
View File
@@ -10,7 +10,7 @@ import { useCockpitStore } from '../../stores/cockpit.js';
// navigation, cohort counts, nearest contact and weather toggle; until it // navigation, cohort counts, nearest contact and weather toggle; until it
// does, the card shows the state it starts in. Its visibility, collapse and // does, the card shows the state it starts in. Its visibility, collapse and
// layout stay with the controller, and the controls keep its listeners. // layout stay with the controller, and the controls keep its listeners.
const { context } = storeToRefs(useCockpitStore()); const { presence, context } = storeToRefs(useCockpitStore());
const initial = createContextState(); const initial = createContextState();
const c = computed(() => context.value ?? initial); const c = computed(() => context.value ?? initial);
const directionStyle = computed(() => const directionStyle = computed(() =>
@@ -26,7 +26,7 @@ const weatherLabel = computed(() =>
</script> </script>
<template> <template>
<aside id="cockpit-context" class="cockpit-context-window" aria-label="Contact cockpit summary" hidden data-i18n-aria-label :data-state="c.state ?? undefined"> <aside id="cockpit-context" class="cockpit-context-window" aria-label="Contact cockpit summary" :hidden="!presence?.context" data-i18n-aria-label :data-state="c.state ?? undefined">
<header class="cockpit-context-header"> <header class="cockpit-context-header">
<div> <div>
<span data-i18n class="cockpit-context-kicker">CONTACT</span> <span data-i18n class="cockpit-context-kicker">CONTACT</span>