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 @@