mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +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
+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();
|
||||
}
|
||||
Reference in new issue
Block a user