diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index d056947bd..b245e9f8c 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -128,12 +128,17 @@ They contain no application state. backdrop to the end of the page from the first publish until the shell is destroyed; the shell takes their clicks from the document), then Cockpit (in progress: `CockpitBriefing` renders the briefing card's - pages, cycle toggle, regional news and local conditions from the - `gev-cockpit` store that the Cockpit controller publishes to, starting - from `cockpitBriefPages.js`, which both read; the controls keep the - controller's listeners, and the route card and signal list keep their - owners' bindings for now). Layers that write into the right rail - directly gain a slot instead. + pages, cycle toggle, regional news and local conditions, and + `CockpitContext` the Contact card's scope, navigation, counts, nearest + contact and weather toggle, from the `gev-cockpit` store that the Cockpit + controller publishes to, starting from `cockpitBriefPages.js` and + `cockpitContextState.js`, which both read; the controls keep the + controller's listeners, and the cards' visibility, collapse and layout + stay with the controller. The signal rows, the route card, the + instruments and the radio controls follow; the signal rows become a + component that keeps their focus-safe reorder, since a keyed Vue list + would move the focused row and drop focus several times a second). + Layers that write into the right rail directly gain a slot instead. 4. **Cleanup.** The build-time template expansion and the attribute translation pass are retired once nothing uses them. Hosts register panels diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 546dd0852..e87793bbd 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -28,7 +28,9 @@ "src/ui/views/panels/SdrCard.vue", "src/ui/views/panels/PhoneTabBar.vue", "src/ui/views/panels/CockpitBriefing.vue", + "src/ui/views/panels/CockpitContext.vue", "src/ui/cockpitBriefPages.js", + "src/ui/cockpitContextState.js", "src/ui/views/panels/MissionDetail.vue", "src/ui/views/panels/MissionRoster.vue", "src/ui/views/panels/AwarenessPanel.vue", @@ -727,6 +729,7 @@ "src/ui/cockpit.js", "src/ui/cockpitBriefing.js", "src/ui/cockpitBriefPages.js", + "src/ui/cockpitContextState.js", "src/ui/cockpitCamera.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", @@ -847,6 +850,7 @@ "src/ui/cockpit.js", "src/ui/cockpitBriefing.js", "src/ui/cockpitBriefPages.js", + "src/ui/cockpitContextState.js", "src/ui/cockpitCamera.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", @@ -2548,6 +2552,7 @@ "src/ui/cockpit.js", "src/ui/cockpitBriefing.js", "src/ui/cockpitBriefPages.js", + "src/ui/cockpitContextState.js", "src/ui/cockpitCamera.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 86d5e9b3c..f74031049 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -268,7 +268,7 @@ test('the Contact panel never hides itself out from under its own NEXT button', // A culled subject holds last-known content behind the CONTACT LOST cue. assert.match(body, /readout\.contactLost/); assert.match(body, /CONTACT LOST/); - assert.match(body, /this\.context\.dataset\.state = 'lost'/); + assert.match(body, /view\.state = 'lost'/); assert.match( body, /if \(readout\.contactLost\) \{/, @@ -276,14 +276,14 @@ test('the Contact panel never hides itself out from under its own NEXT button', ); // PREVIOUS/NEXT must be written before any early return, so the operator can // always step off the current contact — including a lost one. - const navIndex = body.indexOf('this.contextNext.disabled'); + const navIndex = body.indexOf('view.canNext ='); const lostIndex = body.indexOf('if (readout.contactLost)'); assert.ok( navIndex >= 0 && lostIndex > navIndex, 'nav state must be written before the CONTACT LOST return', ); assert.equal( - (body.match(/this\.contextNext\.disabled/g) || []).length, + (body.match(/view\.canNext =/g) || []).length, 1, 'one owner for the NEXT enabled state, reached by every visible path', ); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index c334531ae..ffe881b4f 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -375,8 +375,12 @@ export class StyleManager extends ShellFacade { _models3dBtn: this._models3dBtn, }, operations: { - publishCockpitBrief: (view) => - useCockpitStore(applicationPinia()).publishBrief(view), + publishCockpit: { + brief: (view) => + useCockpitStore(applicationPinia()).publishBrief(view), + context: (view) => + useCockpitStore(applicationPinia()).publishContext(view), + }, _layoutRightPanels: (...args) => this._layoutRightPanels(...args), _setCockpitVision: (...args) => this._setCockpitVision(...args), _stampNavigation: (...args) => this._stampNavigation(...args), diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index 792dfdc2e..e7296b210 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -24,8 +24,23 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({ t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }), }); +/** Hand a fresh copy of the Contact card to the view. */ +export function publishContextView() { + const view = this.contextView; + this.publishContext({ + ...view, + cohorts: Object.fromEntries( + Object.entries(view.cohorts).map(([id, cohort]) => [id, { ...cohort }]), + ), + nearest: { ...view.nearest }, + distance: { ...view.distance }, + direction: { ...view.direction }, + }); +} + export function updateContext(info, heading) { if (!this.context) return; + const view = this.contextView; const snapshot = this.services.militaryAwarenessLayer.getContextSnapshot?.() || null; const trackedId = info.icao24 || info.id; @@ -45,35 +60,30 @@ export function updateContext(info, heading) { this.context.hidden = false; this.hud?.classList.add('context-active'); - if (this.contextSubject) { - const installationCoverage = snapshot.cohorts.find( - (cohort) => cohort.id === 'military-installations', - )?.coverage; - this.contextSubject.textContent = formatCockpitContextScope( - snapshot.subject.label || trackedId, - snapshot.radiusM, - installationCoverage, - LOCALIZED_CONTEXT_SCOPE, - ); - } + const installationCoverage = snapshot.cohorts.find( + (cohort) => cohort.id === 'military-installations', + )?.coverage; + view.subject = formatCockpitContextScope( + snapshot.subject.label || trackedId, + snapshot.radiusM, + installationCoverage, + LOCALIZED_CONTEXT_SCOPE, + ); // Navigation stays wired in every state — the operator must always be able // to step off the current contact from the panel that hosts the controls. - if (this.contextPrevious) - this.contextPrevious.disabled = !snapshot.navigation?.canPrevious; - if (this.contextNext) - this.contextNext.disabled = !snapshot.navigation?.canNext; + view.canPrevious = Boolean(snapshot.navigation?.canPrevious); + view.canNext = Boolean(snapshot.navigation?.canNext); if (readout.contactLost) { // The subject left its source. Every number below is measured against a // position that stopped updating, so hold the last rendered readout and // say so instead of re-deriving stale geometry as if it were live. - const enteringLost = this.context.dataset.state !== 'lost'; - this.context.dataset.state = 'lost'; - if (this.contextUncertainty) { - this.contextUncertainty.textContent = t( - 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR', - ); - } + const enteringLost = view.state !== 'lost'; + view.state = 'lost'; + view.uncertainty = t( + 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR', + ); + this.publishContextView(); // The cue changes the footer's height; re-run layout once on the way in // rather than every frame the contact stays lost. if (enteringLost) this.scheduleContextLayout(); @@ -91,45 +101,38 @@ export function updateContext(info, heading) { let unknownCount = 0; const nearest = []; for (const cohort of snapshot.cohorts) { - const element = this.contextCohorts.get(cohort.id); - const value = element?.querySelector('strong'); - if (value) - value.textContent = cohort.count === null ? '?' : String(cohort.count); - element?.classList.toggle('unknown', cohort.relationship === 'UNKNOWN'); + const shown = view.cohorts[cohort.id]; + if (shown) { + shown.count = cohort.count === null ? '?' : String(cohort.count); + shown.unknown = cohort.relationship === 'UNKNOWN'; + } if (cohort.count === null) unknownCount += 1; for (const item of cohort.nearest) nearest.push({ ...item, cohort }); } nearest.sort((a, b) => (a.distanceM ?? Infinity) - (b.distanceM ?? Infinity)); const closest = nearest[0] || null; const closestLabel = this.services.formatAwarenessLabel(closest); - if (this.contextNearestLabel) { - this.contextNearestLabel.textContent = closest - ? t('{cohort} · {contact}', { - cohort: closest.cohort.label.toUpperCase(), - contact: closestLabel, - }) - : t('NO AVAILABLE EXAMPLE'); - this.contextNearestLabel.setAttribute( - 'aria-label', + const nearestLabel = closest + ? t('{cohort} · {contact}', { + cohort: closest.cohort.label.toUpperCase(), + contact: closestLabel, + }) + : t('NO AVAILABLE EXAMPLE'); + view.nearest = { + label: nearestLabel, + ariaLabel: closest && closestLabel === '—' - ? t('{cohort}, Unavailable', { - cohort: closest.cohort.label, - }) - : this.contextNearestLabel.textContent, - ); - } - if (this.contextDistance) { - const distanceM = closest?.distanceM; - this.contextDistance.textContent = Number.isFinite(distanceM) - ? formatCockpitDistance(distanceM) - : '—'; - this.contextDistance.setAttribute( - 'aria-label', - Number.isFinite(distanceM) - ? this.contextDistance.textContent - : t('Unavailable'), - ); - } + ? t('{cohort}, Unavailable', { cohort: closest.cohort.label }) + : nearestLabel, + }; + const distanceM = closest?.distanceM; + const distance = Number.isFinite(distanceM) + ? formatCockpitDistance(distanceM) + : '—'; + view.distance = { + text: distance, + ariaLabel: Number.isFinite(distanceM) ? distance : t('Unavailable'), + }; // The arrow and BRG are nose-relative to the tracked aircraft. When the // subject is a vessel, an installation, or another aircraft, the rest of @@ -154,36 +157,27 @@ export function updateContext(info, heading) { : null; relative = relativeBearing(bearing, heading); } - if (this.contextDirection) { - this.contextDirection.style.transform = `rotate(${relative ?? 0}deg)`; - this.contextDirection.classList.toggle('unknown', relative === null); + view.direction = { angle: relative ?? 0, unknown: relative === null }; + if (relative === null) view.bearing = t('BRG —'); + else if (Math.abs(relative) < 8) view.bearing = t('AHEAD'); + else { + view.bearing = t('{side} {angle}', { + side: relative < 0 ? t('L') : t('R'), + angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`, + }); } - if (this.contextBearing) { - if (relative === null) this.contextBearing.textContent = t('BRG —'); - else if (Math.abs(relative) < 8) - this.contextBearing.textContent = t('AHEAD'); - else { - this.contextBearing.textContent = t('{side} {angle}', { - side: relative < 0 ? t('L') : t('R'), - angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`, - }); - } - } - if (this.contextUncertainty) { - this.contextUncertainty.textContent = unknownCount - ? tn( - unknownCount, - '{count} INPUT UNKNOWN · NOT AN ALL-CLEAR', - '{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR', - ) - : t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR'); - } - if (this.contextUpdated) { - this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt) - ? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z' - : '--:--:--Z'; - } - this.context.dataset.state = unknownCount ? 'uncertain' : 'current'; + view.uncertainty = unknownCount + ? tn( + unknownCount, + '{count} INPUT UNKNOWN · NOT AN ALL-CLEAR', + '{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR', + ) + : t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR'); + view.updated = Number.isFinite(snapshot.evaluatedAt) + ? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z' + : '--:--:--Z'; + view.state = unknownCount ? 'uncertain' : 'current'; + this.publishContextView(); this.updateCockpitSignals(snapshot, unknownCount); if (this.contextLayoutStamp !== snapshot.evaluatedAt) { this.contextLayoutStamp = snapshot.evaluatedAt; diff --git a/src/ui/cockpitContext.test.mjs b/src/ui/cockpitContext.test.mjs new file mode 100644 index 000000000..6d36cc75d --- /dev/null +++ b/src/ui/cockpitContext.test.mjs @@ -0,0 +1,244 @@ +// src/ui/cockpitContext.test.mjs — the Cockpit Contact card renders what the +// Cockpit controller presents, over happy-dom. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +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 { createContextState } from './cockpitContextState.js'; +import { syncWeatherToggle } from './cockpitInstruments.js'; +import { useCockpitStore } from './stores/cockpit.js'; +import CockpitContext from './views/panels/CockpitContext.vue'; + +function fixture(t, snapshot = null) { + document.body.replaceChildren(); + const host = document.body.appendChild(document.createElement('div')); + const pinia = createPinia(); + const app = createApp(CockpitContext).use(pinia); + app.mount(host); + t.after(() => app.unmount()); + const store = useCockpitStore(pinia); + const $ = (selector) => document.querySelector(selector); + const signals = []; + const layouts = []; + // The Contact presenter needs only this much of the controller. + const owner = { + context: $('#cockpit-context'), + hud: document.createElement('section'), + contextView: createContextState(), + contextLayoutStamp: null, + publishContext: (view) => store.publishContext(view), + publishContextView, + pushCockpitSignal: (...args) => signals.push(args), + updateCockpitSignals() {}, + scheduleContextLayout: () => layouts.push(true), + services: { + militaryAwarenessLayer: { getContextSnapshot: () => owner.snapshot }, + formatAwarenessLabel: (item) => item?.id ?? '—', + }, + snapshot, + }; + const text = (selector) => $(selector).textContent.trim(); + return { $, text, owner, signals, layouts }; +} + +const INFO = { + layerId: 'flights', + icao24: 'abc123', + latitude: 30, + longitude: -97, +}; + +const snapshot = (overrides = {}) => ({ + subject: { layerId: 'military', id: 'm1', label: 'RCH123' }, + radiusM: 250_000, + evaluatedAt: Date.UTC(2026, 9, 31, 12, 34, 56), + navigation: { canPrevious: false, canNext: true }, + cohorts: [ + { + id: 'flights', + label: 'Flights', + count: 12, + relationship: 'OBSERVED', + nearest: [{ id: 'N1', distanceM: 4200 }], + }, + { + id: 'military', + label: 'Military', + count: null, + relationship: 'UNKNOWN', + nearest: [], + }, + ], + ...overrides, +}); + +test('before the controller presents, the card shows the state it starts in', (t) => { + const { $, text } = fixture(t); + const card = $('#cockpit-context'); + assert.equal(card.hidden, true); + assert.equal(card.hasAttribute('data-state'), false); + assert.equal(text('#cockpit-context-subject'), 'CONTACTS · 250 KM'); + assert.equal($('#cockpit-context-previous').disabled, false); + assert.equal($('#cockpit-context-next').disabled, false); + assert.deepEqual( + [...document.querySelectorAll('[data-context-cohort] strong')].map( + (count) => count.textContent, + ), + ['—', '—', '—', '—'], + ); + assert.equal(text('#cockpit-context-nearest-label'), 'NO AVAILABLE EXAMPLE'); + assert.equal( + $('#cockpit-context-nearest-label').hasAttribute('aria-label'), + false, + ); + assert.equal($('#cockpit-context-direction').getAttribute('style'), null); + assert.equal( + text('#cockpit-context-uncertainty'), + 'AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR', + ); + assert.equal(text('#cockpit-context-updated'), '--:--:--Z'); + const weather = $('#cockpit-weather-toggle'); + assert.equal(weather.getAttribute('aria-pressed'), 'false'); + assert.equal( + weather.getAttribute('aria-label'), + 'Enable cockpit weather effects', + ); + assert.equal(weather.title, 'Enable cockpit weather effects'); + assert.equal(text('#cockpit-weather-state'), 'OFF'); +}); + +test('a snapshot fills the card and keeps classes other code sets', async (t) => { + const { $, text, owner, layouts } = fixture(t, snapshot()); + const flights = $('[data-context-cohort="flights"]'); + flights.classList.add('voice-highlight'); + updateContext.call(owner, INFO, 0); + await nextTick(); + 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.match( + text('#cockpit-context-subject'), + /^RCH123 · 250\u00a0KM AIR\/SEA WINDOW$/, + ); + assert.equal($('#cockpit-context-previous').disabled, true); + assert.equal($('#cockpit-context-next').disabled, false); + assert.equal(flights.querySelector('strong').textContent, '12'); + assert.equal(flights.classList.contains('unknown'), false); + assert.equal(flights.classList.contains('voice-highlight'), true); + const military = $('[data-context-cohort="military"]'); + assert.equal(military.querySelector('strong').textContent, '?'); + assert.equal(military.classList.contains('unknown'), true); + assert.equal(text('#cockpit-context-nearest-label'), 'FLIGHTS · N1'); + assert.equal( + $('#cockpit-context-nearest-label').getAttribute('aria-label'), + 'FLIGHTS · N1', + ); + assert.match(text('#cockpit-context-distance'), /^4\.2/); + // The tracked aircraft is not the subject: the nose-relative half dashes. + assert.equal(text('#cockpit-context-bearing'), 'BRG —'); + const direction = $('#cockpit-context-direction'); + assert.equal(direction.style.transform, 'rotate(0deg)'); + assert.equal(direction.classList.contains('unknown'), true); + assert.equal(direction.classList.contains('material-symbols-outlined'), true); + assert.equal( + text('#cockpit-context-uncertainty'), + '1 INPUT UNKNOWN · NOT AN ALL-CLEAR', + ); + assert.equal(text('#cockpit-context-updated'), '12:34:56Z'); + assert.equal(layouts.length, 1); +}); + +test('a lost contact holds its readout behind the CONTACT LOST cue', async (t) => { + const { $, text, owner, layouts } = fixture(t, snapshot()); + updateContext.call(owner, INFO, 0); + await nextTick(); + const readout = () => + [ + '#cockpit-context-nearest-label', + '#cockpit-context-distance', + '#cockpit-context-bearing', + '#cockpit-context-updated', + ].map(text); + const held = readout(); + owner.snapshot = snapshot({ + subjectPresent: false, + navigation: { canPrevious: true, canNext: false }, + cohorts: [{ id: 'flights', label: 'Flights', count: 1, nearest: [] }], + }); + updateContext.call(owner, INFO, 0); + updateContext.call(owner, INFO, 0); + await nextTick(); + assert.equal($('#cockpit-context').dataset.state, 'lost'); + assert.equal( + text('#cockpit-context-uncertainty'), + 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR', + ); + // Navigation follows the lost snapshot; the readout holds. + assert.deepEqual(readout(), held); + assert.equal($('#cockpit-context-previous').disabled, false); + assert.equal($('#cockpit-context-next').disabled, true); + assert.equal($('[data-context-cohort="flights"] strong').textContent, '12'); + // Layout reruns once on the way in, not every update. + assert.equal(layouts.length, 2); +}); + +test('without a snapshot the card stands by, hidden', async (t) => { + const { $, owner, signals } = fixture(t, snapshot()); + updateContext.call(owner, INFO, 0); + owner.snapshot = null; + updateContext.call(owner, INFO, 0); + await nextTick(); + assert.equal($('#cockpit-context').hidden, true); + assert.equal(owner.hud.classList.contains('context-active'), false); + assert.equal(signals.at(-1)[2], 'CONTEXT STANDBY'); +}); + +test('the weather toggle renders its state and the action it offers', async (t) => { + const { $, text, owner } = fixture(t); + syncWeatherToggle.call(owner, true); + await nextTick(); + const weather = $('#cockpit-weather-toggle'); + assert.equal(weather.getAttribute('aria-pressed'), 'true'); + assert.equal( + weather.getAttribute('aria-label'), + 'Disable cockpit weather effects', + ); + assert.equal(weather.title, 'Disable cockpit weather effects'); + assert.equal(text('#cockpit-weather-state'), 'ON'); + assert.equal(owner.contextView.weather, true); +}); + +test('re-renders leave the card visibility and layout to the controller', async (t) => { + const { $, owner } = fixture(t, snapshot()); + updateContext.call(owner, INFO, 0); + const card = $('#cockpit-context'); + card.dataset.collapsed = 'true'; + card.dataset.layoutMode = 'compact-bottom'; + owner.snapshot = snapshot({ evaluatedAt: Date.UTC(2026, 9, 31, 12, 35) }); + updateContext.call(owner, INFO, 0); + await nextTick(); + assert.equal(card.hidden, false); + assert.equal(card.dataset.collapsed, 'true'); + assert.equal(card.dataset.layoutMode, 'compact-bottom'); +}); + +test('the card is labelled in the UI language', async (t) => { + t.after(() => setLocale('en')); + setLocale('ru'); + const { $, text, owner } = fixture(t); + const subject = translate('CONTACTS · 250 KM'); + assert.notEqual(subject, 'CONTACTS · 250 KM'); + assert.equal(text('#cockpit-context-subject'), subject); + assert.equal(text('#cockpit-weather-state'), translate('OFF')); + syncWeatherToggle.call(owner, true); + await nextTick(); + assert.equal(text('#cockpit-weather-state'), translate('ON')); + assert.equal( + $('#cockpit-weather-toggle').getAttribute('aria-label'), + translate('Disable cockpit weather effects'), + ); +}); diff --git a/src/ui/cockpitContextState.js b/src/ui/cockpitContextState.js new file mode 100644 index 000000000..dafffc11f --- /dev/null +++ b/src/ui/cockpitContextState.js @@ -0,0 +1,38 @@ +/** + * The Cockpit Contact card's state as it first renders. It needs only the + * i18n module, so the controller and the view can both read it. + */ +import { t } from '../i18n/index.js'; + +/** Cohorts the card counts, in its order. */ +const COCKPIT_CONTEXT_COHORTS = Object.freeze([ + 'flights', + 'military', + 'ais-live-vessels', + 'military-installations', +]); + +/** + * The card before any snapshot: navigation on, counts and readouts empty, + * weather off. The controller keeps it, changes it in place and publishes a + * copy; `CockpitContext` renders it. + * @returns {object} + */ +export function createContextState() { + return { + state: null, + subject: t('CONTACTS · 250 KM'), + canPrevious: true, + canNext: true, + cohorts: Object.fromEntries( + COCKPIT_CONTEXT_COHORTS.map((id) => [id, { count: '—', unknown: false }]), + ), + nearest: { label: t('NO AVAILABLE EXAMPLE'), ariaLabel: null }, + distance: { text: '—', ariaLabel: null }, + direction: { angle: null, unknown: false }, + bearing: '—', + uncertainty: t('AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR'), + updated: '--:--:--Z', + weather: false, + }; +} diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index ad6b7e09d..765d86ddf 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -19,7 +19,8 @@ import { cycleVisionMode, clearPredictiveRoute, } from './cockpitInstruments.js'; -import { updateContext } from './cockpitContext.js'; +import { publishContextView, updateContext } from './cockpitContext.js'; +import { createContextState } from './cockpitContextState.js'; import { createBriefState } from './cockpitBriefPages.js'; import { publishBriefView, @@ -60,7 +61,7 @@ export class CockpitViewController { onEntered = null, onExited = null, restoreTrackingFrame = null, - publishBrief = () => {}, + publish = {}, } = {}, ) { this.viewer = viewer; @@ -138,29 +139,10 @@ export class CockpitViewController { this.isEntryAllowed = typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true; this.context = document.getElementById('cockpit-context'); - this.contextSubject = document.getElementById('cockpit-context-subject'); - this.contextNearestLabel = document.getElementById( - 'cockpit-context-nearest-label', - ); - this.contextBearing = document.getElementById('cockpit-context-bearing'); - this.contextDistance = document.getElementById('cockpit-context-distance'); - this.contextDirection = document.getElementById( - 'cockpit-context-direction', - ); - this.contextUncertainty = document.getElementById( - 'cockpit-context-uncertainty', - ); - this.contextUpdated = document.getElementById('cockpit-context-updated'); - this.contextCohorts = new Map( - Array.from(document.querySelectorAll('[data-context-cohort]')).map( - (element) => [element.dataset.contextCohort, element], - ), - ); this.contextPrevious = document.getElementById('cockpit-context-previous'); this.contextNext = document.getElementById('cockpit-context-next'); this.contextToggle = document.getElementById('cockpit-context-toggle'); this.weatherToggle = document.getElementById('cockpit-weather-toggle'); - this.weatherState = document.getElementById('cockpit-weather-state'); this.contextCollapsed = false; this.signalStream = document.getElementById('cockpit-signal-stream'); this.signalList = document.getElementById('cockpit-signal-list'); @@ -177,8 +159,10 @@ export class CockpitViewController { this.signalSignatures = new Map(); this.briefPageIndex = 0; this.briefAutoRotateEnabled = false; - // CockpitBriefing renders what publishBrief receives. - this.publishBrief = publishBrief; + // Cockpit's views render what these receive. + this.publishBrief = publish.brief ?? (() => {}); + this.publishContext = publish.context ?? (() => {}); + this.contextView = createContextState(); this.brief = createBriefState(); this.briefTimer = null; this.lastAircraftInfo = null; @@ -232,8 +216,7 @@ export class CockpitViewController { this.setContextCollapsed(!this.contextCollapsed), ); this._listen(this.weatherToggle, 'click', () => { - const enabled = - this.weatherToggle.getAttribute('aria-pressed') !== 'true'; + const enabled = !this.contextView.weather; this.syncWeatherToggle(enabled); window.dispatchEvent( new CustomEvent('gev:cockpit-weather-toggle', { @@ -330,6 +313,9 @@ export class CockpitViewController { clearPredictiveRoute() { return clearPredictiveRoute.call(this); } + publishContextView() { + return publishContextView.call(this); + } updateContext(info, heading) { return updateContext.call(this, info, heading); } diff --git a/src/ui/cockpitController.test.mjs b/src/ui/cockpitController.test.mjs index c700bb537..216200596 100644 --- a/src/ui/cockpitController.test.mjs +++ b/src/ui/cockpitController.test.mjs @@ -209,3 +209,38 @@ test('stopped Cockpit cannot rearm briefing rotation or request a fresh region', env.restore(); } }); + +test('the weather toggle flips from its own state, however fast it is pressed', () => { + const env = environment(); + try { + const toggle = new EventTarget(); + document.getElementById = (id) => + id === 'cockpit-weather-toggle' ? toggle : null; + const requested = []; + window.addEventListener('gev:cockpit-weather-toggle', (event) => + requested.push(event.detail.enabled), + ); + const published = []; + const owner = new CockpitViewController(env.viewer, { + services: {}, + publish: { context: (view) => published.push(view.weather) }, + }); + // Two presses before any view renders still alternate. + toggle.dispatchEvent(new Event('click')); + toggle.dispatchEvent(new Event('click')); + assert.deepEqual(requested, [true, false]); + assert.deepEqual(published, [true, false]); + // The effects owner reports its state; the next press flips that. + window.dispatchEvent( + new CustomEvent('gev:cockpit-weather-state', { + detail: { enabled: true }, + }), + ); + toggle.dispatchEvent(new Event('click')); + assert.deepEqual(requested, [true, false, false]); + assert.deepEqual(published, [true, false, true, false]); + owner.dispose(); + } finally { + env.restore(); + } +}); diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index cd426a39a..266237134 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -82,7 +82,7 @@ export class CockpitCoordinator { flightsEnabled: !!this._dataManager?.isEnabled('flights'), militaryEnabled: !!this._dataManager?.isEnabled('military'), }), - publishBrief: (view) => this.publishCockpitBrief?.(view), + publish: this.publishCockpit, onEntered: () => this.enterPanels(), onExited: () => this.exitPanels(), restoreTrackingFrame: (entity) => { diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index a4f843c4c..349784b5d 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -245,17 +245,8 @@ export function updateRoute(info) { } export function syncWeatherToggle(enabled) { - if (!this.weatherToggle) return; - const active = !!enabled; - this.weatherToggle.setAttribute('aria-pressed', String(active)); - const weatherLabel = active - ? t('Disable cockpit weather effects') - : t('Enable cockpit weather effects'); - this.weatherToggle.setAttribute('aria-label', weatherLabel); - this.weatherToggle.title = weatherLabel; - if (this.weatherState) { - this.weatherState.textContent = active ? t('ON') : t('OFF'); - } + this.contextView.weather = !!enabled; + this.publishContextView(); } export function setVisionMode(mode, { revealParameters = false } = {}) { diff --git a/src/ui/stores/cockpit.js b/src/ui/stores/cockpit.js index 4bdb09c68..ec1f87506 100644 --- a/src/ui/stores/cockpit.js +++ b/src/ui/stores/cockpit.js @@ -4,7 +4,8 @@ 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 - * conditions and publishes them here; it keeps the controls' listeners. + * conditions, and the Contact card's readouts and weather toggle, and + * publishes them here; it keeps the controls' listeners. */ export const useCockpitStore = defineStore('gev-cockpit', () => { /** The presented briefing; null until the controller first presents it. */ @@ -15,5 +16,13 @@ export const useCockpitStore = defineStore('gev-cockpit', () => { brief.value = next && markRaw(next); } - return { brief, publishBrief }; + /** The presented Contact card; null until the controller first presents it. */ + const context = shallowRef(null); + + /** @param {?object} next A fresh presentation of the Contact card. */ + function publishContext(next) { + context.value = next && markRaw(next); + } + + return { brief, publishBrief, context, publishContext }; }); diff --git a/src/ui/views/CockpitView.vue b/src/ui/views/CockpitView.vue index 18dc08b8b..bc2ef6603 100644 --- a/src/ui/views/CockpitView.vue +++ b/src/ui/views/CockpitView.vue @@ -1,5 +1,6 @@