Files
gods-eye-view/src/ui/cockpitInstruments.js
T
Sameh Khamis 0e90a03de7 Round every heading readout through one shared helper
The transit, ALPR, vessel, CCTV and flight/military context fixes each
wrapped 359.5° to north with an inline Math.round(x) % 360. They now call
roundHeadingDegrees in src/data/headingDegrees.js, which also wraps negative
and multi-turn bearings. normalizeHeading moves there with it, and
cockpitMath re-exports it so existing imports keep working. It now adds a
turn only to a negative remainder, so a bearing just under half a degree
keeps its precision.

The awareness, Local ADS-B, cockpit wind, cockpit heading and Street Level
readouts use the same helper. The Cockpit's TRACK ACQUIRED signal rounded
without wrapping and could read COURSE 360°; it now uses
formatHeadingReadout, which shows --- for a track that is not finite.
2026-11-01 21:41:49 -08:00

258 lines
8.0 KiB
JavaScript

/** Instrument values, routes and vision controls for the Cockpit controller. */
import { formatNumber, t } from '../i18n/index.js';
import {
COCKPIT_VISION_MODES,
normalizeCockpitVisionMode,
} from '../cockpitVisionPolicy.js';
import {
altitudeRulerCurveInset,
altitudeRulerTicks,
bearingBetweenCoordinates,
cockpitAltitudeDisplayFt,
cockpitUiUpdateDue,
compassDivisions,
formatAltitudeRulerTick,
formatCompassDivision,
formatCockpitEmergency,
formatHeadingReadout,
formatSpeedRulerTick,
normalizeHeading,
relativeBearing,
speedRulerTicks,
} from '../cockpitMath.js';
import {
COCKPIT_CONTEXT_UPDATE_MS,
compassPointLabel,
formatCockpitCoordinate,
} 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();
/** The emergency line in the active language (see cockpitMath.js). */
const LOCALIZED_EMERGENCY = Object.freeze({
squawk: (parts) =>
t('SQUAWK {code} · {status}', {
code: parts.squawk,
status: emergencyLabel(parts),
}),
adsb: (parts) =>
parts.priority
? t('ADS-B PRIORITY · {status}', { status: emergencyLabel(parts) })
: 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);
view.callsign =
info.callsign || info.registration || info.icao24 || t('AIRCRAFT');
const speedKt = Number.isFinite(info.velocityMps)
? info.velocityMps * 1.94384
: null;
view.speed = {
text: formatSpeedRulerTick(speedKt),
value: speedKt,
immediate: forceContext,
};
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);
const displayedAltitudeFt = Number.isFinite(altitudeFt)
? Math.round(altitudeFt)
: null;
view.altitude = {
text:
displayedAltitudeFt !== null
? formatNumber(displayedAltitudeFt)
: '-----',
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),
);
view.heading = {
text: formatHeadingReadout(heading),
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 ||
cockpitUiUpdateDue(
nowMs,
this.lastContextUpdateMs,
COCKPIT_CONTEXT_UPDATE_MS,
)
) {
this.lastContextUpdateMs = nowMs;
this.updateLocalPosition(info);
this.maybeRefreshRegionalBrief(info);
this.updateContext(info, heading);
}
}
/** 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');
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) ||
!Number.isFinite(info?.latitude)
) {
this.clearPredictiveRoute();
return;
}
const destinationBearing = bearingBetweenCoordinates(
info.latitude,
info.longitude,
destination.lat,
destination.lon,
);
const relative = relativeBearing(
destinationBearing,
this.heading ?? info.track ?? 0,
);
if (!Number.isFinite(destinationBearing) || !Number.isFinite(relative)) {
this.clearPredictiveRoute();
return;
}
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) {
this.contextView.weather = !!enabled;
this.publishContextView();
}
export function setVisionMode(mode, { revealParameters = false } = {}) {
const next = normalizeCockpitVisionMode(mode);
this.visionMode = next;
this.publishVision(createVisionState(next));
this.onVisionChange?.(next, this.active, { revealParameters });
}
export function cycleVisionMode(direction = 1) {
const modes = COCKPIT_VISION_MODES;
const currentIndex = Math.max(0, modes.indexOf(this.visionMode));
const step = direction < 0 ? -1 : 1;
const nextIndex = (currentIndex + step + modes.length) % modes.length;
this.setVisionMode(modes[nextIndex], { revealParameters: true });
}
export function clearPredictiveRoute() {
this.routeView.direction.visible = false;
this.publishRouteView();
}