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.
This commit is contained in:
Sameh Khamis committed 2026-10-31 14:36:32 -07:00
1 parent ffe14e50f5
commit a2bca0fb22
29 files changed
+1089 -404

No files matched your search

+12 -6
View File
@@ -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.
+10
View File
@@ -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",
+6 -5
View File
@@ -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(
-2
View File
@@ -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",
-2
View File
@@ -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",
-2
View File
@@ -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",
-2
View File
@@ -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",
-2
View File
@@ -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": "次のビジョンスタイル",
-2
View File
@@ -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",
-2
View File
@@ -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": "Предыдущая страница брифинга",
-2
View File
@@ -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": "Наступний візуальний стиль",
-2
View File
@@ -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": "下一个视觉样式",
-2
View File
@@ -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": "下一個視覺樣式",
+1 -1
View File
@@ -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
+6
View File
@@ -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),
+7 -7
View File
@@ -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');
+18 -32
View File
@@ -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);
}
+95
View File
@@ -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]),
};
}
+129 -170
View File
@@ -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 `<span class="${slot === 0 ? 'active' : ''}" style="--slot:${slot};--depth:${Math.abs(slot)}">${compassPointLabel(formatCompassDivision(division))}</span>`;
})
.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();
}
+409
View File
@@ -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'),
/^Текущий/,
);
});
-103
View File
@@ -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);
}
+116
View File
@@ -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;
}
+111
View File
@@ -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');
});
+1 -3
View File
@@ -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;
+39 -3
View File
@@ -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,
};
});
+54 -35
View File
@@ -1,11 +1,46 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../i18n/index.js';
import {
createInstrumentState,
createRouteState,
createVisionState,
} from '../cockpitInstrumentState.js';
import { vClassToggle } from '../rail/classToggle.js';
import { useCockpitStore } from '../stores/cockpit.js';
import CockpitBriefing from './panels/CockpitBriefing.vue';
import CockpitContext from './panels/CockpitContext.vue';
import CockpitRollingValue from './panels/CockpitRollingValue.vue';
// The Cockpit HUD. The Cockpit controller presents the instruments, the
// destination arrow and the vision selector on every HUD update; until it
// does, they show the state they start in. The HUD's visibility, classes
// and layout stay with the controller, and the controls keep its listeners.
const { instruments, route, vision } = storeToRefs(useCockpitStore());
const initialInstruments = createInstrumentState();
const initialRoute = createRouteState();
const initialVision = createVisionState();
const hud = computed(() => instruments.value ?? initialInstruments);
const direction = computed(() => (route.value ?? initialRoute).direction);
const v = computed(() => vision.value ?? initialVision);
const visionLabel = computed(() =>
t('Current cockpit vision style: {style}. Activate for next style.', {
style: v.value.name,
}),
);
const visionTitle = computed(() =>
t('Current style: {style} — click for next', { style: v.value.name }),
);
const tickStyle = (tick) =>
tick.slot === null
? undefined
: { '--slot': tick.slot, '--depth': tick.depth, '--curve': tick.curve };
</script>
<template>
<!-- Aircraft first-person view. It can be entered from the visible view switcher. -->
<section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden data-i18n-aria-label>
<section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden data-i18n-aria-label :data-layer="hud.layer ?? undefined">
<!-- One optical coordinate system: every targeting mark shares this center.
Decorative only; telemetry and interactive panels remain separate. -->
<svg class="cockpit-frame-lines" viewBox="0 0 1200 720" preserveAspectRatio="none" aria-hidden="true" focusable="false">
@@ -69,67 +104,51 @@ import CockpitContext from './panels/CockpitContext.vue';
<div class="cockpit-horizon-guide">
<span></span><small data-i18n>LEVEL</small><span></span>
</div>
<div id="cockpit-route-direction" class="cockpit-route-direction" hidden aria-label="Estimated destination direction" data-i18n-aria-label>
<div id="cockpit-route-direction" class="cockpit-route-direction" :hidden="!direction.visible" aria-label="Estimated destination direction" data-i18n-aria-label :style="direction.angle === null ? undefined : { '--route-angle': direction.angle }">
<svg class="cockpit-route-chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 2 4.58 19.93c-.34.83.51 1.63 1.31 1.27L12 18.47l6.11 2.73c.8.36 1.65-.44 1.31-1.27L12 2Z" />
</svg>
<small id="cockpit-route-direction-label" data-i18n>DEST ---°</small>
<small id="cockpit-route-direction-label">{{ direction.label }}</small>
</div>
<div class="cockpit-visor-status cockpit-visor-status-left" data-i18n>OPTICAL PLANE · 01</div>
<div class="cockpit-visor-status cockpit-visor-status-right" data-i18n>VISOR LOCK · ACTIVE</div>
</div>
<div id="cockpit-speed-rim" class="cockpit-altitude-rim cockpit-speed-rim">
<div id="cockpit-speed-rim" class="cockpit-altitude-rim cockpit-speed-rim" v-class-toggle="{ unavailable: hud.speedRim.unavailable }">
<span class="cockpit-altitude-rim-label" data-i18n>GROUND SPEED · KTS</span>
<div class="cockpit-altitude-rim-rail"></div>
<div class="cockpit-altitude-rim-scale">
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span v-for="(tick, index) in hud.speedRim.ticks" :key="index" data-speed-rim-tick :hidden="tick.hidden" :class="{ major: tick.major }" :style="tickStyle(tick)"><b>{{ tick.label }}</b><i></i></span>
</div>
<div class="cockpit-altitude-rim-pointer">
<span class="material-symbols-outlined">arrow_right</span>
<strong id="cockpit-speed-rim-value">---</strong>
<strong id="cockpit-speed-rim-value">{{ hud.speedRim.value }}</strong>
</div>
</div>
<div id="cockpit-altitude-rim" class="cockpit-altitude-rim">
<div id="cockpit-altitude-rim" class="cockpit-altitude-rim" v-class-toggle="{ unavailable: hud.altitudeRim.unavailable }">
<span class="cockpit-altitude-rim-label" data-i18n>ALTITUDE · FT</span>
<div class="cockpit-altitude-rim-rail"></div>
<div class="cockpit-altitude-rim-scale">
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span v-for="(tick, index) in hud.altitudeRim.ticks" :key="index" data-altitude-rim-tick :hidden="tick.hidden" :class="{ major: tick.major }" :style="tickStyle(tick)"><b>{{ tick.label }}</b><i></i></span>
</div>
<div class="cockpit-altitude-rim-pointer">
<strong id="cockpit-altitude-rim-value">-----</strong>
<strong id="cockpit-altitude-rim-value">{{ hud.altitudeRim.value }}</strong>
<span class="material-symbols-outlined">arrow_left</span>
</div>
</div>
<div class="cockpit-topline">
<div>
<span class="cockpit-kicker" data-i18n>FIRST PERSON</span>
<strong id="cockpit-callsign" data-i18n>AIRCRAFT</strong>
<span id="cockpit-aircraft-meta" class="cockpit-aircraft-meta" data-i18n>LIVE TRACK · COURSE ALIGNED</span>
<strong id="cockpit-callsign">{{ hud.callsign }}</strong>
<span id="cockpit-aircraft-meta" v-class-toggle="{ emergency: hud.meta.emergency }" class="cockpit-aircraft-meta">{{ hud.meta.text }}</span>
</div>
<div class="cockpit-vision-controls" role="group" aria-label="Cockpit vision style" data-i18n-aria-label>
<button id="cockpit-vision-previous" class="cockpit-vision-nav" type="button" aria-label="Previous cockpit vision style" title="Previous vision style" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
<small data-i18n>PREV</small>
</button>
<button id="cockpit-vision-current" class="cockpit-vision-current" type="button" data-cockpit-vision="optical" aria-label="Current cockpit vision style: NORMAL. Activate for next style." title="Current style: NORMAL — click for next" data-i18n-aria-label data-i18n-title>
<button id="cockpit-vision-current" class="cockpit-vision-current" type="button" :data-cockpit-vision="v.mode" :aria-label="visionLabel" :title="visionTitle">
<small data-i18n>CURRENT</small>
<strong id="cockpit-vision-current-label" aria-live="polite" data-i18n>NORMAL</strong>
<strong id="cockpit-vision-current-label" aria-live="polite">{{ v.label }}</strong>
</button>
<button id="cockpit-vision-next" class="cockpit-vision-nav" type="button" aria-label="Next cockpit vision style" title="Next vision style" data-i18n-aria-label data-i18n-title>
<small data-i18n>NEXT</small>
@@ -142,22 +161,22 @@ import CockpitContext from './panels/CockpitContext.vue';
<div class="cockpit-instrument-row">
<div class="cockpit-readout cockpit-speed">
<span class="cockpit-readout-label" data-i18n>GROUND SPEED</span>
<strong id="cockpit-speed-value">---</strong>
<CockpitRollingValue id="cockpit-speed-value" tag="strong" :reading="hud.speed" placeholder="---" />
<span class="cockpit-readout-unit" data-i18n>KTS</span>
</div>
<div class="cockpit-heading-value"><span id="cockpit-heading-value">---</span><small>°</small></div>
<div class="cockpit-heading-value"><CockpitRollingValue id="cockpit-heading-value" :reading="hud.heading" placeholder="---" :circular-range="360" /><small>°</small></div>
<div class="cockpit-readout cockpit-altitude">
<span class="cockpit-readout-label" data-i18n>ALTITUDE</span>
<strong id="cockpit-altitude-value">-----</strong>
<CockpitRollingValue id="cockpit-altitude-value" tag="strong" :reading="hud.altitude" placeholder="-----" />
<span class="cockpit-readout-unit" data-i18n>FT</span>
</div>
</div>
<span class="cockpit-heading-caret material-symbols-outlined" aria-hidden="true">arrow_drop_down</span>
<div class="cockpit-compass-tape" id="cockpit-compass-tape" aria-hidden="true"></div>
<div class="cockpit-compass-tape" id="cockpit-compass-tape" aria-hidden="true"><span v-for="division in hud.compass" :key="division.slot" :class="division.slot === 0 ? 'active' : ''" :style="{ '--slot': division.slot, '--depth': Math.abs(division.slot) }">{{ division.label }}</span></div>
</div>
<div class="cockpit-position-readout">
<span id="cockpit-clock">--:--:--Z</span>
<span id="cockpit-position">--° · --°</span>
<span id="cockpit-clock">{{ hud.clock }}</span>
<span id="cockpit-position">{{ hud.position }}</span>
</div>
<CockpitContext />
<CockpitBriefing />
+4 -19
View File
@@ -3,12 +3,13 @@ import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { createBriefState } from '../../cockpitBriefPages.js';
import { useCockpitStore } from '../../stores/cockpit.js';
import CockpitRoute from './CockpitRoute.vue';
// The Cockpit briefing card. The Cockpit controller presents its pages,
// cycle toggle, regional news and local conditions; until it does, the card
// shows the state it starts in. The controls keep the controller's
// listeners. The route card and the signal list keep their owners'
// bindings, so nothing here binds them.
// listeners. The route card renders itself; the signal list keeps its
// owner's bindings, so nothing here binds it.
const { brief } = storeToRefs(useCockpitStore());
const initial = createBriefState();
const b = computed(() => brief.value ?? initial);
@@ -21,23 +22,7 @@ const streamState = computed(() => ({
<template>
<aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" hidden data-i18n-aria-label v-bind="streamState">
<section id="cockpit-route" class="cockpit-route-card" aria-label="Estimated flight plan" hidden data-i18n-aria-label>
<div class="cockpit-route-heading">
<span class="cockpit-route-kicker" data-i18n>ESTIMATED FLIGHT PLAN</span>
<small id="cockpit-route-status" data-i18n>ROUTE DATA UNAVAILABLE</small>
</div>
<div class="cockpit-route-endpoints">
<div class="cockpit-route-airport">
<span data-i18n>FROM</span>
<strong id="cockpit-route-from" data-i18n>UNKNOWN</strong>
</div>
<span class="cockpit-route-arrow material-symbols-outlined" aria-hidden="true">east</span>
<div class="cockpit-route-airport">
<span data-i18n>TO</span>
<strong id="cockpit-route-to" data-i18n>UNKNOWN</strong>
</div>
</div>
</section>
<CockpitRoute />
<header class="cockpit-signal-header">
<div class="cockpit-brief-heading">
<span id="cockpit-brief-kicker" class="cockpit-signal-kicker"><i aria-hidden="true"></i> {{ b.kicker }}</span>
@@ -0,0 +1,38 @@
<script setup>
import { shallowRef, watch } from 'vue';
import {
advanceRollingValue,
createRollingState,
} from '../../cockpitRollingValue.js';
// One Cockpit odometer readout. Every reading the controller publishes is a
// fresh object, so each one restamps the throttle just as the controller's
// own repeated writes did. The view takes the last reading of a tick, so
// the controller publishes one per HUD update. Until the first reading, the
// placeholder shows.
// The template is one line: templates keep whitespace, and a space between
// glyphs would show.
const props = defineProps({
tag: { type: String, default: 'span' },
reading: { type: Object, default: null },
placeholder: { type: String, required: true },
circularRange: { type: Number, default: null },
});
const state = shallowRef(createRollingState());
watch(
() => props.reading,
(reading) => {
if (!reading) return;
state.value = advanceRollingValue(state.value, reading.text, reading.value, {
circularRange: props.circularRange,
immediate: reading.immediate,
});
},
{ immediate: true },
);
</script>
<template>
<component :is="tag" :aria-label="state.text"><template v-if="!state.tokens">{{ placeholder }}</template><span v-for="(token, index) in state.tokens" :key="`${state.generation}:${index}`" :class="token.className" aria-hidden="true"><span v-if="token.track" class="cockpit-roll-track"><span>{{ token.track[0] }}</span><span>{{ token.track[1] }}</span></span><template v-else>{{ token.text }}</template></span></component>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { createRouteState } from '../../cockpitInstrumentState.js';
import { useCockpitStore } from '../../stores/cockpit.js';
// The Cockpit flight plan card. The Cockpit controller presents the route's
// endpoints and whether a destination direction exists; until it does, the
// card stays hidden.
const { route } = storeToRefs(useCockpitStore());
const initial = createRouteState();
const r = computed(() => route.value ?? initial);
</script>
<template>
<section id="cockpit-route" class="cockpit-route-card" aria-label="Estimated flight plan" data-i18n-aria-label :hidden="!r.visible">
<div class="cockpit-route-heading">
<span class="cockpit-route-kicker" data-i18n>ESTIMATED FLIGHT PLAN</span>
<small id="cockpit-route-status">{{ r.status }}</small>
</div>
<div class="cockpit-route-endpoints">
<div class="cockpit-route-airport">
<span data-i18n>FROM</span>
<strong id="cockpit-route-from">{{ r.from }}</strong>
</div>
<span class="cockpit-route-arrow material-symbols-outlined" aria-hidden="true">east</span>
<div class="cockpit-route-airport">
<span data-i18n>TO</span>
<strong id="cockpit-route-to">{{ r.to }}</strong>
</div>
</div>
</section>
</template>