mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
The Cockpit controller no longer writes the Contact card's scope, navigation state, cohort counts, nearest contact, bearing, uncertainty, update time or weather toggle: it keeps them as plain state, starting from cockpitContextState.js, and publishes a copy to the gev-cockpit store, and a new CockpitContext component renders it. A lost contact still holds its last readout. The weather toggle flips from that state, not the button's attribute. The card's visibility, collapse and layout stay with the controller. The controller's publishBrief option becomes a publish object, one entry per Cockpit view.
187 lines
6.5 KiB
JavaScript
187 lines
6.5 KiB
JavaScript
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
|
|
import * as Cesium from 'cesium';
|
|
import { formatNumber, t, tn } from '../i18n/index.js';
|
|
import { unitLabel } from './unitLabels.js';
|
|
import {
|
|
bearingBetweenCoordinates,
|
|
formatCockpitContextScope,
|
|
relativeBearing,
|
|
resolveCockpitContextReadout,
|
|
} from '../cockpitMath.js';
|
|
import {
|
|
cockpitUnitCase,
|
|
formatCockpitDistance,
|
|
} from './cockpitPresentation.js';
|
|
|
|
/** The Context scope line in the active language (see cockpitMath.js). */
|
|
const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
|
|
window: ({ label, radiusKm }) =>
|
|
t('{label} · {radius} AIR/SEA WINDOW', {
|
|
label,
|
|
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${cockpitUnitCase(unitLabel('km'))}`,
|
|
}),
|
|
installations: ({ scope, coverage }) =>
|
|
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
|
|
});
|
|
|
|
/** Hand a fresh copy of the Contact card to the view. */
|
|
export function publishContextView() {
|
|
const view = this.contextView;
|
|
this.publishContext({
|
|
...view,
|
|
cohorts: Object.fromEntries(
|
|
Object.entries(view.cohorts).map(([id, cohort]) => [id, { ...cohort }]),
|
|
),
|
|
nearest: { ...view.nearest },
|
|
distance: { ...view.distance },
|
|
direction: { ...view.direction },
|
|
});
|
|
}
|
|
|
|
export function updateContext(info, heading) {
|
|
if (!this.context) return;
|
|
const view = this.contextView;
|
|
const snapshot =
|
|
this.services.militaryAwarenessLayer.getContextSnapshot?.() || null;
|
|
const trackedId = info.icao24 || info.id;
|
|
const readout = resolveCockpitContextReadout({ snapshot, info });
|
|
if (!readout.visible) {
|
|
this.context.hidden = true;
|
|
this.hud?.classList.remove('context-active');
|
|
this.contextLayoutStamp = null;
|
|
this.pushCockpitSignal(
|
|
'context-status',
|
|
'info',
|
|
t('CONTEXT STANDBY'),
|
|
t('ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS'),
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.context.hidden = false;
|
|
this.hud?.classList.add('context-active');
|
|
const installationCoverage = snapshot.cohorts.find(
|
|
(cohort) => cohort.id === 'military-installations',
|
|
)?.coverage;
|
|
view.subject = formatCockpitContextScope(
|
|
snapshot.subject.label || trackedId,
|
|
snapshot.radiusM,
|
|
installationCoverage,
|
|
LOCALIZED_CONTEXT_SCOPE,
|
|
);
|
|
// Navigation stays wired in every state — the operator must always be able
|
|
// to step off the current contact from the panel that hosts the controls.
|
|
view.canPrevious = Boolean(snapshot.navigation?.canPrevious);
|
|
view.canNext = Boolean(snapshot.navigation?.canNext);
|
|
|
|
if (readout.contactLost) {
|
|
// The subject left its source. Every number below is measured against a
|
|
// position that stopped updating, so hold the last rendered readout and
|
|
// say so instead of re-deriving stale geometry as if it were live.
|
|
const enteringLost = view.state !== 'lost';
|
|
view.state = 'lost';
|
|
view.uncertainty = t(
|
|
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR',
|
|
);
|
|
this.publishContextView();
|
|
// The cue changes the footer's height; re-run layout once on the way in
|
|
// rather than every frame the contact stays lost.
|
|
if (enteringLost) this.scheduleContextLayout();
|
|
this.pushCockpitSignal(
|
|
'context-status',
|
|
'warning',
|
|
t('CONTACT LOST · {subject}', {
|
|
subject: snapshot.subject.label || snapshot.subject.id || 'SUBJECT',
|
|
}),
|
|
t('SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN'),
|
|
);
|
|
return;
|
|
}
|
|
|
|
let unknownCount = 0;
|
|
const nearest = [];
|
|
for (const cohort of snapshot.cohorts) {
|
|
const shown = view.cohorts[cohort.id];
|
|
if (shown) {
|
|
shown.count = cohort.count === null ? '?' : String(cohort.count);
|
|
shown.unknown = cohort.relationship === 'UNKNOWN';
|
|
}
|
|
if (cohort.count === null) unknownCount += 1;
|
|
for (const item of cohort.nearest) nearest.push({ ...item, cohort });
|
|
}
|
|
nearest.sort((a, b) => (a.distanceM ?? Infinity) - (b.distanceM ?? Infinity));
|
|
const closest = nearest[0] || null;
|
|
const closestLabel = this.services.formatAwarenessLabel(closest);
|
|
const nearestLabel = closest
|
|
? t('{cohort} · {contact}', {
|
|
cohort: closest.cohort.label.toUpperCase(),
|
|
contact: closestLabel,
|
|
})
|
|
: t('NO AVAILABLE EXAMPLE');
|
|
view.nearest = {
|
|
label: nearestLabel,
|
|
ariaLabel:
|
|
closest && closestLabel === '—'
|
|
? t('{cohort}, Unavailable', { cohort: closest.cohort.label })
|
|
: nearestLabel,
|
|
};
|
|
const distanceM = closest?.distanceM;
|
|
const distance = Number.isFinite(distanceM)
|
|
? formatCockpitDistance(distanceM)
|
|
: '—';
|
|
view.distance = {
|
|
text: distance,
|
|
ariaLabel: Number.isFinite(distanceM) ? distance : t('Unavailable'),
|
|
};
|
|
|
|
// The arrow and BRG are nose-relative to the tracked aircraft. When the
|
|
// subject is a vessel, an installation, or another aircraft, the rest of
|
|
// this row is measured from that subject — so the aircraft-frame half is
|
|
// dashed rather than presented alongside subject-frame distances as if the
|
|
// two shared an origin.
|
|
let relative = null;
|
|
if (
|
|
readout.aircraftRelative &&
|
|
closest?.position &&
|
|
Number.isFinite(info.latitude) &&
|
|
Number.isFinite(info.longitude)
|
|
) {
|
|
const cartographic = Cesium.Cartographic.fromCartesian(closest.position);
|
|
const bearing = cartographic
|
|
? bearingBetweenCoordinates(
|
|
info.latitude,
|
|
info.longitude,
|
|
Cesium.Math.toDegrees(cartographic.latitude),
|
|
Cesium.Math.toDegrees(cartographic.longitude),
|
|
)
|
|
: null;
|
|
relative = relativeBearing(bearing, heading);
|
|
}
|
|
view.direction = { angle: relative ?? 0, unknown: relative === null };
|
|
if (relative === null) view.bearing = t('BRG —');
|
|
else if (Math.abs(relative) < 8) view.bearing = t('AHEAD');
|
|
else {
|
|
view.bearing = t('{side} {angle}', {
|
|
side: relative < 0 ? t('L') : t('R'),
|
|
angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`,
|
|
});
|
|
}
|
|
view.uncertainty = unknownCount
|
|
? tn(
|
|
unknownCount,
|
|
'{count} INPUT UNKNOWN · NOT AN ALL-CLEAR',
|
|
'{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR',
|
|
)
|
|
: t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR');
|
|
view.updated = Number.isFinite(snapshot.evaluatedAt)
|
|
? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z'
|
|
: '--:--:--Z';
|
|
view.state = unknownCount ? 'uncertain' : 'current';
|
|
this.publishContextView();
|
|
this.updateCockpitSignals(snapshot, unknownCount);
|
|
if (this.contextLayoutStamp !== snapshot.evaluatedAt) {
|
|
this.contextLayoutStamp = snapshot.evaluatedAt;
|
|
this.scheduleContextLayout();
|
|
}
|
|
}
|