mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
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:
1 parent
ffe14e50f5
commit
a2bca0fb22
29 files changed
+1089
-404
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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'),
|
||||
/^Текущий/,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user