From a2bca0fb2282e7c60b3e1b0aa65b20bfd696e2f4 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 14:36:32 -0700 Subject: [PATCH] feat(ui): render the Cockpit instruments, route and vision selector from a store The Cockpit controller no longer writes the HUD's instruments, the flight plan card, the visor's destination arrow or the vision selector. It keeps them as plain state, starting from cockpitInstrumentState.js, and publishes copies to the gev-cockpit store: the instruments on every HUD update, the route only when it changed, the vision selector when the mode changes. CockpitView renders the callsign, aircraft meta and emergency mark, speed and altitude rims and ticks, compass tape, clock, position, HUD layer, destination arrow and vision selector; a new CockpitRoute component renders the flight plan card inside the briefing card. The odometer readouts become CockpitRollingValue components. Their throttle and digit roll move unchanged from setCockpitRollingValue into cockpitRollingValue.js: every accepted reading restamps the throttle, a change inside it is dropped, and each accepted change draws fresh glyphs so the roll animation restarts. A rim tick with no reading still hides where it stood, and a hidden arrow keeps its last angle and label. In a dev build the HUD's Vue patch costs about 0.5 ms per update (1.4 ms at 4x CPU throttle), against 0.4 ms (2.1 ms) for the DOM writes it replaces, at about 10 updates a second. --- docs/VUE-MIGRATION.md | 18 +- scripts/package-boundaries.json | 10 + src/cockpitMarkup.test.mjs | 11 +- src/i18n/locales/de.json | 2 - src/i18n/locales/es.json | 2 - src/i18n/locales/fr.json | 2 - src/i18n/locales/it.json | 2 - src/i18n/locales/ja.json | 2 - src/i18n/locales/pt.json | 2 - src/i18n/locales/ru.json | 2 - src/i18n/locales/uk.json | 2 - src/i18n/locales/zh-CN.json | 2 - src/i18n/locales/zh-Hant.json | 2 - src/splitFlap.js | 2 +- src/ui/applicationShell.js | 6 + src/ui/cockpitBriefing.test.mjs | 14 +- src/ui/cockpitController.js | 50 +-- src/ui/cockpitInstrumentState.js | 95 +++++ src/ui/cockpitInstruments.js | 299 ++++++-------- src/ui/cockpitInstruments.test.mjs | 409 ++++++++++++++++++++ src/ui/cockpitPresentation.js | 103 ----- src/ui/cockpitRollingValue.js | 116 ++++++ src/ui/cockpitRollingValue.test.mjs | 111 ++++++ src/ui/cockpitTrackingController.js | 4 +- src/ui/stores/cockpit.js | 42 +- src/ui/views/CockpitView.vue | 89 +++-- src/ui/views/panels/CockpitBriefing.vue | 23 +- src/ui/views/panels/CockpitRollingValue.vue | 38 ++ src/ui/views/panels/CockpitRoute.vue | 33 ++ 29 files changed, 1089 insertions(+), 404 deletions(-) create mode 100644 src/ui/cockpitInstrumentState.js create mode 100644 src/ui/cockpitInstruments.test.mjs create mode 100644 src/ui/cockpitRollingValue.js create mode 100644 src/ui/cockpitRollingValue.test.mjs create mode 100644 src/ui/views/panels/CockpitRollingValue.vue create mode 100644 src/ui/views/panels/CockpitRoute.vue diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index b245e9f8c..e71710001 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -130,12 +130,18 @@ They contain no application state. Cockpit (in progress: `CockpitBriefing` renders the briefing card's 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 + contact and weather toggle, `CockpitView` the instruments, destination + arrow and vision selector, and `CockpitRoute` the flight plan card, from + the `gev-cockpit` store that the Cockpit controller publishes to, + starting from `cockpitBriefPages.js`, `cockpitContextState.js` and + `cockpitInstrumentState.js`, which both read; the odometer readouts + (`CockpitRollingValue`) keep their throttle and roll in + `cockpitRollingValue.js`. The instruments publish on every HUD update + (about 10 Hz); in a dev build their Vue patch costs about 0.5 ms, close + to the DOM writes it replaced. The controls keep the controller's + listeners, and the cards' visibility, collapse and layout stay with the + controller. The signal rows 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. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e87793bbd..e2240c8df 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -29,8 +29,12 @@ "src/ui/views/panels/PhoneTabBar.vue", "src/ui/views/panels/CockpitBriefing.vue", "src/ui/views/panels/CockpitContext.vue", + "src/ui/views/panels/CockpitRoute.vue", + "src/ui/views/panels/CockpitRollingValue.vue", "src/ui/cockpitBriefPages.js", "src/ui/cockpitContextState.js", + "src/ui/cockpitInstrumentState.js", + "src/ui/cockpitRollingValue.js", "src/ui/views/panels/MissionDetail.vue", "src/ui/views/panels/MissionRoster.vue", "src/ui/views/panels/AwarenessPanel.vue", @@ -730,6 +734,8 @@ "src/ui/cockpitBriefing.js", "src/ui/cockpitBriefPages.js", "src/ui/cockpitContextState.js", + "src/ui/cockpitInstrumentState.js", + "src/ui/cockpitRollingValue.js", "src/ui/cockpitCamera.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", @@ -851,6 +857,8 @@ "src/ui/cockpitBriefing.js", "src/ui/cockpitBriefPages.js", "src/ui/cockpitContextState.js", + "src/ui/cockpitInstrumentState.js", + "src/ui/cockpitRollingValue.js", "src/ui/cockpitCamera.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", @@ -2553,6 +2561,8 @@ "src/ui/cockpitBriefing.js", "src/ui/cockpitBriefPages.js", "src/ui/cockpitContextState.js", + "src/ui/cockpitInstrumentState.js", + "src/ui/cockpitRollingValue.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 f74031049..69dd2bf1f 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -16,7 +16,9 @@ import { readAircraftInfo, _adoptTrackedEntity, } from './ui/cockpitTrackingController.js'; -import { setVisionMode, cycleVisionMode } from './ui/cockpitInstruments.js'; +import { cycleVisionMode } from './ui/cockpitInstruments.js'; +import { createVisionState } from './ui/cockpitInstrumentState.js'; +import { COCKPIT_VISION_MODES } from './cockpitVisionPolicy.js'; import { updateContext as cockpitContext } from './ui/cockpitContext.js'; import { syncSignalLayout, @@ -153,11 +155,10 @@ test('Cockpit vision cycle exposes every map preset exactly once', () => { cycleVisionMode.toString(), /const modes = COCKPIT_VISION_MODES;/, ); - assert.match( - setVisionMode.toString(), - /const labels = \{\s*optical: t\('NORMAL'\),\s*crt: t\('CRT'\),\s*nvg: t\('NVG'\),\s*thermal: t\('FLIR'\),\s*anime: t\('ANIME'\),\s*noir: t\('NOIR'\),?\s*\};/, + assert.deepEqual( + COCKPIT_VISION_MODES.map((mode) => createVisionState(mode).label), + ['NORMAL', 'CRT', 'NVG', 'FLIR', 'ANIME', 'NOIR'], ); - assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/); assert.doesNotMatch(ui, /getInheritedVisionLabel/); assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/); assert.match( diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 9bf61dd27..fa56aaf84 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -676,8 +676,6 @@ "Cockpit vision style": "Sichtstil im Cockpit", "Previous cockpit vision style": "Vorheriger Cockpit-Sichtstil", "Previous vision style": "Vorheriger Sichtstil", - "Current cockpit vision style: NORMAL. Activate for next style.": "Aktueller Cockpit-Sichtstil: NORMAL. Aktivieren für den nächsten Stil.", - "Current style: NORMAL — click for next": "Aktueller Stil: NORMAL. Klicken für den nächsten", "CURRENT": "AKTUELL", "Next cockpit vision style": "Nächster Cockpit-Sichtstil", "Next vision style": "Nächster Sichtstil", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index f579e2af4..ebabfe1b2 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -129,8 +129,6 @@ "PREV": "ANT.", "Previous cockpit vision style": "Estilo de visión de cabina anterior", "Previous vision style": "Estilo de visión anterior", - "Current cockpit vision style: NORMAL. Activate for next style.": "Estilo de visión de cabina actual: NORMAL. Actívalo para pasar al siguiente estilo.", - "Current style: NORMAL — click for next": "Estilo actual: NORMAL — haz clic para el siguiente", "NEXT": "SIG.", "Next cockpit vision style": "Siguiente estilo de visión de cabina", "Next vision style": "Siguiente estilo de visión", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 0d63ef1da..e768b6a89 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2705,8 +2705,6 @@ "Cockpit vision style": "Style de vision du cockpit", "Previous cockpit vision style": "Style de vision précédent du cockpit", "Previous vision style": "Style de vision précédent", - "Current cockpit vision style: NORMAL. Activate for next style.": "Style de vision actuel du cockpit : NORMAL. Activer pour le style suivant.", - "Current style: NORMAL — click for next": "Style actuel : NORMAL — cliquer pour le suivant", "CURRENT": "ACTUEL", "Next cockpit vision style": "Style de vision suivant du cockpit", "Next vision style": "Style de vision suivant", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 864f1f56a..f1bf08e73 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -2705,8 +2705,6 @@ "Cockpit vision style": "Stile di visione del cockpit", "Previous cockpit vision style": "Stile di visione precedente del cockpit", "Previous vision style": "Stile di visione precedente", - "Current cockpit vision style: NORMAL. Activate for next style.": "Stile di visione attuale del cockpit: NORMALE. Attiva per lo stile successivo.", - "Current style: NORMAL — click for next": "Stile attuale: NORMALE. Clic per il successivo", "CURRENT": "ATTUALE", "Next cockpit vision style": "Stile di visione successivo del cockpit", "Next vision style": "Stile di visione successivo", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5eea0a9fe..130308457 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2563,8 +2563,6 @@ "Cockpit vision style": "コックピットのビジョンスタイル", "Previous cockpit vision style": "前のコックピットビジョンスタイル", "Previous vision style": "前のビジョンスタイル", - "Current cockpit vision style: NORMAL. Activate for next style.": "現在のコックピットビジョンスタイル:標準。押すと次のスタイルに切り替わります。", - "Current style: NORMAL — click for next": "現在のスタイル:標準。クリックで次へ", "CURRENT": "現在", "Next cockpit vision style": "次のコックピットビジョンスタイル", "Next vision style": "次のビジョンスタイル", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 69aab858a..6ba74601f 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -2707,8 +2707,6 @@ "Cockpit vision style": "Estilo de visão da cabine", "Previous cockpit vision style": "Estilo anterior de visão da cabine", "Previous vision style": "Estilo de visão anterior", - "Current cockpit vision style: NORMAL. Activate for next style.": "Estilo atual de visão da cabine: NORMAL. Ative para o próximo estilo.", - "Current style: NORMAL — click for next": "Estilo atual: NORMAL — clique para o próximo", "CURRENT": "ATUAL", "Next cockpit vision style": "Próximo estilo de visão da cabine", "Next vision style": "Próximo estilo de visão", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e7ef05f5b..7cc27b20c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -626,8 +626,6 @@ "Cockpit display and Radio controls": "Управление экраном и радио кабины", "Previous cockpit vision style": "Предыдущий стиль обзора кабины", "Previous vision style": "Предыдущий стиль обзора", - "Current cockpit vision style: NORMAL. Activate for next style.": "Текущий стиль обзора кабины: ОБЫЧНЫЙ. Активируйте для следующего стиля.", - "Current style: NORMAL — click for next": "Текущий стиль: ОБЫЧНЫЙ — нажмите для следующего", "Next cockpit vision style": "Следующий стиль обзора кабины", "Next vision style": "Следующий стиль обзора", "Previous briefing page": "Предыдущая страница брифинга", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index f9067a2b8..238c94fef 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -2775,8 +2775,6 @@ "Cockpit vision style": "Візуальний стиль у кабіні", "Previous cockpit vision style": "Попередній візуальний стиль у кабіні", "Previous vision style": "Попередній візуальний стиль", - "Current cockpit vision style: NORMAL. Activate for next style.": "Поточний візуальний стиль у кабіні: ЗВИЧАЙНИЙ. Активуйте, щоб перейти до наступного.", - "Current style: NORMAL — click for next": "Поточний стиль: ЗВИЧАЙНИЙ — клацніть для наступного", "CURRENT": "ПОТОЧНИЙ", "Next cockpit vision style": "Наступний візуальний стиль у кабіні", "Next vision style": "Наступний візуальний стиль", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6dab5ce52..d6b46cda6 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2459,8 +2459,6 @@ "Cockpit vision style": "座舱视觉样式", "Previous cockpit vision style": "上一个座舱视觉样式", "Previous vision style": "上一个视觉样式", - "Current cockpit vision style: NORMAL. Activate for next style.": "当前座舱视觉样式:常规。点击可切换到下一个样式。", - "Current style: NORMAL — click for next": "当前样式:常规——点击切换到下一个", "CURRENT": "当前", "Next cockpit vision style": "下一个座舱视觉样式", "Next vision style": "下一个视觉样式", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index cc57752c7..caa1f5948 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2447,8 +2447,6 @@ "Cockpit vision style": "座艙視覺樣式", "Previous cockpit vision style": "上一個座艙視覺樣式", "Previous vision style": "上一個視覺樣式", - "Current cockpit vision style: NORMAL. Activate for next style.": "當前座艙視覺樣式:常規。點擊可切換到下一個樣式。", - "Current style: NORMAL — click for next": "當前樣式:常規——點擊切換到下一個", "CURRENT": "當前", "Next cockpit vision style": "下一個座艙視覺樣式", "Next vision style": "下一個視覺樣式", diff --git a/src/splitFlap.js b/src/splitFlap.js index 9b78feeba..5b365471f 100644 --- a/src/splitFlap.js +++ b/src/splitFlap.js @@ -48,7 +48,7 @@ * smooth width ease. * * The motion vocabulary deliberately matches the cockpit odometer roll - * (`setCockpitRollingValue`, style.css `.cockpit-roll-token`): ~200 ms per + * (`cockpitRollingValue.js`, style.css `.cockpit-roll-token`): ~200 ms per * character on `cubic-bezier(0.2, 0.75, 0.25, 1)`, reduced-motion collapsing * the duration rather than removing the animation. What this adds over the * odometer is the CASCADE — the left-to-right sweep that makes a departure diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index ffe881b4f..ed32ac495 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -380,6 +380,12 @@ export class StyleManager extends ShellFacade { useCockpitStore(applicationPinia()).publishBrief(view), context: (view) => useCockpitStore(applicationPinia()).publishContext(view), + instruments: (view) => + useCockpitStore(applicationPinia()).publishInstruments(view), + route: (view) => + useCockpitStore(applicationPinia()).publishRoute(view), + vision: (view) => + useCockpitStore(applicationPinia()).publishVision(view), }, _layoutRightPanels: (...args) => this._layoutRightPanels(...args), _setCockpitVision: (...args) => this._setCockpitVision(...args), diff --git a/src/ui/cockpitBriefing.test.mjs b/src/ui/cockpitBriefing.test.mjs index a31d77a22..9bb2b76ba 100644 --- a/src/ui/cockpitBriefing.test.mjs +++ b/src/ui/cockpitBriefing.test.mjs @@ -240,28 +240,28 @@ test('an unchanged position publishes nothing new', (t) => { assert.equal(views.length, 1); }); -test('re-renders leave the signal rows and route card the controller owns', async (t) => { +test('re-renders leave the signal rows the controller owns', async (t) => { const { $, text, owner } = fixture(t); const list = $('#cockpit-signal-list'); - const route = $('#cockpit-route'); const row = document.createElement('li'); row.textContent = 'N123AB'; list.append(row); - $('#cockpit-route-from').textContent = 'AUS'; - $('#cockpit-route').hidden = false; $('#cockpit-signal-stream').hidden = false; showBriefPage.call(owner, 2); renderRegionalBriefStatus.call(owner, 'loading', INFO); await nextTick(); assert.equal($('#cockpit-signal-list') === list, true); - assert.equal($('#cockpit-route') === route, true); assert.equal(list.firstElementChild === row, true); assert.equal(text('#cockpit-signal-list'), 'N123AB'); - assert.equal(text('#cockpit-route-from'), 'AUS'); - assert.equal($('#cockpit-route').hidden, false); assert.equal($('#cockpit-signal-stream').hidden, false); }); +test('the route card sits in the briefing card, hidden until a route', (t) => { + const { $ } = fixture(t); + const route = $('#cockpit-signal-stream > #cockpit-route'); + assert.equal(route?.hidden, true); +}); + test('the card is labelled in the UI language', async (t) => { t.after(() => setLocale('en')); setLocale('ru'); diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index 765d86ddf..b9f78a18e 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -12,6 +12,8 @@ import { } from './cockpitTrackingController.js'; import { update } from './cockpitCamera.js'; import { + publishInstrumentsView, + publishRouteView, updateHud, updateRoute, syncWeatherToggle, @@ -22,6 +24,10 @@ import { import { publishContextView, updateContext } from './cockpitContext.js'; import { createContextState } from './cockpitContextState.js'; import { createBriefState } from './cockpitBriefPages.js'; +import { + createInstrumentState, + createRouteState, +} from './cockpitInstrumentState.js'; import { publishBriefView, showBriefPage, @@ -81,7 +87,6 @@ export class CockpitViewController { this.surfaceWaitStartedMs = 0; this.surfaceAcquiring = false; this.surfaceFallback = false; - this.lastCompassSignature = ''; this.entry = document.getElementById('cockpit-entry'); this.tr3bToggle = document.getElementById('tr3b-toggle'); this._tr3bSignature = null; @@ -89,39 +94,8 @@ export class CockpitViewController { this.resetGlobeButton = document.getElementById('cockpit-reset-globe'); this.hud = document.getElementById('cockpit-hud'); this.entryFocusOrigin = null; - this.callsign = document.getElementById('cockpit-callsign'); - this.speed = document.getElementById('cockpit-speed-value'); - this.speedRim = document.getElementById('cockpit-speed-rim'); - this.speedRimValue = document.getElementById('cockpit-speed-rim-value'); - this.speedRimTicks = Array.from( - document.querySelectorAll('[data-speed-rim-tick]'), - ); - this.altitude = document.getElementById('cockpit-altitude-value'); - this.altitudeRim = document.getElementById('cockpit-altitude-rim'); - this.altitudeRimValue = document.getElementById( - 'cockpit-altitude-rim-value', - ); - this.altitudeRimTicks = Array.from( - document.querySelectorAll('[data-altitude-rim-tick]'), - ); - this.headingValue = document.getElementById('cockpit-heading-value'); - this.compassTape = document.getElementById('cockpit-compass-tape'); - this.clock = document.getElementById('cockpit-clock'); - this.position = document.getElementById('cockpit-position'); - this.aircraftMeta = document.getElementById('cockpit-aircraft-meta'); - this.route = document.getElementById('cockpit-route'); - this.routeFrom = document.getElementById('cockpit-route-from'); - this.routeTo = document.getElementById('cockpit-route-to'); - this.routeStatus = document.getElementById('cockpit-route-status'); - this.routeDirection = document.getElementById('cockpit-route-direction'); - this.routeDirectionLabel = document.getElementById( - 'cockpit-route-direction-label', - ); this.visionPrevious = document.getElementById('cockpit-vision-previous'); this.visionCurrent = document.getElementById('cockpit-vision-current'); - this.visionCurrentLabel = document.getElementById( - 'cockpit-vision-current-label', - ); this.visionNext = document.getElementById('cockpit-vision-next'); this.visionMode = 'optical'; this.onVisionChange = onVisionChange; @@ -162,6 +136,12 @@ export class CockpitViewController { // Cockpit's views render what these receive. this.publishBrief = publish.brief ?? (() => {}); this.publishContext = publish.context ?? (() => {}); + this.publishInstruments = publish.instruments ?? (() => {}); + this.publishRoute = publish.route ?? (() => {}); + this.publishVision = publish.vision ?? (() => {}); + this.instruments = createInstrumentState(); + this.routeView = createRouteState(); + this.routeSignature = null; this.contextView = createContextState(); this.brief = createBriefState(); this.briefTimer = null; @@ -295,6 +275,12 @@ export class CockpitViewController { update() { return update.call(this); } + publishInstrumentsView() { + return publishInstrumentsView.call(this); + } + publishRouteView() { + return publishRouteView.call(this); + } updateHud(info, nowMs, forceContext) { return updateHud.call(this, info, nowMs, forceContext); } diff --git a/src/ui/cockpitInstrumentState.js b/src/ui/cockpitInstrumentState.js new file mode 100644 index 000000000..90b553ea5 --- /dev/null +++ b/src/ui/cockpitInstrumentState.js @@ -0,0 +1,95 @@ +/** + * The Cockpit instruments, route and vision selector as they first render. + * It needs only the i18n module, so the controller and the views can both + * read it. + */ +import { msg, t } from '../i18n/index.js'; + +/** Ticks on each altitude and speed rim. */ +const COCKPIT_RIM_TICKS = 9; + +/** The vision selector's short label for each mode. */ +const COCKPIT_VISION_LABELS = Object.freeze({ + optical: msg('NORMAL'), + crt: msg('CRT'), + nvg: msg('NVG'), + thermal: msg('FLIR'), + anime: msg('ANIME'), + noir: msg('NOIR'), +}); + +/** The name each mode is announced and titled with. */ +const COCKPIT_VISION_NAMES = Object.freeze({ + optical: msg('Normal'), + crt: msg('CRT'), + nvg: msg('Night vision'), + thermal: msg('Thermal'), + anime: msg('Anime'), + noir: msg('Noir'), +}); + +/** A rim before any reading: its ticks shown but unplaced and unlabelled. */ +function createRimState(value) { + return { + unavailable: false, + value, + ticks: Array.from({ length: COCKPIT_RIM_TICKS }, () => ({ + hidden: false, + slot: null, + depth: null, + curve: null, + major: false, + label: '', + })), + }; +} + +/** + * The instruments before any aircraft reading: readouts show their + * placeholders. The controller keeps it, changes it in place on every HUD + * update and publishes a copy; `CockpitView` renders it. + * @returns {object} + */ +export function createInstrumentState() { + return { + layer: null, + callsign: t('AIRCRAFT'), + meta: { text: t('LIVE TRACK · COURSE ALIGNED'), emergency: false }, + speed: null, + altitude: null, + heading: null, + speedRim: createRimState('---'), + altitudeRim: createRimState('-----'), + compass: [], + clock: '--:--:--Z', + position: '--° · --°', + }; +} + +/** + * The flight plan card and the visor's destination arrow before any route: + * both hidden. + * @returns {object} + */ +export function createRouteState() { + return { + visible: false, + from: t('UNKNOWN'), + to: t('UNKNOWN'), + status: t('ROUTE DATA UNAVAILABLE'), + direction: { visible: false, angle: null, label: t('DEST ---°') }, + }; +} + +/** + * The vision selector showing `mode`. + * @param {string} mode A normalized Cockpit vision mode. + * @returns {{mode: string, label: string, name: string}} + */ +export function createVisionState(mode = 'optical') { + return { + mode, + label: t(COCKPIT_VISION_LABELS[mode]), + name: t(COCKPIT_VISION_NAMES[mode]), + }; +} diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index 349784b5d..484125db1 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -24,8 +24,8 @@ import { COCKPIT_CONTEXT_UPDATE_MS, compassPointLabel, formatCockpitCoordinate, - setCockpitRollingValue, } from './cockpitPresentation.js'; +import { createVisionState } from './cockpitInstrumentState.js'; /** A transponder status name (src/data/aircraftEmergency.js) for the HUD. */ const emergencyLabel = ({ name }) => t(name).toUpperCase(); @@ -43,138 +43,122 @@ const LOCALIZED_EMERGENCY = Object.freeze({ : t('ADS-B EMERGENCY · {status}', { status: emergencyLabel(parts) }), }); +/** Place a rim's ticks; a tick with no reading hides and keeps its place. */ +function placeRimTicks(ticks, readings, label) { + ticks.forEach((tick, index) => { + const reading = readings[index]; + tick.hidden = !reading; + if (!reading) return; + tick.slot = reading.slot.toFixed(4); + tick.depth = reading.depth.toFixed(4); + tick.curve = altitudeRulerCurveInset(reading.slot).toFixed(5); + tick.major = reading.major; + tick.label = label(reading); + }); +} + +export function publishInstrumentsView() { + const view = this.instruments; + const rim = (state) => ({ + ...state, + ticks: state.ticks.map((tick) => ({ ...tick })), + }); + this.publishInstruments({ + ...view, + meta: { ...view.meta }, + speed: view.speed && { ...view.speed }, + altitude: view.altitude && { ...view.altitude }, + heading: view.heading && { ...view.heading }, + speedRim: rim(view.speedRim), + altitudeRim: rim(view.altitudeRim), + compass: view.compass.map((division) => ({ ...division })), + }); +} + export function updateHud( info, nowMs = performance.now(), forceContext = false, ) { this.lastAircraftInfo = info; + const view = this.instruments; const heading = normalizeHeading(this.heading ?? info.track ?? 0); - if (this.callsign) { - this.callsign.textContent = - info.callsign || info.registration || info.icao24 || t('AIRCRAFT'); - } + view.callsign = + info.callsign || info.registration || info.icao24 || t('AIRCRAFT'); const speedKt = Number.isFinite(info.velocityMps) ? info.velocityMps * 1.94384 : null; - setCockpitRollingValue(this.speed, formatSpeedRulerTick(speedKt), speedKt, { + view.speed = { + text: formatSpeedRulerTick(speedKt), + value: speedKt, immediate: forceContext, - }); - if (this.speedRim) - this.speedRim.classList.toggle('unavailable', speedKt === null); - if (this.speedRimValue) - this.speedRimValue.textContent = formatSpeedRulerTick(speedKt); - const speedTicks = speedRulerTicks(speedKt, this.speedRimTicks.length); - this.speedRimTicks.forEach((element, index) => { - const tick = speedTicks[index]; - element.hidden = !tick; - if (!tick) return; - element.style.setProperty('--slot', tick.slot.toFixed(4)); - element.style.setProperty('--depth', tick.depth.toFixed(4)); - element.style.setProperty( - '--curve', - altitudeRulerCurveInset(tick.slot).toFixed(5), - ); - element.classList.toggle('major', tick.major); - const label = element.querySelector('b'); - if (label) label.textContent = formatSpeedRulerTick(tick.valueKt); - }); + }; + view.speedRim.unavailable = speedKt === null; + view.speedRim.value = formatSpeedRulerTick(speedKt); + placeRimTicks( + view.speedRim.ticks, + speedRulerTicks(speedKt, view.speedRim.ticks.length), + (tick) => formatSpeedRulerTick(tick.valueKt), + ); const altitudeFt = cockpitAltitudeDisplayFt(info.altitudeM, info.onGround); - if (this.altitude) { - const displayedAltitudeFt = Number.isFinite(altitudeFt) - ? Math.round(altitudeFt) - : null; - setCockpitRollingValue( - this.altitude, + const displayedAltitudeFt = Number.isFinite(altitudeFt) + ? Math.round(altitudeFt) + : null; + view.altitude = { + text: displayedAltitudeFt !== null ? formatNumber(displayedAltitudeFt) : '-----', - displayedAltitudeFt, - { immediate: forceContext }, - ); - } - if (this.altitudeRim) - this.altitudeRim.classList.toggle('unavailable', altitudeFt === null); - if (this.altitudeRimValue) { - this.altitudeRimValue.textContent = formatAltitudeRulerTick(altitudeFt); - } - const altitudeTicks = altitudeRulerTicks( - altitudeFt, - this.altitudeRimTicks.length, + value: displayedAltitudeFt, + immediate: forceContext, + }; + view.altitudeRim.unavailable = altitudeFt === null; + view.altitudeRim.value = formatAltitudeRulerTick(altitudeFt); + placeRimTicks( + view.altitudeRim.ticks, + altitudeRulerTicks(altitudeFt, view.altitudeRim.ticks.length), + (tick) => formatAltitudeRulerTick(tick.valueFt), ); - this.altitudeRimTicks.forEach((element, index) => { - const tick = altitudeTicks[index]; - element.hidden = !tick; - if (!tick) return; - element.style.setProperty('--slot', tick.slot.toFixed(4)); - element.style.setProperty('--depth', tick.depth.toFixed(4)); - element.style.setProperty( - '--curve', - altitudeRulerCurveInset(tick.slot).toFixed(5), - ); - element.classList.toggle('major', tick.major); - const label = element.querySelector('b'); - if (label) label.textContent = formatAltitudeRulerTick(tick.valueFt); - }); - setCockpitRollingValue( - this.headingValue, - String(Math.round(heading) % 360).padStart(3, '0'), - heading, - { circularRange: 360, immediate: forceContext }, - ); - if (this.compassTape) { - const divisions = compassDivisions(heading); - const signature = divisions.join(','); - if (signature !== this.lastCompassSignature) { - this.lastCompassSignature = signature; - this.compassTape.innerHTML = divisions - .map((division, index) => { - const slot = index - 3; - return `${compassPointLabel(formatCompassDivision(division))}`; - }) - .join(''); - } - } - if (this.clock) - this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z'; - if (this.position) { - const lat = Number.isFinite(info.latitude) - ? formatCockpitCoordinate(info.latitude, 'N', 'S') - : '--'; - const lon = Number.isFinite(info.longitude) - ? formatCockpitCoordinate(info.longitude, 'E', 'W') - : '--'; - this.position.textContent = `${lat} · ${lon}`; - } - if (this.aircraftMeta) { - const feedState = this.surfaceAcquiring - ? t('ACQUIRING SURFACE') - : this.surfaceFallback - ? t('SURFACE FALLBACK') - : info.stale - ? t('STALE FEED') - : t('LIVE TRACK'); - const aircraftClass = - info.layerId === 'military' ? t('MILITARY') : t('COMMERCIAL'); - const emergency = formatCockpitEmergency( - info.emergency, - LOCALIZED_EMERGENCY, - ); - this.aircraftMeta.textContent = emergency - ? t('{aircraftClass} · {emergency} · {feedState}', { - aircraftClass, - emergency, - feedState, - }) - : t('{aircraftClass} · {feedState} · COURSE ALIGNED', { - aircraftClass, - feedState, - }); - this.aircraftMeta.classList.toggle( - 'emergency', - info.emergency?.severity === 'emergency', - ); - } + view.heading = { + text: String(Math.round(heading) % 360).padStart(3, '0'), + value: heading, + immediate: forceContext, + }; + view.compass = compassDivisions(heading).map((division, index) => ({ + slot: index - 3, + label: compassPointLabel(formatCompassDivision(division)), + })); + view.clock = new Date().toISOString().slice(11, 19) + 'Z'; + const lat = Number.isFinite(info.latitude) + ? formatCockpitCoordinate(info.latitude, 'N', 'S') + : '--'; + const lon = Number.isFinite(info.longitude) + ? formatCockpitCoordinate(info.longitude, 'E', 'W') + : '--'; + view.position = `${lat} · ${lon}`; + const feedState = this.surfaceAcquiring + ? t('ACQUIRING SURFACE') + : this.surfaceFallback + ? t('SURFACE FALLBACK') + : info.stale + ? t('STALE FEED') + : t('LIVE TRACK'); + const aircraftClass = + info.layerId === 'military' ? t('MILITARY') : t('COMMERCIAL'); + const emergency = formatCockpitEmergency(info.emergency, LOCALIZED_EMERGENCY); + view.meta.text = emergency + ? t('{aircraftClass} · {emergency} · {feedState}', { + aircraftClass, + emergency, + feedState, + }) + : t('{aircraftClass} · {feedState} · COURSE ALIGNED', { + aircraftClass, + feedState, + }); + view.meta.emergency = info.emergency?.severity === 'emergency'; + view.layer = info.layerId || 'flights'; + this.publishInstrumentsView(); this.updateRoute(info); if ( forceContext || @@ -189,24 +173,33 @@ export function updateHud( this.maybeRefreshRegionalBrief(info); this.updateContext(info, heading); } - if (this.hud) this.hud.dataset.layer = info.layerId || 'flights'; +} + +/** Publish the route card and destination arrow when either changed. */ +export function publishRouteView() { + const signature = JSON.stringify(this.routeView); + if (signature === this.routeSignature) return; + this.routeSignature = signature; + this.publishRoute({ + ...this.routeView, + direction: { ...this.routeView.direction }, + }); } export function updateRoute(info) { + const route = this.routeView; const origin = info?.route?.origin; const destination = info?.route?.destination; const validDestination = Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon); const routeLabel = (airport) => [airport?.code, airport?.name].filter(Boolean).join(' · ') || t('UNKNOWN'); - if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin); - if (this.routeTo) this.routeTo.textContent = routeLabel(destination); - if (this.routeStatus) { - this.routeStatus.textContent = validDestination - ? t('ARROW · ESTIMATED DIRECTION') - : t('ROUTE DATA UNAVAILABLE'); - } - if (this.route) this.route.hidden = !origin && !destination; + route.from = routeLabel(origin); + route.to = routeLabel(destination); + route.status = validDestination + ? t('ARROW · ESTIMATED DIRECTION') + : t('ROUTE DATA UNAVAILABLE'); + route.visible = !!(origin || destination); if ( !validDestination || !Number.isFinite(info?.longitude) || @@ -229,19 +222,13 @@ export function updateRoute(info) { this.clearPredictiveRoute(); return; } - if (this.routeDirection) { - const displayedRelative = Math.max(-120, Math.min(120, relative)); - this.routeDirection.hidden = false; - this.routeDirection.style.setProperty( - '--route-angle', - `${displayedRelative.toFixed(2)}deg`, - ); - } - if (this.routeDirectionLabel) { - this.routeDirectionLabel.textContent = t('DEST {bearing}', { - bearing: `${formatHeadingReadout(destinationBearing)}°`, - }); - } + const displayedRelative = Math.max(-120, Math.min(120, relative)); + route.direction.visible = true; + route.direction.angle = `${displayedRelative.toFixed(2)}deg`; + route.direction.label = t('DEST {bearing}', { + bearing: `${formatHeadingReadout(destinationBearing)}°`, + }); + this.publishRouteView(); } export function syncWeatherToggle(enabled) { @@ -252,36 +239,7 @@ export function syncWeatherToggle(enabled) { export function setVisionMode(mode, { revealParameters = false } = {}) { const next = normalizeCockpitVisionMode(mode); this.visionMode = next; - const labels = { - optical: t('NORMAL'), - crt: t('CRT'), - nvg: t('NVG'), - thermal: t('FLIR'), - anime: t('ANIME'), - noir: t('NOIR'), - }; - const names = { - optical: t('Normal'), - crt: t('CRT'), - nvg: t('Night vision'), - thermal: t('Thermal'), - anime: t('Anime'), - noir: t('Noir'), - }; - if (this.visionCurrent) { - this.visionCurrent.dataset.cockpitVision = next; - this.visionCurrent.setAttribute( - 'aria-label', - t('Current cockpit vision style: {style}. Activate for next style.', { - style: names[next], - }), - ); - this.visionCurrent.title = t('Current style: {style} — click for next', { - style: names[next], - }); - } - if (this.visionCurrentLabel) - this.visionCurrentLabel.textContent = labels[next]; + this.publishVision(createVisionState(next)); this.onVisionChange?.(next, this.active, { revealParameters }); } @@ -294,5 +252,6 @@ export function cycleVisionMode(direction = 1) { } export function clearPredictiveRoute() { - if (this.routeDirection) this.routeDirection.hidden = true; + this.routeView.direction.visible = false; + this.publishRouteView(); } diff --git a/src/ui/cockpitInstruments.test.mjs b/src/ui/cockpitInstruments.test.mjs new file mode 100644 index 000000000..edc4d9008 --- /dev/null +++ b/src/ui/cockpitInstruments.test.mjs @@ -0,0 +1,409 @@ +// src/ui/cockpitInstruments.test.mjs — the Cockpit HUD's instruments, flight +// plan and vision selector render 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 } from '../i18n/index.js'; +import { + createInstrumentState, + createRouteState, +} from './cockpitInstrumentState.js'; +import { + clearPredictiveRoute, + publishInstrumentsView, + publishRouteView, + setVisionMode, + updateHud, + updateRoute, +} from './cockpitInstruments.js'; +import { useCockpitStore } from './stores/cockpit.js'; +import CockpitView from './views/CockpitView.vue'; + +function fixture(t) { + document.body.replaceChildren(); + const host = document.body.appendChild(document.createElement('div')); + const pinia = createPinia(); + const app = createApp(CockpitView).use(pinia); + app.mount(host); + t.after(() => app.unmount()); + const store = useCockpitStore(pinia); + const published = { instruments: 0, route: 0 }; + // The instrument presenters need only this much of the controller. + const owner = { + heading: null, + surfaceAcquiring: false, + surfaceFallback: false, + lastContextUpdateMs: 0, + instruments: createInstrumentState(), + routeView: createRouteState(), + routeSignature: null, + publishInstruments: (view) => { + published.instruments += 1; + store.publishInstruments(view); + }, + publishRoute: (view) => { + published.route += 1; + store.publishRoute(view); + }, + publishVision: (view) => store.publishVision(view), + publishInstrumentsView, + publishRouteView, + updateRoute, + clearPredictiveRoute, + // The briefing and Contact card have their own tests. + updateLocalPosition() {}, + maybeRefreshRegionalBrief() {}, + updateContext() {}, + }; + const $ = (selector) => document.querySelector(selector); + const text = (selector) => $(selector).textContent.trim(); + const ticks = (kind) => [ + ...document.querySelectorAll(`[data-${kind}-rim-tick]`), + ]; + return { $, text, ticks, owner, published }; +} + +const INFO = Object.freeze({ + callsign: 'UAL123', + velocityMps: 128.6, + altitudeM: 3000, + track: 92, + latitude: 30.1975, + longitude: -97.6664, + layerId: 'flights', +}); + +const ROUTED = Object.freeze({ + ...INFO, + route: { + origin: { code: 'AUS', name: 'Austin' }, + destination: { code: 'DEN', name: 'Denver', lat: 39.86, lon: -104.67 }, + }, +}); + +test('before the controller presents, the HUD shows the state it starts in', (t) => { + const { $, text, ticks } = fixture(t); + assert.equal(text('#cockpit-callsign'), 'AIRCRAFT'); + assert.equal(text('#cockpit-aircraft-meta'), 'LIVE TRACK · COURSE ALIGNED'); + assert.equal(text('#cockpit-speed-value'), '---'); + assert.equal(text('#cockpit-altitude-value'), '-----'); + assert.equal(text('#cockpit-heading-value'), '---'); + assert.equal($('#cockpit-speed-value').hasAttribute('aria-label'), false); + assert.equal(text('#cockpit-speed-rim-value'), '---'); + assert.equal(text('#cockpit-altitude-rim-value'), '-----'); + assert.equal(text('#cockpit-clock'), '--:--:--Z'); + assert.equal(text('#cockpit-position'), '--° · --°'); + assert.equal($('#cockpit-compass-tape').children.length, 0); + assert.equal(ticks('speed').length, 9); + assert.equal(ticks('altitude').length, 9); + assert.equal( + ticks('speed').every((tick) => !tick.hidden && !tick.getAttribute('style')), + true, + ); + assert.equal($('#cockpit-route').hidden, true); + assert.equal($('#cockpit-route-direction').hidden, true); + assert.equal(text('#cockpit-route-direction-label'), 'DEST ---°'); + assert.equal($('#cockpit-hud').hasAttribute('data-layer'), false); + const vision = $('#cockpit-vision-current'); + assert.equal(vision.dataset.cockpitVision, 'optical'); + assert.equal(text('#cockpit-vision-current-label'), 'NORMAL'); + assert.equal( + vision.getAttribute('aria-label'), + 'Current cockpit vision style: Normal. Activate for next style.', + ); +}); + +test('a HUD update renders every readout', async (t) => { + const { $, text, ticks, owner } = fixture(t); + updateHud.call(owner, INFO, 1000, true); + await nextTick(); + assert.equal(text('#cockpit-callsign'), 'UAL123'); + assert.equal( + text('#cockpit-aircraft-meta'), + 'COMMERCIAL · LIVE TRACK · COURSE ALIGNED', + ); + // 128.6 m/s is 250 kt; 3000 m is 9,843 ft; the track is the heading. + const speed = $('#cockpit-speed-value'); + assert.equal(speed.tagName, 'STRONG'); + assert.equal(speed.getAttribute('aria-label'), '250'); + assert.equal(speed.textContent, '250'); + assert.equal( + [...speed.children].every( + (glyph) => + glyph.className === 'cockpit-roll-token' && + glyph.getAttribute('aria-hidden') === 'true', + ), + true, + ); + assert.equal(text('#cockpit-altitude-value'), '9,843'); + assert.equal( + $('#cockpit-altitude-value').children[1].className, + 'cockpit-roll-token is-separator', + ); + assert.equal(text('#cockpit-heading-value'), '092'); + assert.equal(text('#cockpit-speed-rim-value'), '250'); + const placed = ticks('speed').filter((tick) => !tick.hidden); + assert.equal(placed.length, 9); + assert.match(placed[0].style.getPropertyValue('--slot'), /^-?\d+\.\d{4}$/); + assert.match(placed[0].style.getPropertyValue('--curve'), /^-?\d+\.\d{5}$/); + assert.equal( + placed.some((tick) => tick.classList.contains('major')), + true, + ); + assert.equal( + placed.every((tick) => tick.querySelector('b').textContent !== ''), + true, + ); + const compass = [...$('#cockpit-compass-tape').children]; + assert.deepEqual( + compass.map((division) => division.textContent), + ['N', '030', '060', 'E', '120', '150', 'S'], + ); + assert.equal(compass[3].className, 'active'); + assert.equal(compass[0].style.getPropertyValue('--slot'), '-3'); + assert.equal(compass[0].style.getPropertyValue('--depth'), '3'); + assert.match(text('#cockpit-clock'), /^\d\d:\d\d:\d\dZ$/); + assert.equal(text('#cockpit-position'), '30.198°N · 97.666°W'); + assert.equal($('#cockpit-hud').dataset.layer, 'flights'); +}); + +test('a missing speed hides the rim ticks where they stood and marks the rim', async (t) => { + const { $, ticks, owner } = fixture(t); + updateHud.call(owner, INFO, 1000, true); + await nextTick(); + const slot = ticks('speed')[0].style.getPropertyValue('--slot'); + updateHud.call(owner, { ...INFO, velocityMps: null }, 1100, true); + await nextTick(); + assert.equal($('#cockpit-speed-rim').classList.contains('unavailable'), true); + assert.equal( + ticks('speed').every((tick) => tick.hidden), + true, + ); + assert.equal(ticks('speed')[0].style.getPropertyValue('--slot'), slot); + assert.equal( + $('#cockpit-altitude-rim').classList.contains('unavailable'), + false, + ); +}); + +/** Stand in for the clock the odometer readouts throttle on. */ +function stubClock(t, startMs = 1000) { + let nowMs = startMs; + t.mock.method(performance, 'now', () => nowMs); + return (ms) => { + nowMs += ms; + }; +} + +const faster = (velocityMps) => ({ ...INFO, velocityMps }); + +test('a changed reading rolls its digits in fresh glyphs', async (t) => { + const advance = stubClock(t); + const { $, owner } = fixture(t); + updateHud.call(owner, INFO, 1000, true); + await nextTick(); + const before = $('#cockpit-speed-value').firstElementChild; + // 10 kt faster, outside the throttle. + advance(300); + updateHud.call(owner, faster(133.75), 1300); + await nextTick(); + const speed = $('#cockpit-speed-value'); + assert.equal(speed.getAttribute('aria-label'), '260'); + assert.equal(speed.firstElementChild === before, false); + const rolling = speed.querySelectorAll('.is-rolling.roll-up'); + assert.equal(rolling.length, 1); + assert.equal( + rolling[0].querySelector('.cockpit-roll-track').textContent, + '56', + ); +}); + +test('an unchanged reading keeps the glyphs, so a roll is not restarted', async (t) => { + const advance = stubClock(t); + const { $, owner } = fixture(t); + updateHud.call(owner, INFO, 1000, true); + await nextTick(); + advance(300); + updateHud.call(owner, faster(133.75), 1300); + await nextTick(); + const glyphs = [...$('#cockpit-speed-value').children]; + advance(100); + updateHud.call(owner, faster(133.75), 1400); + await nextTick(); + const after = [...$('#cockpit-speed-value').children]; + assert.equal(after.length, glyphs.length); + assert.equal( + after.every((glyph, index) => glyph === glyphs[index]), + true, + ); + assert.equal(after[1].classList.contains('is-rolling'), true); +}); + +test('a change inside the throttle is dropped, and every update restamps it', async (t) => { + const advance = stubClock(t); + const { $, owner } = fixture(t); + const speed = () => $('#cockpit-speed-value').getAttribute('aria-label'); + updateHud.call(owner, INFO, 1000, true); + await nextTick(); + // The same reading 150 ms later restamps the readout... + advance(150); + updateHud.call(owner, INFO, 1150); + await nextTick(); + // ...so a change 200 ms after the first reading is still too soon. + advance(50); + updateHud.call(owner, faster(133.75), 1200); + await nextTick(); + assert.equal(speed(), '250'); + // 220 ms after the restamp it shows. + advance(170); + updateHud.call(owner, faster(133.75), 1370); + await nextTick(); + assert.equal(speed(), '260'); +}); + +test('an emergency and a military track restyle the HUD', async (t) => { + const { $, text, owner } = fixture(t); + updateHud.call( + owner, + { + ...INFO, + layerId: 'military', + emergency: { + source: 'squawk', + squawk: '7700', + label: 'General emergency', + severity: 'emergency', + }, + }, + 1000, + true, + ); + await nextTick(); + const meta = $('#cockpit-aircraft-meta'); + assert.match( + meta.textContent, + /^MILITARY · SQUAWK 7700 · GENERAL EMERGENCY · LIVE TRACK$/, + ); + assert.equal(meta.classList.contains('emergency'), true); + assert.equal(meta.classList.contains('cockpit-aircraft-meta'), true); + assert.equal($('#cockpit-hud').dataset.layer, 'military'); + updateHud.call(owner, INFO, 1100, true); + await nextTick(); + assert.equal(meta.classList.contains('emergency'), false); + assert.equal(text('#cockpit-aircraft-meta').startsWith('COMMERCIAL'), true); +}); + +test('re-renders keep the visibility and classes the controller sets on the HUD', async (t) => { + const { $, owner } = fixture(t); + const hud = $('#cockpit-hud'); + hud.hidden = false; + hud.classList.add('signals-active'); + hud.style.setProperty('--cockpit-utility-top', '120px'); + $('#cockpit-speed-rim').classList.add('external'); + updateHud.call(owner, INFO, 1000, true); + await nextTick(); + updateHud.call(owner, { ...INFO, velocityMps: null }, 1100, true); + await nextTick(); + assert.equal(hud.hidden, false); + assert.equal(hud.classList.contains('signals-active'), true); + assert.equal(hud.style.getPropertyValue('--cockpit-utility-top'), '120px'); + assert.equal($('#cockpit-speed-rim').classList.contains('external'), true); +}); + +test('a route shows the flight plan and the destination arrow', async (t) => { + const { $, text, owner } = fixture(t); + updateHud.call(owner, ROUTED, 1000, true); + await nextTick(); + assert.equal($('#cockpit-route').hidden, false); + assert.equal(text('#cockpit-route-from'), 'AUS · Austin'); + assert.equal(text('#cockpit-route-to'), 'DEN · Denver'); + assert.equal(text('#cockpit-route-status'), 'ARROW · ESTIMATED DIRECTION'); + const arrow = $('#cockpit-route-direction'); + assert.equal(arrow.hidden, false); + assert.match( + arrow.style.getPropertyValue('--route-angle'), + /^-?\d+\.\d\ddeg$/, + ); + assert.match(text('#cockpit-route-direction-label'), /^DEST \d{3}°$/); +}); + +test('a route without a destination fix keeps the card and hides the arrow where it pointed', async (t) => { + const { $, text, owner } = fixture(t); + updateHud.call(owner, ROUTED, 1000, true); + await nextTick(); + const angle = $('#cockpit-route-direction').style.getPropertyValue( + '--route-angle', + ); + updateHud.call( + owner, + { + ...ROUTED, + route: { origin: ROUTED.route.origin, destination: { code: 'DEN' } }, + }, + 1100, + true, + ); + await nextTick(); + assert.equal($('#cockpit-route').hidden, false); + assert.equal(text('#cockpit-route-status'), 'ROUTE DATA UNAVAILABLE'); + assert.equal(text('#cockpit-route-to'), 'DEN'); + const arrow = $('#cockpit-route-direction'); + assert.equal(arrow.hidden, true); + assert.equal(arrow.style.getPropertyValue('--route-angle'), angle); + updateHud.call(owner, INFO, 1200, true); + await nextTick(); + assert.equal($('#cockpit-route').hidden, true); +}); + +test('an unchanged route publishes nothing new; the instruments publish every update', (t) => { + const { owner, published } = fixture(t); + updateHud.call(owner, ROUTED, 1000, true); + updateHud.call(owner, ROUTED, 1100, true); + assert.equal(published.route, 1); + assert.equal(published.instruments, 2); + owner.routeView.visible = false; + clearPredictiveRoute.call(owner); + assert.equal(published.route, 2); +}); + +test('the vision selector shows the mode the controller sets', async (t) => { + const { $, text, owner } = fixture(t); + const changes = []; + owner.onVisionChange = (...args) => changes.push(args); + owner.active = true; + setVisionMode.call(owner, 'thermal', { revealParameters: true }); + await nextTick(); + const vision = $('#cockpit-vision-current'); + assert.equal(owner.visionMode, 'thermal'); + assert.equal(vision.dataset.cockpitVision, 'thermal'); + assert.equal(text('#cockpit-vision-current-label'), 'FLIR'); + assert.equal( + vision.getAttribute('aria-label'), + 'Current cockpit vision style: Thermal. Activate for next style.', + ); + assert.equal(vision.title, 'Current style: Thermal — click for next'); + assert.deepEqual(changes, [['thermal', true, { revealParameters: true }]]); +}); + +test('the HUD reads in the UI language', async (t) => { + t.after(() => setLocale('en')); + setLocale('ru'); + const { $, text, owner } = fixture(t); + assert.equal(text('#cockpit-callsign'), 'САМОЛЁТ'); + updateHud.call(owner, INFO, 1000, true); + setVisionMode.call(owner, 'nvg'); + await nextTick(); + assert.equal(text('#cockpit-altitude-value'), '9\u00a0843'); + assert.equal( + $('#cockpit-altitude-value').children[1].className, + 'cockpit-roll-token is-separator', + ); + assert.match(text('#cockpit-position'), /с\. ш\./); + assert.match( + $('#cockpit-vision-current').getAttribute('aria-label'), + /^Текущий/, + ); +}); diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index 61b6961f2..1e646838c 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -139,106 +139,3 @@ export function formatCockpitWindDirection(value) { const degrees = Math.round(normalized) % 360; return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`; } - -// Digit grouping and decimal marks (`26,175`, `26 175`, `26.175`) take a -// narrow cell; a full digit cell would open a gap in the middle of a number. -const ROLL_SEPARATOR = /^[,.\u00a0\u202f]$/; - -function rollTokenClass(character) { - return ROLL_SEPARATOR.test(character) - ? 'cockpit-roll-token is-separator' - : 'cockpit-roll-token'; -} - -export function setCockpitRollingValue( - element, - text, - numericValue, - { circularRange = null, immediate = false } = {}, -) { - if (!element) return; - const nextText = String(text); - const previousText = element.dataset.rollingText; - const previousValue = Number(element.dataset.rollingValue); - const nowMs = performance.now(); - const lastRollMs = Number(element.dataset.rollingAt); - if ( - !immediate && - previousText !== undefined && - previousText !== nextText && - Number.isFinite(lastRollMs) && - nowMs - lastRollMs < 220 - ) { - return; - } - element.dataset.rollingText = nextText; - element.dataset.rollingAt = String(nowMs); - if (Number.isFinite(numericValue)) - element.dataset.rollingValue = String(numericValue); - else delete element.dataset.rollingValue; - element.setAttribute('aria-label', nextText); - - if (immediate || previousText === undefined || previousText === nextText) { - if ( - previousText !== nextText || - !element.querySelector('.cockpit-roll-token') - ) { - element.replaceChildren( - ...Array.from(nextText, (character) => { - const token = document.createElement('span'); - token.className = rollTokenClass(character); - token.setAttribute('aria-hidden', 'true'); - token.textContent = character; - return token; - }), - ); - } - return; - } - - let delta = - Number.isFinite(numericValue) && Number.isFinite(previousValue) - ? numericValue - previousValue - : 0; - if (Number.isFinite(circularRange) && circularRange > 0) { - const halfRange = circularRange / 2; - if (delta > halfRange) delta -= circularRange; - else if (delta < -halfRange) delta += circularRange; - } - const direction = delta < 0 ? 'down' : 'up'; - const width = Math.max(previousText.length, nextText.length); - const from = previousText.padStart(width, ' '); - const to = nextText.padStart(width, ' '); - const fragment = document.createDocumentFragment(); - - for (let index = 0; index < width; index += 1) { - const previousCharacter = from[index]; - const nextCharacter = to[index]; - const token = document.createElement('span'); - token.className = rollTokenClass(nextCharacter); - token.setAttribute('aria-hidden', 'true'); - - if ( - previousCharacter === nextCharacter || - !/\d/.test(previousCharacter) || - !/\d/.test(nextCharacter) - ) { - token.textContent = nextCharacter === ' ' ? '\u00a0' : nextCharacter; - fragment.append(token); - continue; - } - - token.classList.add('is-rolling', `roll-${direction}`); - const track = document.createElement('span'); - track.className = 'cockpit-roll-track'; - const first = document.createElement('span'); - const second = document.createElement('span'); - first.textContent = direction === 'up' ? previousCharacter : nextCharacter; - second.textContent = direction === 'up' ? nextCharacter : previousCharacter; - track.append(first, second); - token.append(track); - fragment.append(token); - } - - element.replaceChildren(fragment); -} diff --git a/src/ui/cockpitRollingValue.js b/src/ui/cockpitRollingValue.js new file mode 100644 index 000000000..69db6dd00 --- /dev/null +++ b/src/ui/cockpitRollingValue.js @@ -0,0 +1,116 @@ +/** + * The Cockpit odometer readouts (ground speed, altitude, heading): which + * glyphs a reading shows and which of them roll. It has no imports, so the + * view that renders the glyphs can read it. + */ + +/** The shortest gap between two rolls, in ms. */ +const ROLL_THROTTLE_MS = 220; + +// Digit grouping and decimal marks (`26,175`, `26 175`, `26.175`) take a +// narrow cell; a full digit cell would open a gap in the middle of a number. +const ROLL_SEPARATOR = /^[,.\u00a0\u202f]$/; + +function rollTokenClass(character) { + return ROLL_SEPARATOR.test(character) + ? 'cockpit-roll-token is-separator' + : 'cockpit-roll-token'; +} + +/** + * A readout before its first reading: no glyphs, so the view shows its + * placeholder. + * @returns {object} + */ +export function createRollingState() { + return { + text: undefined, + value: NaN, + at: NaN, + tokens: null, + generation: 0, + }; +} + +/** + * Take a reading. A changed reading inside the throttle is dropped; any + * other reading restamps the readout. A changed reading rolls each digit + * that differs, unless it is the first or `immediate`, which draw it flat. + * + * @param {object} state From createRollingState or an earlier call. + * @param {string} text The reading as shown. + * @param {?number} numericValue Its value, for the roll direction. + * @param {{circularRange?: ?number, immediate?: boolean, nowMs?: number}} [options] + * @returns {object} `state` itself when its glyphs stay, else a new state + * with a new `generation`, so every glyph is drawn afresh and its roll + * animation restarts. + */ +export function advanceRollingValue( + state, + text, + numericValue, + { circularRange = null, immediate = false, nowMs = performance.now() } = {}, +) { + const nextText = String(text); + const previousText = state.text; + const previousValue = state.value; + if ( + !immediate && + previousText !== undefined && + previousText !== nextText && + Number.isFinite(state.at) && + nowMs - state.at < ROLL_THROTTLE_MS + ) { + return state; + } + state.at = nowMs; + state.value = Number.isFinite(numericValue) ? numericValue : NaN; + if (previousText === nextText && state.tokens) return state; + + const next = { ...state, text: nextText, generation: state.generation + 1 }; + if (immediate || previousText === undefined || previousText === nextText) { + next.tokens = Array.from(nextText, (character) => ({ + className: rollTokenClass(character), + text: character, + track: null, + })); + return next; + } + + let delta = + Number.isFinite(numericValue) && Number.isFinite(previousValue) + ? numericValue - previousValue + : 0; + if (Number.isFinite(circularRange) && circularRange > 0) { + const halfRange = circularRange / 2; + if (delta > halfRange) delta -= circularRange; + else if (delta < -halfRange) delta += circularRange; + } + const direction = delta < 0 ? 'down' : 'up'; + const width = Math.max(previousText.length, nextText.length); + const from = previousText.padStart(width, ' '); + const to = nextText.padStart(width, ' '); + next.tokens = Array.from(to, (nextCharacter, index) => { + const previousCharacter = from[index]; + if ( + previousCharacter === nextCharacter || + !/\d/.test(previousCharacter) || + !/\d/.test(nextCharacter) + ) { + return { + className: rollTokenClass(nextCharacter), + text: nextCharacter === ' ' ? '\u00a0' : nextCharacter, + track: null, + }; + } + return { + className: `${rollTokenClass(nextCharacter)} is-rolling roll-${direction}`, + text: null, + track: + direction === 'up' + ? [previousCharacter, nextCharacter] + : [nextCharacter, previousCharacter], + }; + }); + return next; +} diff --git a/src/ui/cockpitRollingValue.test.mjs b/src/ui/cockpitRollingValue.test.mjs new file mode 100644 index 000000000..dd5248c0a --- /dev/null +++ b/src/ui/cockpitRollingValue.test.mjs @@ -0,0 +1,111 @@ +// src/ui/cockpitRollingValue.test.mjs — which glyphs a Cockpit odometer +// readout shows, and which of them roll. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + advanceRollingValue, + createRollingState, +} from './cockpitRollingValue.js'; + +const glyphs = (state) => + state.tokens.map((token) => token.text ?? token.track.join('>')).join('|'); + +test('the first reading draws flat, one glyph per character', () => { + const state = advanceRollingValue(createRollingState(), '1,250', 1250, { + nowMs: 1000, + }); + assert.equal(state.text, '1,250'); + assert.equal(glyphs(state), '1|,|2|5|0'); + assert.deepEqual( + state.tokens.map((token) => token.className), + [ + 'cockpit-roll-token', + 'cockpit-roll-token is-separator', + 'cockpit-roll-token', + 'cockpit-roll-token', + 'cockpit-roll-token', + ], + ); +}); + +test('a changed reading rolls only the digits that differ, upward when rising', () => { + const first = advanceRollingValue(createRollingState(), '248', 248, { + nowMs: 1000, + }); + const next = advanceRollingValue(first, '251', 251, { nowMs: 1300 }); + assert.equal(next === first, false); + assert.equal(next.generation, first.generation + 1); + assert.equal(glyphs(next), '2|4>5|8>1'); + assert.equal( + next.tokens[1].className, + 'cockpit-roll-token is-rolling roll-up', + ); + assert.equal(next.tokens[0].className, 'cockpit-roll-token'); +}); + +test('a falling reading rolls down, and a longer one pads its new columns', () => { + const first = advanceRollingValue(createRollingState(), '99', 99, { + nowMs: 1000, + }); + const down = advanceRollingValue(first, '98', 98, { nowMs: 1300 }); + assert.equal(glyphs(down), '9|8>9'); + assert.match(down.tokens[1].className, /roll-down$/); + const wider = advanceRollingValue(down, '100', 100, { nowMs: 1600 }); + assert.equal(glyphs(wider), '1|9>0|8>0'); + const narrower = advanceRollingValue(wider, '7', 7, { nowMs: 1900 }); + assert.equal(glyphs(narrower), '\u00a0|\u00a0|7>0'); +}); + +test('heading wraps the short way round the circle', () => { + const first = advanceRollingValue(createRollingState(), '359', 359, { + circularRange: 360, + nowMs: 1000, + }); + const next = advanceRollingValue(first, '001', 1, { + circularRange: 360, + nowMs: 1300, + }); + assert.match(next.tokens[1].className, /roll-up$/); +}); + +test('a change inside the throttle is dropped; any accepted reading restamps it', () => { + const first = advanceRollingValue(createRollingState(), '100', 100, { + nowMs: 1000, + }); + // The same reading 150 ms later is accepted and restamps the readout. + const same = advanceRollingValue(first, '100', 100, { nowMs: 1150 }); + assert.equal(same === first, true); + assert.equal(same.at, 1150); + // So a change 200 ms after the first reading is still inside the throttle. + const early = advanceRollingValue(same, '110', 110, { nowMs: 1200 }); + assert.equal(early === same, true); + assert.equal(early.text, '100'); + assert.equal(early.at, 1150); + const late = advanceRollingValue(early, '110', 110, { nowMs: 1370 }); + assert.equal(late.text, '110'); +}); + +test('an immediate reading skips the throttle and draws flat', () => { + const first = advanceRollingValue(createRollingState(), '100', 100, { + nowMs: 1000, + }); + const next = advanceRollingValue(first, '120', 120, { + immediate: true, + nowMs: 1010, + }); + assert.equal(glyphs(next), '1|2|0'); + assert.equal( + next.tokens.every((token) => token.track === null), + true, + ); +}); + +test('a reading with no value keeps rolling upward', () => { + const first = advanceRollingValue(createRollingState(), '---', null, { + nowMs: 1000, + }); + assert.equal(Number.isNaN(first.value), true); + const next = advanceRollingValue(first, '120', 120, { nowMs: 1300 }); + // Dashes are not digits, so nothing rolls. + assert.equal(glyphs(next), '1|2|0'); +}); diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index 06aea19d2..602f5a70d 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -154,7 +154,6 @@ export function enter() { this.surfaceWaitStartedMs = performance.now(); this.surfaceAcquiring = false; this.surfaceFallback = false; - this.lastCompassSignature = ''; this.cockpitAnchorValid = false; this.lastCameraUpdateMs = 0; this.active = true; @@ -220,7 +219,6 @@ export function exit({ restoreTracking = true } = {}) { this.lastHudUpdateMs = 0; this.lastContextUpdateMs = 0; this.contextNavigationDeadlineMs = 0; - this.lastCompassSignature = ''; this.stopBriefRotation(); this.regionalBriefAbort?.abort(); this.regionalBriefAbort = null; @@ -231,7 +229,7 @@ export function exit({ restoreTracking = true } = {}) { this.hud?.style.removeProperty('--cockpit-utility-top'); this.hud?.style.removeProperty('--cockpit-utility-max-height'); if (this.hud) this.hud.hidden = true; - if (this.route) this.route.hidden = true; + this.routeView.visible = false; this.clearPredictiveRoute(); this.setVisionMode('optical'); if (this.signalStream) this.signalStream.hidden = true; diff --git a/src/ui/stores/cockpit.js b/src/ui/stores/cockpit.js index ec1f87506..53069ec3d 100644 --- a/src/ui/stores/cockpit.js +++ b/src/ui/stores/cockpit.js @@ -4,8 +4,9 @@ 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 the Contact card's readouts and weather toggle, and - * publishes them here; it keeps the controls' listeners. + * conditions, the Contact card's readouts and weather toggle, the + * instruments, the flight plan and the vision selector, 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. */ @@ -24,5 +25,40 @@ export const useCockpitStore = defineStore('gev-cockpit', () => { context.value = next && markRaw(next); } - return { brief, publishBrief, context, publishContext }; + /** The presented instruments; null until the first HUD update. */ + const instruments = shallowRef(null); + + /** @param {?object} next A fresh presentation of the instruments. */ + function publishInstruments(next) { + instruments.value = next && markRaw(next); + } + + /** The presented flight plan and destination arrow; null until the first. */ + const route = shallowRef(null); + + /** @param {?object} next A fresh presentation of the route. */ + function publishRoute(next) { + route.value = next && markRaw(next); + } + + /** The presented vision selector; null until the first vision change. */ + const vision = shallowRef(null); + + /** @param {?object} next A fresh presentation of the vision selector. */ + function publishVision(next) { + vision.value = next && markRaw(next); + } + + return { + brief, + publishBrief, + context, + publishContext, + instruments, + publishInstruments, + route, + publishRoute, + vision, + publishVision, + }; }); diff --git a/src/ui/views/CockpitView.vue b/src/ui/views/CockpitView.vue index bc2ef6603..954497520 100644 --- a/src/ui/views/CockpitView.vue +++ b/src/ui/views/CockpitView.vue @@ -1,11 +1,46 @@