From ffe14e50f531fd333ae6d21d0b5bfc7748e8e64b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 14:08:19 -0700 Subject: [PATCH] feat(ui): render the Cockpit Contact card from a store The Cockpit controller no longer writes the Contact card's scope, navigation state, cohort counts, nearest contact, bearing, uncertainty, update time or weather toggle: it keeps them as plain state, starting from cockpitContextState.js, and publishes a copy to the gev-cockpit store, and a new CockpitContext component renders it. A lost contact still holds its last readout. The weather toggle flips from that state, not the button's attribute. The card's visibility, collapse and layout stay with the controller. The controller's publishBrief option becomes a publish object, one entry per Cockpit view. --- docs/VUE-MIGRATION.md | 17 +- scripts/package-boundaries.json | 5 + src/cockpitMarkup.test.mjs | 6 +- src/ui/applicationShell.js | 8 +- src/ui/cockpitContext.js | 160 ++++++++-------- src/ui/cockpitContext.test.mjs | 244 +++++++++++++++++++++++++ src/ui/cockpitContextState.js | 38 ++++ src/ui/cockpitController.js | 36 ++-- src/ui/cockpitController.test.mjs | 35 ++++ src/ui/cockpitCoordinator.js | 2 +- src/ui/cockpitInstruments.js | 13 +- src/ui/stores/cockpit.js | 13 +- src/ui/views/CockpitView.vue | 52 +----- src/ui/views/panels/CockpitContext.vue | 72 ++++++++ 14 files changed, 518 insertions(+), 183 deletions(-) create mode 100644 src/ui/cockpitContext.test.mjs create mode 100644 src/ui/cockpitContextState.js create mode 100644 src/ui/views/panels/CockpitContext.vue 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 @@