diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 77e87bc49..29b3d5138 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -191,5 +191,22 @@
"src/ui/contextPresentation.js",
"src/ui/contextSession.js",
"src/ui/contextSubscriptions.js",
- "src/ui/contextTransactions.js"
+ "src/ui/contextTransactions.js",
+ "src/ui/cockpit.js",
+ "src/ui/cockpitBriefing.js",
+ "src/ui/cockpitCamera.js",
+ "src/ui/cockpitContext.js",
+ "src/ui/cockpitController.js",
+ "src/ui/cockpitController.test.mjs",
+ "src/ui/cockpitDisplayPortal.js",
+ "src/ui/cockpitDisplayPortal.test.mjs",
+ "src/ui/cockpitInput.js",
+ "src/ui/cockpitInstruments.js",
+ "src/ui/cockpitLayout.js",
+ "src/ui/cockpitPresentation.js",
+ "src/ui/cockpitSignals.js",
+ "src/ui/cockpitTrackingController.js",
+ "src/cockpitMath.js",
+ "src/cockpitUtilityLayout.js",
+ "src/cockpitVisionPolicy.js"
]
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 0cb54b36f..43373f43e 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -413,8 +413,6 @@
"src/cockpitVisionPolicy.js",
"src/ui/panelRailGeometry.js"
],
- "external": [
- "cesium"
- ]
+ "external": ["cesium"]
}
}
diff --git a/src/cockpitMath.js b/src/cockpitMath.js
index fc3e2135f..a0bd83676 100644
--- a/src/cockpitMath.js
+++ b/src/cockpitMath.js
@@ -25,8 +25,13 @@ export function slewHeading(current, target, maxStepDeg) {
* becoming a first-person surge or reversal while still removing drift.
*/
export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
- if (!Number.isFinite(distanceM) || distanceM <= 0
- || !Number.isFinite(dtSec) || dtSec <= 0) return 0;
+ if (
+ !Number.isFinite(distanceM) ||
+ distanceM <= 0 ||
+ !Number.isFinite(dtSec) ||
+ dtSec <= 0
+ )
+ return 0;
const dt = Math.min(0.1, dtSec);
const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0;
const eased = distanceM * (1 - Math.exp(-1.25 * dt));
@@ -36,14 +41,21 @@ export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
/** Return whether a throttled cockpit presentation update is due. */
export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) {
- if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0) return false;
- if (!Number.isFinite(lastUpdateMs) || lastUpdateMs <= 0 || nowMs < lastUpdateMs) return true;
+ if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0)
+ return false;
+ if (
+ !Number.isFinite(lastUpdateMs) ||
+ lastUpdateMs <= 0 ||
+ nowMs < lastUpdateMs
+ )
+ return true;
return nowMs - lastUpdateMs >= intervalMs;
}
/** Return whether the bounded rendered-surface acquisition window has elapsed. */
export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) {
- if (![nowMs, startedMs, timeoutMs].every(Number.isFinite) || timeoutMs < 0) return true;
+ if (![nowMs, startedMs, timeoutMs].every(Number.isFinite) || timeoutMs < 0)
+ return true;
return nowMs - startedMs >= timeoutMs;
}
@@ -51,7 +63,11 @@ export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) {
* Keep the cockpit camera above the shared rendered-surface floor.
* Unknown floors preserve the proposed camera height.
*/
-export function cockpitGroundSafeHeight(proposedHeightM, groundHeightM, clearanceM) {
+export function cockpitGroundSafeHeight(
+ proposedHeightM,
+ groundHeightM,
+ clearanceM,
+) {
if (!Number.isFinite(proposedHeightM)) return proposedHeightM;
if (!Number.isFinite(groundHeightM)) return proposedHeightM;
const clearance = Number.isFinite(clearanceM) ? Math.max(0, clearanceM) : 0;
@@ -68,36 +84,51 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) {
}
/** Format the cockpit Context scope without overstating installation coverage. */
-export function formatCockpitContextScope(subjectLabel, radiusM, installationCoverage = null) {
- const normalizedLabel = typeof subjectLabel === 'string'
- ? subjectLabel.trim() || '—'
- : Number.isFinite(subjectLabel) ? String(subjectLabel) : '—';
- const radiusKm = Number.isFinite(radiusM) && radiusM >= 0
- ? String(Math.round(radiusM / 1000))
- : '—';
- const coverage = typeof installationCoverage === 'string'
- ? installationCoverage.trim()
- : '';
+export function formatCockpitContextScope(
+ subjectLabel,
+ radiusM,
+ installationCoverage = null,
+) {
+ const normalizedLabel =
+ typeof subjectLabel === 'string'
+ ? subjectLabel.trim() || '—'
+ : Number.isFinite(subjectLabel)
+ ? String(subjectLabel)
+ : '—';
+ const radiusKm =
+ Number.isFinite(radiusM) && radiusM >= 0
+ ? String(Math.round(radiusM / 1000))
+ : '—';
+ const coverage =
+ typeof installationCoverage === 'string' ? installationCoverage.trim() : '';
const base = `${normalizedLabel} · ${radiusKm} KM AIR/SEA WINDOW`;
- return coverage
- ? `${base} · INSTALLATIONS ${coverage}`
- : base;
+ return coverage ? `${base} · INSTALLATIONS ${coverage}` : base;
}
/** Return seven 30-degree compass divisions centered on a heading. */
export function compassDivisions(heading) {
const center = Math.round(normalizeHeading(heading) / 30) * 30;
- return [-90, -60, -30, 0, 30, 60, 90].map((offset) => normalizeHeading(center + offset));
+ return [-90, -60, -30, 0, 30, 60, 90].map((offset) =>
+ normalizeHeading(center + offset),
+ );
}
/** Format a compass division as a cardinal/intercardinal label or degrees. */
export function formatCompassDivision(heading) {
const normalized = normalizeHeading(heading);
const labels = new Map([
- [0, 'N'], [45, 'NE'], [90, 'E'], [135, 'SE'],
- [180, 'S'], [225, 'SW'], [270, 'W'], [315, 'NW'],
+ [0, 'N'],
+ [45, 'NE'],
+ [90, 'E'],
+ [135, 'SE'],
+ [180, 'S'],
+ [225, 'SW'],
+ [270, 'W'],
+ [315, 'NW'],
]);
- return labels.get(normalized) || String(Math.round(normalized)).padStart(3, '0');
+ return (
+ labels.get(normalized) || String(Math.round(normalized)).padStart(3, '0')
+ );
}
/** Choose a readable altitude-tape interval for the current flight level. */
@@ -190,20 +221,22 @@ export function formatSpeedRulerTick(valueKt) {
/** Return the initial great-circle bearing between two latitude/longitude points. */
export function bearingBetweenCoordinates(fromLat, fromLon, toLat, toLon) {
if (![fromLat, fromLon, toLat, toLon].every(Number.isFinite)) return null;
- const φ1 = fromLat * Math.PI / 180;
- const φ2 = toLat * Math.PI / 180;
- const Δλ = (toLon - fromLon) * Math.PI / 180;
+ const φ1 = (fromLat * Math.PI) / 180;
+ const φ2 = (toLat * Math.PI) / 180;
+ const Δλ = ((toLon - fromLon) * Math.PI) / 180;
const y = Math.sin(Δλ) * Math.cos(φ2);
- const x = Math.cos(φ1) * Math.sin(φ2)
- - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
+ const x =
+ Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
if (Math.abs(x) < 1e-12 && Math.abs(y) < 1e-12) return null;
- return normalizeHeading(Math.atan2(y, x) * 180 / Math.PI);
+ return normalizeHeading((Math.atan2(y, x) * 180) / Math.PI);
}
/** Return a target bearing relative to the current heading in the [-180, 180) range. */
export function relativeBearing(bearing, heading) {
if (!Number.isFinite(bearing) || !Number.isFinite(heading)) return null;
- return ((normalizeHeading(bearing) - normalizeHeading(heading) + 540) % 360) - 180;
+ return (
+ ((normalizeHeading(bearing) - normalizeHeading(heading) + 540) % 360) - 180
+ );
}
/**
@@ -229,10 +262,13 @@ export function resolveTrackedAircraftInfo({
if (civilian) candidates.push({ ...civilian, layerId: 'flights' });
if (military) candidates.push({ ...military, layerId: 'military' });
if (!candidates.length) return null;
- const trackedKey = String(trackedId || '').trim().toLowerCase();
+ const trackedKey = String(trackedId || '')
+ .trim()
+ .toLowerCase();
if (trackedKey) {
const owner = candidates.find(
- (candidate) => `${candidate.layerId}:${candidate.icao24}`.toLowerCase() === trackedKey,
+ (candidate) =>
+ `${candidate.layerId}:${candidate.icao24}`.toLowerCase() === trackedKey,
);
if (owner) return owner;
}
@@ -253,7 +289,10 @@ export function resolveTrackedAircraftInfo({
* aircraftRelative: boolean, contactLost: boolean}}
* Panel visibility plus the per-field rendering rules for this frame.
*/
-export function resolveCockpitContextReadout({ snapshot = null, info = null } = {}) {
+export function resolveCockpitContextReadout({
+ snapshot = null,
+ info = null,
+} = {}) {
if (!snapshot) {
return {
visible: false,
@@ -264,14 +303,15 @@ export function resolveCockpitContextReadout({ snapshot = null, info = null } =
};
}
const trackedId = info?.icao24 ?? info?.id;
- const subjectMatchesTracked = snapshot.subject?.layerId === info?.layerId
- && String(snapshot.subject?.id) === String(trackedId);
+ const subjectMatchesTracked =
+ snapshot.subject?.layerId === info?.layerId &&
+ String(snapshot.subject?.id) === String(trackedId);
// `subjectPresent` is absent on snapshots built before the presence signal
// existed; only an explicit `false` means the subject left its source.
const contactLost = snapshot.subjectPresent === false;
return {
visible: true,
- mode: contactLost ? 'lost' : (subjectMatchesTracked ? 'tracked' : 'foreign'),
+ mode: contactLost ? 'lost' : subjectMatchesTracked ? 'tracked' : 'foreign',
subjectMatchesTracked,
// The nose-relative arrow and BRG readout are measured in the tracked
// aircraft's own frame; every other value in that row is measured from the
diff --git a/src/cockpitUtilityLayout.js b/src/cockpitUtilityLayout.js
index dea0a77a3..0d6c09327 100644
--- a/src/cockpitUtilityLayout.js
+++ b/src/cockpitUtilityLayout.js
@@ -62,7 +62,10 @@ export function resolveCockpitUtilityLayout({
gap = 7,
minimumExpandedHeight = 120,
}) {
- const available = Math.max(minimumExpandedHeight, Number(availableHeight) || 0);
+ const available = Math.max(
+ minimumExpandedHeight,
+ Number(availableHeight) || 0,
+ );
const expanded = Math.max(0, Number(expandedHeight) || 0);
const collapsed = Math.max(0, Number(collapsedHeight) || 0);
const spacing = Math.max(0, Number(gap) || 0);
@@ -76,4 +79,3 @@ export function resolveCockpitUtilityLayout({
),
};
}
-
diff --git a/src/cockpitVisionPolicy.js b/src/cockpitVisionPolicy.js
index 40602df2e..c49aa35da 100644
--- a/src/cockpitVisionPolicy.js
+++ b/src/cockpitVisionPolicy.js
@@ -1,4 +1,10 @@
-export const COCKPIT_VISION_MODES = Object.freeze(['optical', 'crt', 'nvg', 'thermal', 'noir']);
+export const COCKPIT_VISION_MODES = Object.freeze([
+ 'optical',
+ 'crt',
+ 'nvg',
+ 'thermal',
+ 'noir',
+]);
const TARGET_STYLE_BY_MODE = Object.freeze({
crt: 'retro',
diff --git a/src/ui/cockpitBriefing.js b/src/ui/cockpitBriefing.js
index ae777e377..d6f222cfc 100644
--- a/src/ui/cockpitBriefing.js
+++ b/src/ui/cockpitBriefing.js
@@ -1,150 +1,199 @@
/** Regional briefing requests, pages and rotation for the Cockpit controller. */
-import { COCKPIT_BRIEF_ROTATE_MS, COCKPIT_BRIEF_CYCLE_OFF_HELP, COCKPIT_BRIEF_CYCLE_ON_HELP, COCKPIT_REGIONAL_REFRESH_MS, COCKPIT_REGIONAL_REFRESH_DISTANCE_M, COCKPIT_BRIEF_PAGES, formatCockpitBriefAge, formatCockpitWindDirection } from './cockpitPresentation.js';
+import {
+ COCKPIT_BRIEF_ROTATE_MS,
+ COCKPIT_BRIEF_CYCLE_OFF_HELP,
+ COCKPIT_BRIEF_CYCLE_ON_HELP,
+ COCKPIT_REGIONAL_REFRESH_MS,
+ COCKPIT_REGIONAL_REFRESH_DISTANCE_M,
+ COCKPIT_BRIEF_PAGES,
+ formatCockpitBriefAge,
+ formatCockpitWindDirection,
+} from './cockpitPresentation.js';
export function showBriefPage(index, { manual = false } = {}) {
- const count = COCKPIT_BRIEF_PAGES.length;
- this.briefPageIndex = ((Number(index) % count) + count) % count;
- const page = COCKPIT_BRIEF_PAGES[this.briefPageIndex];
- this.briefPages.forEach((element) => {
- element.hidden = element.dataset.cockpitBriefPage !== page.id;
- });
- this.briefTabs.forEach((button) => {
- const current = Number(button.dataset.cockpitBriefIndex) === this.briefPageIndex;
- button.setAttribute('aria-current', current ? 'true' : 'false');
- });
- if (this.briefKicker) {
- const indicator = this.briefKicker.querySelector('i');
- this.briefKicker.replaceChildren(...[indicator, document.createTextNode(` ${page.kicker}`)].filter(Boolean));
- }
- if (this.briefSubtitle) this.briefSubtitle.textContent = page.subtitle;
- if (this.briefPosition) this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`;
- if (this.briefSource) this.briefSource.textContent = page.source;
- if (this.signalStream) this.signalStream.dataset.briefPage = page.id;
- if (manual && this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true });
- this.scheduleContextLayout();
+ const count = COCKPIT_BRIEF_PAGES.length;
+ this.briefPageIndex = ((Number(index) % count) + count) % count;
+ const page = COCKPIT_BRIEF_PAGES[this.briefPageIndex];
+ this.briefPages.forEach((element) => {
+ element.hidden = element.dataset.cockpitBriefPage !== page.id;
+ });
+ this.briefTabs.forEach((button) => {
+ const current =
+ Number(button.dataset.cockpitBriefIndex) === this.briefPageIndex;
+ button.setAttribute('aria-current', current ? 'true' : 'false');
+ });
+ if (this.briefKicker) {
+ const indicator = this.briefKicker.querySelector('i');
+ this.briefKicker.replaceChildren(
+ ...[indicator, document.createTextNode(` ${page.kicker}`)].filter(
+ Boolean,
+ ),
+ );
}
+ if (this.briefSubtitle) this.briefSubtitle.textContent = page.subtitle;
+ if (this.briefPosition)
+ this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`;
+ if (this.briefSource) this.briefSource.textContent = page.source;
+ if (this.signalStream) this.signalStream.dataset.briefPage = page.id;
+ if (manual && this.briefAutoRotateEnabled)
+ this.startBriefRotation({ reset: true });
+ this.scheduleContextLayout();
+}
export function setBriefAutoRotate(enabled) {
- this.briefAutoRotateEnabled = Boolean(enabled);
- if (this.briefAutoToggle) {
- this.briefAutoToggle.setAttribute('aria-pressed', String(this.briefAutoRotateEnabled));
- const label = this.briefAutoRotateEnabled ? 'CYCLE ON' : 'CYCLE OFF';
- this.briefAutoToggle.textContent = label;
- const help = this.briefAutoRotateEnabled
- ? COCKPIT_BRIEF_CYCLE_ON_HELP
- : COCKPIT_BRIEF_CYCLE_OFF_HELP;
- this.briefAutoToggle.setAttribute('aria-label', label);
- this.briefAutoToggle.title = help;
- }
- if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true });
- else this.stopBriefRotation();
+ this.briefAutoRotateEnabled = Boolean(enabled);
+ if (this.briefAutoToggle) {
+ this.briefAutoToggle.setAttribute(
+ 'aria-pressed',
+ String(this.briefAutoRotateEnabled),
+ );
+ const label = this.briefAutoRotateEnabled ? 'CYCLE ON' : 'CYCLE OFF';
+ this.briefAutoToggle.textContent = label;
+ const help = this.briefAutoRotateEnabled
+ ? COCKPIT_BRIEF_CYCLE_ON_HELP
+ : COCKPIT_BRIEF_CYCLE_OFF_HELP;
+ this.briefAutoToggle.setAttribute('aria-label', label);
+ this.briefAutoToggle.title = help;
}
+ if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true });
+ else this.stopBriefRotation();
+}
export function startBriefRotation({ reset = false } = {}) {
- if (reset) this.stopBriefRotation();
- if (!this.briefAutoRotateEnabled
- || this.briefTimer
- || !this.active
- || this.signalCollapsed
- || document.hidden) return;
- this.briefTimer = window.setTimeout(() => {
- this.briefTimer = null;
- const hasPointer = this.signalStream?.matches(':hover') === true;
- const hasFocus = this.signalStream?.contains(document.activeElement) === true;
- const isInteracting = hasPointer || hasFocus;
- if (!isInteracting) {
- this.showBriefPage(this.briefPageIndex + 1);
- }
- this.startBriefRotation();
- }, COCKPIT_BRIEF_ROTATE_MS);
- }
+ if (reset) this.stopBriefRotation();
+ if (
+ !this.briefAutoRotateEnabled ||
+ this.briefTimer ||
+ !this.active ||
+ this.signalCollapsed ||
+ document.hidden
+ )
+ return;
+ this.briefTimer = window.setTimeout(() => {
+ this.briefTimer = null;
+ const hasPointer = this.signalStream?.matches(':hover') === true;
+ const hasFocus =
+ this.signalStream?.contains(document.activeElement) === true;
+ const isInteracting = hasPointer || hasFocus;
+ if (!isInteracting) {
+ this.showBriefPage(this.briefPageIndex + 1);
+ }
+ this.startBriefRotation();
+ }, COCKPIT_BRIEF_ROTATE_MS);
+}
export function stopBriefRotation() {
- if (this.briefTimer) window.clearTimeout(this.briefTimer);
- this.briefTimer = null;
- }
+ if (this.briefTimer) window.clearTimeout(this.briefTimer);
+ this.briefTimer = null;
+}
export function updateLocalPosition(info) {
- if (!this.localCoordinates) return;
- if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) {
- this.localCoordinates.textContent = 'POSITION UNAVAILABLE';
- return;
- }
- const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`;
- const lon = `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`;
- this.localCoordinates.textContent = `${lat} · ${lon}`;
+ if (!this.localCoordinates) return;
+ if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) {
+ this.localCoordinates.textContent = 'POSITION UNAVAILABLE';
+ return;
}
+ const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`;
+ const lon = `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`;
+ this.localCoordinates.textContent = `${lat} · ${lon}`;
+}
export function maybeRefreshRegionalBrief(info) {
- if (!this.active || !Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) return;
- const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.registration || info.callsign || 'unknown'}`;
- if (subjectId !== this.regionalBriefSubjectId) {
- this.regionalBriefAbort?.abort();
- this.regionalBriefAbort = null;
- this.regionalBriefRequestToken += 1;
- this.regionalBriefSubjectId = subjectId;
- this.regionalBrief = null;
- this.regionalBriefAnchor = null;
- this.regionalBriefFetchedAt = 0;
- }
- const point = { latitude: info.latitude, longitude: info.longitude };
- const ageMs = Date.now() - this.regionalBriefFetchedAt;
- const distanceM = this.services.regionalDistanceM(this.regionalBriefAnchor, point);
- if (this.regionalBriefAbort || (ageMs < COCKPIT_REGIONAL_REFRESH_MS
- && distanceM < COCKPIT_REGIONAL_REFRESH_DISTANCE_M)) return;
-
- this.regionalBriefAnchor = point;
- this.regionalBriefFetchedAt = Date.now();
- const controller = new AbortController();
- const requestToken = ++this.regionalBriefRequestToken;
- this.regionalBriefAbort = controller;
- if (!this.regionalBrief) this.renderRegionalBriefStatus('loading', info);
- this.services.fetchRegionalBrief(point.latitude, point.longitude, { signal: controller.signal })
- .then((payload) => {
- if (!this.active
- || requestToken !== this.regionalBriefRequestToken
- || subjectId !== this.regionalBriefSubjectId) return;
- this.regionalBrief = payload;
- this.renderRegionalBrief(payload, info);
- })
- .catch((error) => {
- if (error?.name !== 'AbortError'
- && this.active
- && requestToken === this.regionalBriefRequestToken
- && subjectId === this.regionalBriefSubjectId) {
- this.renderRegionalBriefStatus('unavailable', info);
- }
- })
- .finally(() => {
- if (this.regionalBriefAbort === controller) this.regionalBriefAbort = null;
- });
+ if (
+ !this.active ||
+ !Number.isFinite(info.latitude) ||
+ !Number.isFinite(info.longitude)
+ )
+ return;
+ const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.registration || info.callsign || 'unknown'}`;
+ if (subjectId !== this.regionalBriefSubjectId) {
+ this.regionalBriefAbort?.abort();
+ this.regionalBriefAbort = null;
+ this.regionalBriefRequestToken += 1;
+ this.regionalBriefSubjectId = subjectId;
+ this.regionalBrief = null;
+ this.regionalBriefAnchor = null;
+ this.regionalBriefFetchedAt = 0;
}
+ const point = { latitude: info.latitude, longitude: info.longitude };
+ const ageMs = Date.now() - this.regionalBriefFetchedAt;
+ const distanceM = this.services.regionalDistanceM(
+ this.regionalBriefAnchor,
+ point,
+ );
+ if (
+ this.regionalBriefAbort ||
+ (ageMs < COCKPIT_REGIONAL_REFRESH_MS &&
+ distanceM < COCKPIT_REGIONAL_REFRESH_DISTANCE_M)
+ )
+ return;
+
+ this.regionalBriefAnchor = point;
+ this.regionalBriefFetchedAt = Date.now();
+ const controller = new AbortController();
+ const requestToken = ++this.regionalBriefRequestToken;
+ this.regionalBriefAbort = controller;
+ if (!this.regionalBrief) this.renderRegionalBriefStatus('loading', info);
+ this.services
+ .fetchRegionalBrief(point.latitude, point.longitude, {
+ signal: controller.signal,
+ })
+ .then((payload) => {
+ if (
+ !this.active ||
+ requestToken !== this.regionalBriefRequestToken ||
+ subjectId !== this.regionalBriefSubjectId
+ )
+ return;
+ this.regionalBrief = payload;
+ this.renderRegionalBrief(payload, info);
+ })
+ .catch((error) => {
+ if (
+ error?.name !== 'AbortError' &&
+ this.active &&
+ requestToken === this.regionalBriefRequestToken &&
+ subjectId === this.regionalBriefSubjectId
+ ) {
+ this.renderRegionalBriefStatus('unavailable', info);
+ }
+ })
+ .finally(() => {
+ if (this.regionalBriefAbort === controller)
+ this.regionalBriefAbort = null;
+ });
+}
export function renderRegionalBriefStatus(status, info) {
- if (this.newsStatus) {
- this.newsStatus.hidden = false;
- this.newsStatus.dataset.state = status;
- this.newsStatus.textContent = status === 'loading'
+ if (this.newsStatus) {
+ this.newsStatus.hidden = false;
+ this.newsStatus.dataset.state = status;
+ this.newsStatus.textContent =
+ status === 'loading'
? 'ACQUIRING REGIONAL NEWS'
: 'REGIONAL NEWS UNAVAILABLE';
- }
- if (status === 'unavailable') this.newsList?.replaceChildren();
- if (this.localPlace && status === 'loading') this.localPlace.textContent = 'RESOLVING REGION';
- if (this.localPlace && status === 'unavailable') this.localPlace.textContent = 'REGION UNAVAILABLE';
- this.updateLocalPosition(info);
}
+ if (status === 'unavailable') this.newsList?.replaceChildren();
+ if (this.localPlace && status === 'loading')
+ this.localPlace.textContent = 'RESOLVING REGION';
+ if (this.localPlace && status === 'unavailable')
+ this.localPlace.textContent = 'REGION UNAVAILABLE';
+ this.updateLocalPosition(info);
+}
export function renderRegionalBrief(payload, info) {
- const articles = Array.isArray(payload?.articles) ? payload.articles : [];
- if (this.newsStatus) {
- this.newsStatus.hidden = articles.length > 0;
- this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable';
- this.newsStatus.textContent = payload?.newsStatus === 'empty'
+ const articles = Array.isArray(payload?.articles) ? payload.articles : [];
+ if (this.newsStatus) {
+ this.newsStatus.hidden = articles.length > 0;
+ this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable';
+ this.newsStatus.textContent =
+ payload?.newsStatus === 'empty'
? 'NO RECENT LOCATION MATCHES'
: 'REGIONAL NEWS UNAVAILABLE';
- }
- if (this.newsList) {
- this.newsList.replaceChildren(...articles.slice(0, 4).map((article) => {
+ }
+ if (this.newsList) {
+ this.newsList.replaceChildren(
+ ...articles.slice(0, 4).map((article) => {
const entry = document.createElement('li');
const link = document.createElement('a');
link.href = article.url;
@@ -157,36 +206,50 @@ export function renderRegionalBrief(payload, info) {
link.append(title, metadata);
entry.append(link);
return entry;
- }));
- }
-
- const placeLabel = payload?.place?.label || payload?.place?.country || 'REGION UNAVAILABLE';
- if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase();
- this.updateLocalPosition(info);
- const weather = payload?.weather;
- if (this.localTemperature) {
- this.localTemperature.textContent = Number.isFinite(weather?.temperatureC)
- ? `${Math.round(weather.temperatureC)}°C` : '—';
- }
- if (this.localWind) {
- this.localWind.textContent = Number.isFinite(weather?.windKph)
- ? `${Math.round(weather.windKph)} KM/H` : '—';
- }
- if (this.localWindDirection) {
- this.localWindDirection.textContent = formatCockpitWindDirection(weather?.windDirectionDeg);
- }
- if (this.localCondition) this.localCondition.textContent = this.services.weatherCodeLabel(weather?.weatherCode);
- if (this.localCloud) {
- this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct)
- ? `CLOUD ${Math.round(weather.cloudCoverPct)}%` : 'CLOUD UNKNOWN';
- }
- if (this.localPrecipitation) {
- this.localPrecipitation.textContent = Number.isFinite(weather?.precipitationMm)
- ? weather.precipitationMm.toFixed(1) : '—';
- }
- if (this.signalStream) this.signalStream.dataset.regionalStatus = payload?.status || 'partial';
- if (this.briefPageIndex === 1 && this.briefSource) {
- this.briefSource.textContent = `${String(payload?.newsSource || 'REGIONAL NEWS').toUpperCase()} · LOCATION QUERY`;
- }
- this.scheduleContextLayout();
+ }),
+ );
}
+
+ const placeLabel =
+ payload?.place?.label || payload?.place?.country || 'REGION UNAVAILABLE';
+ if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase();
+ this.updateLocalPosition(info);
+ const weather = payload?.weather;
+ if (this.localTemperature) {
+ this.localTemperature.textContent = Number.isFinite(weather?.temperatureC)
+ ? `${Math.round(weather.temperatureC)}°C`
+ : '—';
+ }
+ if (this.localWind) {
+ this.localWind.textContent = Number.isFinite(weather?.windKph)
+ ? `${Math.round(weather.windKph)} KM/H`
+ : '—';
+ }
+ if (this.localWindDirection) {
+ this.localWindDirection.textContent = formatCockpitWindDirection(
+ weather?.windDirectionDeg,
+ );
+ }
+ if (this.localCondition)
+ this.localCondition.textContent = this.services.weatherCodeLabel(
+ weather?.weatherCode,
+ );
+ if (this.localCloud) {
+ this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct)
+ ? `CLOUD ${Math.round(weather.cloudCoverPct)}%`
+ : 'CLOUD UNKNOWN';
+ }
+ if (this.localPrecipitation) {
+ this.localPrecipitation.textContent = Number.isFinite(
+ weather?.precipitationMm,
+ )
+ ? weather.precipitationMm.toFixed(1)
+ : '—';
+ }
+ if (this.signalStream)
+ this.signalStream.dataset.regionalStatus = payload?.status || 'partial';
+ if (this.briefPageIndex === 1 && this.briefSource) {
+ this.briefSource.textContent = `${String(payload?.newsSource || 'REGIONAL NEWS').toUpperCase()} · LOCATION QUERY`;
+ }
+ this.scheduleContextLayout();
+}
diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js
index 8071fd2b3..c33ba3c15 100644
--- a/src/ui/cockpitCamera.js
+++ b/src/ui/cockpitCamera.js
@@ -1,225 +1,360 @@
/** Camera placement, ground acquisition and motion correction for the Cockpit controller. */
import * as Cesium from 'cesium';
-import { cockpitAnchorCorrectionStep, cockpitGroundSafeHeight, cockpitSurfaceWaitExpired, cockpitUiUpdateDue, slewHeading } from '../cockpitMath.js';
-import { COCKPIT_HEADING_SLEW_DPS, COCKPIT_FORWARD_OFFSET_M, COCKPIT_UP_OFFSET_M, COCKPIT_MIN_GROUND_CLEARANCE_M, COCKPIT_VIEW_PITCH_DEG, COCKPIT_CAMERA_UPDATE_MS, COCKPIT_HUD_UPDATE_MS, COCKPIT_GROUND_PROBE_MS, COCKPIT_GROUND_WAIT_TIMEOUT_MS } from './cockpitPresentation.js';
+import {
+ cockpitAnchorCorrectionStep,
+ cockpitGroundSafeHeight,
+ cockpitSurfaceWaitExpired,
+ cockpitUiUpdateDue,
+ slewHeading,
+} from '../cockpitMath.js';
+import {
+ COCKPIT_HEADING_SLEW_DPS,
+ COCKPIT_FORWARD_OFFSET_M,
+ COCKPIT_UP_OFFSET_M,
+ COCKPIT_MIN_GROUND_CLEARANCE_M,
+ COCKPIT_VIEW_PITCH_DEG,
+ COCKPIT_CAMERA_UPDATE_MS,
+ COCKPIT_HUD_UPDATE_MS,
+ COCKPIT_GROUND_PROBE_MS,
+ COCKPIT_GROUND_WAIT_TIMEOUT_MS,
+} from './cockpitPresentation.js';
export function update() {
- if (this.destroyed) return false;
- if (!this.active) {
- // Entry availability changes on a human timescale (tracking start/stop,
- // info arriving after a poll) — polling it every rendered frame ran
- // readAircraftInfo() + DOM pokes at display rate in plain map mode.
- // 250 ms keeps the chip imperceptibly fresh; trackedEntityChanged still
- // fires syncEntry immediately on the events that matter. (perf item 9)
- const nowMs = performance.now();
- if (nowMs - (this._lastEntrySyncMs || 0) >= 250) {
- this._lastEntrySyncMs = nowMs;
- this.syncEntry();
- }
- return;
- }
-
+ if (this.destroyed) return false;
+ if (!this.active) {
+ // Entry availability changes on a human timescale (tracking start/stop,
+ // info arriving after a poll) — polling it every rendered frame ran
+ // readAircraftInfo() + DOM pokes at display rate in plain map mode.
+ // 250 ms keeps the chip imperceptibly fresh; trackedEntityChanged still
+ // fires syncEntry immediately on the events that matter. (perf item 9)
const nowMs = performance.now();
- const info = this.readAircraftInfo();
-
- // Adopt a newly selected track before the camera cadence gate. A context
- // NEXT/PREV selection can otherwise spend one frame driving the old
- // aircraft with the new aircraft's metadata.
- this._adoptTrackedEntity(nowMs, info);
- if (!info || !this.trackedEntity || !this.viewer.entities.contains(this.trackedEntity)) {
- if (nowMs < this.contextNavigationDeadlineMs) return;
- this.exit({ restoreTracking: false });
- return;
+ if (nowMs - (this._lastEntrySyncMs || 0) >= 250) {
+ this._lastEntrySyncMs = nowMs;
+ this.syncEntry();
}
- if (!cockpitUiUpdateDue(nowMs, this.lastCameraUpdateMs, COCKPIT_CAMERA_UPDATE_MS)) return;
- this.lastCameraUpdateMs = nowMs;
+ return;
+ }
- const target = this.trackedEntity.position.getValue(this.viewer.clock.currentTime, this.scratchTarget);
- if (!target) return;
- const dtSec = Math.min(0.1, Math.max(0, (nowMs - this.lastFrameMs) / 1000));
- this.lastFrameMs = nowMs;
- if (Number.isFinite(info.track)) {
- this.heading = slewHeading(
- this.heading ?? info.track, info.track, COCKPIT_HEADING_SLEW_DPS * dtSec,
- );
- }
+ const nowMs = performance.now();
+ const info = this.readAircraftInfo();
- if (!this.cockpitAnchorValid) {
- Cesium.Cartesian3.clone(target, this.cockpitAnchor);
- this.cockpitAnchorValid = true;
- }
+ // Adopt a newly selected track before the camera cadence gate. A context
+ // NEXT/PREV selection can otherwise spend one frame driving the old
+ // aircraft with the new aircraft's metadata.
+ this._adoptTrackedEntity(nowMs, info);
+ if (
+ !info ||
+ !this.trackedEntity ||
+ !this.viewer.entities.contains(this.trackedEntity)
+ ) {
+ if (nowMs < this.contextNavigationDeadlineMs) return;
+ this.exit({ restoreTracking: false });
+ return;
+ }
+ if (
+ !cockpitUiUpdateDue(
+ nowMs,
+ this.lastCameraUpdateMs,
+ COCKPIT_CAMERA_UPDATE_MS,
+ )
+ )
+ return;
+ this.lastCameraUpdateMs = nowMs;
- // First-person motion cannot use the delayed feed correction as a raw
- // camera destination: a harmless icon re-anchor becomes a whole-world
- // surge/reversal in cockpit view. Advance the camera anchor inertially
- // from the reported course/speed and converge on the authoritative layer
- // display position at a bounded rate. This preserves the layer's required
- // 15/30-second interpolation and per-frame cache without exposing its
- // sample-boundary corrections to the camera.
- const headingRad = Cesium.Math.toRadians(this.heading ?? 0);
- const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG);
- const speedMps = Number.isFinite(info.velocityMps) ? Math.max(0, info.velocityMps) : 0;
-
- if (info.stale) {
- // A feed backoff has no authoritative velocity epoch to advance from.
- // Hold the cockpit on the exact layer-rendered position so the camera
- // cannot coast away while the icon correctly remains fixed.
- Cesium.Cartesian3.clone(target, this.cockpitAnchor);
- } else {
- Cesium.Transforms.eastNorthUpToFixedFrame(
- this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchEnu,
- );
- this.scratchLocal.x = Math.sin(headingRad);
- this.scratchLocal.y = Math.cos(headingRad);
- this.scratchLocal.z = 0;
- Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchHorizontal);
- Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal);
- Cesium.Cartesian3.multiplyByScalar(
- this.scratchHorizontal, speedMps * dtSec, this.scratchAdvance,
- );
- Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchAdvance, this.cockpitAnchor);
- Cesium.Cartesian3.subtract(target, this.cockpitAnchor, this.scratchCorrection);
- const correctionDistanceM = Cesium.Cartesian3.magnitude(this.scratchCorrection);
- const correctionStepM = cockpitAnchorCorrectionStep(correctionDistanceM, speedMps, dtSec);
- if (correctionStepM > 0 && correctionDistanceM > 0) {
- Cesium.Cartesian3.multiplyByScalar(
- this.scratchCorrection, correctionStepM / correctionDistanceM, this.scratchCorrection,
- );
- Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchCorrection, this.cockpitAnchor);
- }
- }
-
- // The inertial anchor is independent of the layer's render-floor clamp and
- // can otherwise coast into a photoreal mesh while a landing contact is
- // between fixes. Clamp it against the same mesh-first shared floor used by
- // aircraft rendering. For a slow contact whose floor cell is still cold,
- // its already-clamped render position is a conservative temporary floor.
- const anchorCartographic = Cesium.Cartographic.fromCartesian(
- this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchAnchorCartographic,
+ const target = this.trackedEntity.position.getValue(
+ this.viewer.clock.currentTime,
+ this.scratchTarget,
+ );
+ if (!target) return;
+ const dtSec = Math.min(0.1, Math.max(0, (nowMs - this.lastFrameMs) / 1000));
+ this.lastFrameMs = nowMs;
+ if (Number.isFinite(info.track)) {
+ this.heading = slewHeading(
+ this.heading ?? info.track,
+ info.track,
+ COCKPIT_HEADING_SLEW_DPS * dtSec,
);
- const targetCartographic = Cesium.Cartographic.fromCartesian(
- target, Cesium.Ellipsoid.WGS84, this.scratchTargetCartographic,
- );
- let cockpitFloorM = this.services.cachedGroundFloor(info.latitude, info.longitude);
- if (info.onGround === true) {
- const groundPoint = [{ lat: info.latitude, lon: info.longitude }];
- this.services.warmGroundFloor(groundPoint);
- const meshFloorM = this.services.cachedMeshFloor(info.latitude, info.longitude);
- if (this.services.meshFloorPreferred() && !Number.isFinite(meshFloorM)) {
- if (cockpitUiUpdateDue(nowMs, this.lastGroundProbeMs, COCKPIT_GROUND_PROBE_MS)) {
- this.lastGroundProbeMs = nowMs;
- const viewerCartographic = this.viewer.camera.positionCartographic;
- this.services.sampleMeshFloorCells(this.viewer.scene, groundPoint, {
- excludeObjects: [this.trackedEntity],
- viewerLat: Cesium.Math.toDegrees(viewerCartographic.latitude),
- viewerLon: Cesium.Math.toDegrees(viewerCartographic.longitude),
- });
- }
- cockpitFloorM = this.services.cachedMeshFloor(info.latitude, info.longitude);
- if (!Number.isFinite(cockpitFloorM)) {
- if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs;
- if (!cockpitSurfaceWaitExpired(nowMs, this.surfaceWaitStartedMs, COCKPIT_GROUND_WAIT_TIMEOUT_MS)) {
- // Keep the already-safe map camera in place while the photoreal
- // surface under a parked aircraft is acquired. The bounded wait
- // prevents a permanently cold mesh cell from freezing cockpit.
- this.surfaceAcquiring = true;
- this.surfaceFallback = false;
- if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) {
- this.lastHudUpdateMs = nowMs;
- this.updateHud(info, nowMs);
- }
- return;
- }
- this.surfaceAcquiring = false;
- this.surfaceFallback = true;
- } else {
- this.surfaceWaitStartedMs = 0;
- this.surfaceAcquiring = false;
- this.surfaceFallback = false;
- }
- }
- } else {
- this.surfaceWaitStartedMs = 0;
- this.surfaceAcquiring = false;
- this.surfaceFallback = false;
- }
- if (!Number.isFinite(cockpitFloorM)
- && speedMps < 90
- && Number.isFinite(targetCartographic?.height)) {
- cockpitFloorM = targetCartographic.height - this.services.GROUND_FLOOR_LIFT_M;
- }
- if (anchorCartographic && Number.isFinite(cockpitFloorM)) {
- const minimumAnchorHeightM = cockpitGroundSafeHeight(
- anchorCartographic.height,
- cockpitFloorM,
- COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M,
- );
- if (minimumAnchorHeightM !== anchorCartographic.height) {
- anchorCartographic.height = minimumAnchorHeightM;
- Cesium.Ellipsoid.WGS84.cartographicToCartesian(anchorCartographic, this.cockpitAnchor);
- }
- }
+ }
- // Rebuild the local frame at the stabilized anchor after advancing it.
+ if (!this.cockpitAnchorValid) {
+ Cesium.Cartesian3.clone(target, this.cockpitAnchor);
+ this.cockpitAnchorValid = true;
+ }
+
+ // First-person motion cannot use the delayed feed correction as a raw
+ // camera destination: a harmless icon re-anchor becomes a whole-world
+ // surge/reversal in cockpit view. Advance the camera anchor inertially
+ // from the reported course/speed and converge on the authoritative layer
+ // display position at a bounded rate. This preserves the layer's required
+ // 15/30-second interpolation and per-frame cache without exposing its
+ // sample-boundary corrections to the camera.
+ const headingRad = Cesium.Math.toRadians(this.heading ?? 0);
+ const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG);
+ const speedMps = Number.isFinite(info.velocityMps)
+ ? Math.max(0, info.velocityMps)
+ : 0;
+
+ if (info.stale) {
+ // A feed backoff has no authoritative velocity epoch to advance from.
+ // Hold the cockpit on the exact layer-rendered position so the camera
+ // cannot coast away while the icon correctly remains fixed.
+ Cesium.Cartesian3.clone(target, this.cockpitAnchor);
+ } else {
Cesium.Transforms.eastNorthUpToFixedFrame(
- this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchEnu,
+ this.cockpitAnchor,
+ Cesium.Ellipsoid.WGS84,
+ this.scratchEnu,
);
this.scratchLocal.x = Math.sin(headingRad);
this.scratchLocal.y = Math.cos(headingRad);
this.scratchLocal.z = 0;
- Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchHorizontal);
+ Cesium.Matrix4.multiplyByPointAsVector(
+ this.scratchEnu,
+ this.scratchLocal,
+ this.scratchHorizontal,
+ );
Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal);
-
- this.scratchLocal.x = Math.sin(headingRad) * Math.cos(pitchRad);
- this.scratchLocal.y = Math.cos(headingRad) * Math.cos(pitchRad);
- this.scratchLocal.z = Math.sin(pitchRad);
- Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchForward);
- Cesium.Cartesian3.normalize(this.scratchForward, this.scratchForward);
-
- this.scratchLocal.x = -Math.sin(headingRad) * Math.sin(pitchRad);
- this.scratchLocal.y = -Math.cos(headingRad) * Math.sin(pitchRad);
- this.scratchLocal.z = Math.cos(pitchRad);
- Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchUp);
- Cesium.Cartesian3.normalize(this.scratchUp, this.scratchUp);
-
Cesium.Cartesian3.multiplyByScalar(
- this.scratchHorizontal, COCKPIT_FORWARD_OFFSET_M, this.scratchCamera,
+ this.scratchHorizontal,
+ speedMps * dtSec,
+ this.scratchAdvance,
);
- Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchCamera, this.scratchCamera);
- Cesium.Matrix4.getTranslation(this.scratchEnu, this.scratchTarget);
- Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget);
- Cesium.Cartesian3.multiplyByScalar(this.scratchTarget, COCKPIT_UP_OFFSET_M, this.scratchTarget);
- Cesium.Cartesian3.add(this.scratchCamera, this.scratchTarget, this.scratchCamera);
-
- // Recheck at the final forward-offset camera coordinate because a taxiing
- // aircraft can cross into an adjacent coarse floor cell between updates.
- const cameraCartographic = Cesium.Cartographic.fromCartesian(
- this.scratchCamera, Cesium.Ellipsoid.WGS84, this.scratchCameraCartographic,
+ Cesium.Cartesian3.add(
+ this.cockpitAnchor,
+ this.scratchAdvance,
+ this.cockpitAnchor,
);
- if (cameraCartographic) {
- const cameraLat = Cesium.Math.toDegrees(cameraCartographic.latitude);
- const cameraLon = Cesium.Math.toDegrees(cameraCartographic.longitude);
- const cameraFloorM = this.services.cachedGroundFloor(cameraLat, cameraLon);
- if (Number.isFinite(cameraFloorM)) {
- cockpitFloorM = Math.max(cockpitFloorM ?? Number.NEGATIVE_INFINITY, cameraFloorM);
- }
- const safeHeightM = cockpitGroundSafeHeight(
- cameraCartographic.height,
- cockpitFloorM,
- COCKPIT_MIN_GROUND_CLEARANCE_M,
+ Cesium.Cartesian3.subtract(
+ target,
+ this.cockpitAnchor,
+ this.scratchCorrection,
+ );
+ const correctionDistanceM = Cesium.Cartesian3.magnitude(
+ this.scratchCorrection,
+ );
+ const correctionStepM = cockpitAnchorCorrectionStep(
+ correctionDistanceM,
+ speedMps,
+ dtSec,
+ );
+ if (correctionStepM > 0 && correctionDistanceM > 0) {
+ Cesium.Cartesian3.multiplyByScalar(
+ this.scratchCorrection,
+ correctionStepM / correctionDistanceM,
+ this.scratchCorrection,
+ );
+ Cesium.Cartesian3.add(
+ this.cockpitAnchor,
+ this.scratchCorrection,
+ this.cockpitAnchor,
);
- if (safeHeightM !== cameraCartographic.height) {
- cameraCartographic.height = safeHeightM;
- Cesium.Ellipsoid.WGS84.cartographicToCartesian(cameraCartographic, this.scratchCamera);
- }
- }
-
- this.viewer.camera.setView({
- destination: this.scratchCamera,
- orientation: { direction: this.scratchForward, up: this.scratchUp },
- });
- if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) {
- this.lastHudUpdateMs = nowMs;
- this.updateHud(info, nowMs);
}
}
+
+ // The inertial anchor is independent of the layer's render-floor clamp and
+ // can otherwise coast into a photoreal mesh while a landing contact is
+ // between fixes. Clamp it against the same mesh-first shared floor used by
+ // aircraft rendering. For a slow contact whose floor cell is still cold,
+ // its already-clamped render position is a conservative temporary floor.
+ const anchorCartographic = Cesium.Cartographic.fromCartesian(
+ this.cockpitAnchor,
+ Cesium.Ellipsoid.WGS84,
+ this.scratchAnchorCartographic,
+ );
+ const targetCartographic = Cesium.Cartographic.fromCartesian(
+ target,
+ Cesium.Ellipsoid.WGS84,
+ this.scratchTargetCartographic,
+ );
+ let cockpitFloorM = this.services.cachedGroundFloor(
+ info.latitude,
+ info.longitude,
+ );
+ if (info.onGround === true) {
+ const groundPoint = [{ lat: info.latitude, lon: info.longitude }];
+ this.services.warmGroundFloor(groundPoint);
+ const meshFloorM = this.services.cachedMeshFloor(
+ info.latitude,
+ info.longitude,
+ );
+ if (this.services.meshFloorPreferred() && !Number.isFinite(meshFloorM)) {
+ if (
+ cockpitUiUpdateDue(
+ nowMs,
+ this.lastGroundProbeMs,
+ COCKPIT_GROUND_PROBE_MS,
+ )
+ ) {
+ this.lastGroundProbeMs = nowMs;
+ const viewerCartographic = this.viewer.camera.positionCartographic;
+ this.services.sampleMeshFloorCells(this.viewer.scene, groundPoint, {
+ excludeObjects: [this.trackedEntity],
+ viewerLat: Cesium.Math.toDegrees(viewerCartographic.latitude),
+ viewerLon: Cesium.Math.toDegrees(viewerCartographic.longitude),
+ });
+ }
+ cockpitFloorM = this.services.cachedMeshFloor(
+ info.latitude,
+ info.longitude,
+ );
+ if (!Number.isFinite(cockpitFloorM)) {
+ if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs;
+ if (
+ !cockpitSurfaceWaitExpired(
+ nowMs,
+ this.surfaceWaitStartedMs,
+ COCKPIT_GROUND_WAIT_TIMEOUT_MS,
+ )
+ ) {
+ // Keep the already-safe map camera in place while the photoreal
+ // surface under a parked aircraft is acquired. The bounded wait
+ // prevents a permanently cold mesh cell from freezing cockpit.
+ this.surfaceAcquiring = true;
+ this.surfaceFallback = false;
+ if (
+ cockpitUiUpdateDue(
+ nowMs,
+ this.lastHudUpdateMs,
+ COCKPIT_HUD_UPDATE_MS,
+ )
+ ) {
+ this.lastHudUpdateMs = nowMs;
+ this.updateHud(info, nowMs);
+ }
+ return;
+ }
+ this.surfaceAcquiring = false;
+ this.surfaceFallback = true;
+ } else {
+ this.surfaceWaitStartedMs = 0;
+ this.surfaceAcquiring = false;
+ this.surfaceFallback = false;
+ }
+ }
+ } else {
+ this.surfaceWaitStartedMs = 0;
+ this.surfaceAcquiring = false;
+ this.surfaceFallback = false;
+ }
+ if (
+ !Number.isFinite(cockpitFloorM) &&
+ speedMps < 90 &&
+ Number.isFinite(targetCartographic?.height)
+ ) {
+ cockpitFloorM =
+ targetCartographic.height - this.services.GROUND_FLOOR_LIFT_M;
+ }
+ if (anchorCartographic && Number.isFinite(cockpitFloorM)) {
+ const minimumAnchorHeightM = cockpitGroundSafeHeight(
+ anchorCartographic.height,
+ cockpitFloorM,
+ COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M,
+ );
+ if (minimumAnchorHeightM !== anchorCartographic.height) {
+ anchorCartographic.height = minimumAnchorHeightM;
+ Cesium.Ellipsoid.WGS84.cartographicToCartesian(
+ anchorCartographic,
+ this.cockpitAnchor,
+ );
+ }
+ }
+
+ // Rebuild the local frame at the stabilized anchor after advancing it.
+ Cesium.Transforms.eastNorthUpToFixedFrame(
+ this.cockpitAnchor,
+ Cesium.Ellipsoid.WGS84,
+ this.scratchEnu,
+ );
+ this.scratchLocal.x = Math.sin(headingRad);
+ this.scratchLocal.y = Math.cos(headingRad);
+ this.scratchLocal.z = 0;
+ Cesium.Matrix4.multiplyByPointAsVector(
+ this.scratchEnu,
+ this.scratchLocal,
+ this.scratchHorizontal,
+ );
+ Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal);
+
+ this.scratchLocal.x = Math.sin(headingRad) * Math.cos(pitchRad);
+ this.scratchLocal.y = Math.cos(headingRad) * Math.cos(pitchRad);
+ this.scratchLocal.z = Math.sin(pitchRad);
+ Cesium.Matrix4.multiplyByPointAsVector(
+ this.scratchEnu,
+ this.scratchLocal,
+ this.scratchForward,
+ );
+ Cesium.Cartesian3.normalize(this.scratchForward, this.scratchForward);
+
+ this.scratchLocal.x = -Math.sin(headingRad) * Math.sin(pitchRad);
+ this.scratchLocal.y = -Math.cos(headingRad) * Math.sin(pitchRad);
+ this.scratchLocal.z = Math.cos(pitchRad);
+ Cesium.Matrix4.multiplyByPointAsVector(
+ this.scratchEnu,
+ this.scratchLocal,
+ this.scratchUp,
+ );
+ Cesium.Cartesian3.normalize(this.scratchUp, this.scratchUp);
+
+ Cesium.Cartesian3.multiplyByScalar(
+ this.scratchHorizontal,
+ COCKPIT_FORWARD_OFFSET_M,
+ this.scratchCamera,
+ );
+ Cesium.Cartesian3.add(
+ this.cockpitAnchor,
+ this.scratchCamera,
+ this.scratchCamera,
+ );
+ Cesium.Matrix4.getTranslation(this.scratchEnu, this.scratchTarget);
+ Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget);
+ Cesium.Cartesian3.multiplyByScalar(
+ this.scratchTarget,
+ COCKPIT_UP_OFFSET_M,
+ this.scratchTarget,
+ );
+ Cesium.Cartesian3.add(
+ this.scratchCamera,
+ this.scratchTarget,
+ this.scratchCamera,
+ );
+
+ // Recheck at the final forward-offset camera coordinate because a taxiing
+ // aircraft can cross into an adjacent coarse floor cell between updates.
+ const cameraCartographic = Cesium.Cartographic.fromCartesian(
+ this.scratchCamera,
+ Cesium.Ellipsoid.WGS84,
+ this.scratchCameraCartographic,
+ );
+ if (cameraCartographic) {
+ const cameraLat = Cesium.Math.toDegrees(cameraCartographic.latitude);
+ const cameraLon = Cesium.Math.toDegrees(cameraCartographic.longitude);
+ const cameraFloorM = this.services.cachedGroundFloor(cameraLat, cameraLon);
+ if (Number.isFinite(cameraFloorM)) {
+ cockpitFloorM = Math.max(
+ cockpitFloorM ?? Number.NEGATIVE_INFINITY,
+ cameraFloorM,
+ );
+ }
+ const safeHeightM = cockpitGroundSafeHeight(
+ cameraCartographic.height,
+ cockpitFloorM,
+ COCKPIT_MIN_GROUND_CLEARANCE_M,
+ );
+ if (safeHeightM !== cameraCartographic.height) {
+ cameraCartographic.height = safeHeightM;
+ Cesium.Ellipsoid.WGS84.cartographicToCartesian(
+ cameraCartographic,
+ this.scratchCamera,
+ );
+ }
+ }
+
+ this.viewer.camera.setView({
+ destination: this.scratchCamera,
+ orientation: { direction: this.scratchForward, up: this.scratchUp },
+ });
+ if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) {
+ this.lastHudUpdateMs = nowMs;
+ this.updateHud(info, nowMs);
+ }
+}
diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js
index b62445598..70dac99f7 100644
--- a/src/ui/cockpitContext.js
+++ b/src/ui/cockpitContext.js
@@ -1,134 +1,157 @@
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
import * as Cesium from 'cesium';
-import { bearingBetweenCoordinates, formatCockpitContextScope, relativeBearing, resolveCockpitContextReadout } from '../cockpitMath.js';
+import {
+ bearingBetweenCoordinates,
+ formatCockpitContextScope,
+ relativeBearing,
+ resolveCockpitContextReadout,
+} from '../cockpitMath.js';
export function updateContext(info, heading) {
- if (!this.context) return;
- 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',
- 'CONTEXT STANDBY',
- 'ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS',
- );
- return;
- }
-
- this.context.hidden = false;
- this.hud?.classList.add('context-active');
- if (this.contextSubject) {
- const installationCoverage = snapshot.cohorts
- .find((cohort) => cohort.id === 'military-installations')?.coverage;
- this.contextSubject.textContent = formatCockpitContextScope(
- snapshot.subject.label || trackedId,
- snapshot.radiusM,
- installationCoverage,
- );
- }
- // 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.
- if (this.contextPrevious) this.contextPrevious.disabled = !snapshot.navigation?.canPrevious;
- if (this.contextNext) this.contextNext.disabled = !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 = this.context.dataset.state !== 'lost';
- this.context.dataset.state = 'lost';
- if (this.contextUncertainty) {
- this.contextUncertainty.textContent = 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR';
- }
- // 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',
- `CONTACT LOST · ${snapshot.subject.label || snapshot.subject.id || 'SUBJECT'}`,
- 'SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN',
- );
- return;
- }
-
- let unknownCount = 0;
- const nearest = [];
- for (const cohort of snapshot.cohorts) {
- const element = this.contextCohorts.get(cohort.id);
- const value = element?.querySelector('strong');
- if (value) value.textContent = cohort.count === null ? '?' : String(cohort.count);
- element?.classList.toggle('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);
- if (this.contextNearestLabel) {
- this.contextNearestLabel.textContent = closest
- ? `${closest.cohort.label.toUpperCase()} · ${closestLabel}` : 'NO AVAILABLE EXAMPLE';
- this.contextNearestLabel.setAttribute(
- 'aria-label',
- closest && closestLabel === '—'
- ? `${closest.cohort.label}, Unavailable`
- : this.contextNearestLabel.textContent,
- );
- }
- if (this.contextDistance) {
- const distanceM = closest?.distanceM;
- this.contextDistance.textContent = Number.isFinite(distanceM)
- ? `${distanceM < 10000 ? (distanceM / 1000).toFixed(1) : Math.round(distanceM / 1000)} KM` : '—';
- this.contextDistance.setAttribute(
- 'aria-label',
- Number.isFinite(distanceM) ? this.contextDistance.textContent : '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);
- }
- if (this.contextDirection) {
- this.contextDirection.style.transform = `rotate(${relative ?? 0}deg)`;
- this.contextDirection.classList.toggle('unknown', relative === null);
- }
- if (this.contextBearing) {
- if (relative === null) this.contextBearing.textContent = 'BRG —';
- else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD';
- else this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`;
- }
- if (this.contextUncertainty) {
- this.contextUncertainty.textContent = unknownCount
- ? `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN · NOT AN ALL-CLEAR`
- : 'AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR';
- }
- if (this.contextUpdated) {
- this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt)
- ? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z' : '--:--:--Z';
- }
- this.context.dataset.state = unknownCount ? 'uncertain' : 'current';
- this.updateCockpitSignals(snapshot, unknownCount);
- if (this.contextLayoutStamp !== snapshot.evaluatedAt) {
- this.contextLayoutStamp = snapshot.evaluatedAt;
- this.scheduleContextLayout();
- }
+ if (!this.context) return;
+ 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',
+ 'CONTEXT STANDBY',
+ 'ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS',
+ );
+ return;
}
+
+ this.context.hidden = false;
+ this.hud?.classList.add('context-active');
+ if (this.contextSubject) {
+ const installationCoverage = snapshot.cohorts.find(
+ (cohort) => cohort.id === 'military-installations',
+ )?.coverage;
+ this.contextSubject.textContent = formatCockpitContextScope(
+ snapshot.subject.label || trackedId,
+ snapshot.radiusM,
+ installationCoverage,
+ );
+ }
+ // 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.
+ if (this.contextPrevious)
+ this.contextPrevious.disabled = !snapshot.navigation?.canPrevious;
+ if (this.contextNext)
+ this.contextNext.disabled = !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 = this.context.dataset.state !== 'lost';
+ this.context.dataset.state = 'lost';
+ if (this.contextUncertainty) {
+ this.contextUncertainty.textContent =
+ 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR';
+ }
+ // 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',
+ `CONTACT LOST · ${snapshot.subject.label || snapshot.subject.id || 'SUBJECT'}`,
+ 'SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN',
+ );
+ return;
+ }
+
+ let unknownCount = 0;
+ const nearest = [];
+ for (const cohort of snapshot.cohorts) {
+ const element = this.contextCohorts.get(cohort.id);
+ const value = element?.querySelector('strong');
+ if (value)
+ value.textContent = cohort.count === null ? '?' : String(cohort.count);
+ element?.classList.toggle('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);
+ if (this.contextNearestLabel) {
+ this.contextNearestLabel.textContent = closest
+ ? `${closest.cohort.label.toUpperCase()} · ${closestLabel}`
+ : 'NO AVAILABLE EXAMPLE';
+ this.contextNearestLabel.setAttribute(
+ 'aria-label',
+ closest && closestLabel === '—'
+ ? `${closest.cohort.label}, Unavailable`
+ : this.contextNearestLabel.textContent,
+ );
+ }
+ if (this.contextDistance) {
+ const distanceM = closest?.distanceM;
+ this.contextDistance.textContent = Number.isFinite(distanceM)
+ ? `${distanceM < 10000 ? (distanceM / 1000).toFixed(1) : Math.round(distanceM / 1000)} KM`
+ : '—';
+ this.contextDistance.setAttribute(
+ 'aria-label',
+ Number.isFinite(distanceM)
+ ? this.contextDistance.textContent
+ : '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);
+ }
+ if (this.contextDirection) {
+ this.contextDirection.style.transform = `rotate(${relative ?? 0}deg)`;
+ this.contextDirection.classList.toggle('unknown', relative === null);
+ }
+ if (this.contextBearing) {
+ if (relative === null) this.contextBearing.textContent = 'BRG —';
+ else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD';
+ else
+ this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`;
+ }
+ if (this.contextUncertainty) {
+ this.contextUncertainty.textContent = unknownCount
+ ? `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN · NOT AN ALL-CLEAR`
+ : 'AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR';
+ }
+ if (this.contextUpdated) {
+ this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt)
+ ? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z'
+ : '--:--:--Z';
+ }
+ this.context.dataset.state = unknownCount ? 'uncertain' : 'current';
+ this.updateCockpitSignals(snapshot, unknownCount);
+ if (this.contextLayoutStamp !== snapshot.evaluatedAt) {
+ this.contextLayoutStamp = snapshot.evaluatedAt;
+ this.scheduleContextLayout();
+ }
+}
diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js
index 48d09fb79..4f5cc33fc 100644
--- a/src/ui/cockpitController.js
+++ b/src/ui/cockpitController.js
@@ -1,25 +1,65 @@
/** Coordinate Cockpit lifecycle through supplied aircraft, terrain, rendering and briefing operations. */
-import { readAircraftInfo, dispatchCockpitModeChanged, toggleTrackedTr3b, syncTr3bToggle, syncEntry, navigateContext, _adoptTrackedEntity, enter, exit } from './cockpitTrackingController.js';
+import {
+ readAircraftInfo,
+ dispatchCockpitModeChanged,
+ toggleTrackedTr3b,
+ syncTr3bToggle,
+ syncEntry,
+ navigateContext,
+ _adoptTrackedEntity,
+ enter,
+ exit,
+} from './cockpitTrackingController.js';
import { update } from './cockpitCamera.js';
-import { updateHud, updateRoute, syncWeatherToggle, setVisionMode, cycleVisionMode, clearPredictiveRoute } from './cockpitInstruments.js';
+import {
+ updateHud,
+ updateRoute,
+ syncWeatherToggle,
+ setVisionMode,
+ cycleVisionMode,
+ clearPredictiveRoute,
+} from './cockpitInstruments.js';
import { updateContext } from './cockpitContext.js';
-import { showBriefPage, setBriefAutoRotate, startBriefRotation, stopBriefRotation, updateLocalPosition, maybeRefreshRegionalBrief, renderRegionalBriefStatus, renderRegionalBrief } from './cockpitBriefing.js';
-import { handleSignalClick, renderCockpitSignals, pushCockpitSignal, updateCockpitSignals } from './cockpitSignals.js';
-import { scheduleContextLayout, setContextCollapsed, setSignalCollapsed, syncContextLayout, syncSignalLayout } from './cockpitLayout.js';
+import {
+ showBriefPage,
+ setBriefAutoRotate,
+ startBriefRotation,
+ stopBriefRotation,
+ updateLocalPosition,
+ maybeRefreshRegionalBrief,
+ renderRegionalBriefStatus,
+ renderRegionalBrief,
+} from './cockpitBriefing.js';
+import {
+ handleSignalClick,
+ renderCockpitSignals,
+ pushCockpitSignal,
+ updateCockpitSignals,
+} from './cockpitSignals.js';
+import {
+ scheduleContextLayout,
+ setContextCollapsed,
+ setSignalCollapsed,
+ syncContextLayout,
+ syncSignalLayout,
+} from './cockpitLayout.js';
import { onKeyDown } from './cockpitInput.js';
import * as Cesium from 'cesium';
export class CockpitViewController {
- constructor(viewer, {
- services,
- onVisionChange = null,
- onCameraTakeover = null,
- isEntryAllowed = null,
- onEntered = null,
- onExited = null,
- getInheritedVisionLabel = null,
- restoreTrackingFrame = null,
- } = {}) {
+ constructor(
+ viewer,
+ {
+ services,
+ onVisionChange = null,
+ onCameraTakeover = null,
+ isEntryAllowed = null,
+ onEntered = null,
+ onExited = null,
+ getInheritedVisionLabel = null,
+ restoreTrackingFrame = null,
+ } = {},
+ ) {
this.viewer = viewer;
this.services = services;
this.destroyed = false;
@@ -48,11 +88,17 @@ export class CockpitViewController {
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.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.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');
@@ -63,33 +109,49 @@ export class CockpitViewController {
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.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.visionCurrentLabel = document.getElementById(
+ 'cockpit-vision-current-label',
+ );
this.visionNext = document.getElementById('cockpit-vision-next');
this.visionMode = 'optical';
this.onVisionChange = onVisionChange;
this.onCameraTakeover = onCameraTakeover;
this.onEntered = onEntered;
this.onExited = onExited;
- this.getInheritedVisionLabel = typeof getInheritedVisionLabel === 'function'
- ? getInheritedVisionLabel
- : () => 'NORMAL';
- this.restoreTrackingFrame = typeof restoreTrackingFrame === 'function'
- ? restoreTrackingFrame
- : () => false;
- this.isEntryAllowed = typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true;
+ this.getInheritedVisionLabel =
+ typeof getInheritedVisionLabel === 'function'
+ ? getInheritedVisionLabel
+ : () => 'NORMAL';
+ this.restoreTrackingFrame =
+ typeof restoreTrackingFrame === 'function'
+ ? restoreTrackingFrame
+ : () => false;
+ this.isEntryAllowed =
+ typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true;
this.context = document.getElementById('cockpit-context');
this.contextSubject = document.getElementById('cockpit-context-subject');
- this.contextNearestLabel = document.getElementById('cockpit-context-nearest-label');
+ this.contextNearestLabel = document.getElementById(
+ 'cockpit-context-nearest-label',
+ );
this.contextBearing = document.getElementById('cockpit-context-bearing');
this.contextDistance = document.getElementById('cockpit-context-distance');
- this.contextDirection = document.getElementById('cockpit-context-direction');
- this.contextUncertainty = document.getElementById('cockpit-context-uncertainty');
+ this.contextDirection = document.getElementById(
+ 'cockpit-context-direction',
+ );
+ this.contextUncertainty = document.getElementById(
+ 'cockpit-context-uncertainty',
+ );
this.contextUpdated = document.getElementById('cockpit-context-updated');
- this.contextCohorts = new Map(Array.from(document.querySelectorAll('[data-context-cohort]'))
- .map((element) => [element.dataset.contextCohort, element]));
+ this.contextCohorts = new Map(
+ Array.from(document.querySelectorAll('[data-context-cohort]')).map(
+ (element) => [element.dataset.contextCohort, element],
+ ),
+ );
this.contextPrevious = document.getElementById('cockpit-context-previous');
this.contextNext = document.getElementById('cockpit-context-next');
this.contextToggle = document.getElementById('cockpit-context-toggle');
@@ -106,18 +168,30 @@ export class CockpitViewController {
this.briefAutoToggle = document.getElementById('cockpit-brief-auto');
this.briefPosition = document.getElementById('cockpit-brief-position');
this.briefSource = document.getElementById('cockpit-brief-source');
- this.briefPages = Array.from(document.querySelectorAll('[data-cockpit-brief-page]'));
- this.briefTabs = Array.from(document.querySelectorAll('[data-cockpit-brief-index]'));
+ this.briefPages = Array.from(
+ document.querySelectorAll('[data-cockpit-brief-page]'),
+ );
+ this.briefTabs = Array.from(
+ document.querySelectorAll('[data-cockpit-brief-index]'),
+ );
this.newsStatus = document.getElementById('cockpit-news-status');
this.newsList = document.getElementById('cockpit-news-list');
this.localPlace = document.getElementById('cockpit-local-place');
- this.localCoordinates = document.getElementById('cockpit-local-coordinates');
- this.localTemperature = document.getElementById('cockpit-local-temperature');
+ this.localCoordinates = document.getElementById(
+ 'cockpit-local-coordinates',
+ );
+ this.localTemperature = document.getElementById(
+ 'cockpit-local-temperature',
+ );
this.localWind = document.getElementById('cockpit-local-wind');
- this.localWindDirection = document.getElementById('cockpit-local-wind-direction');
+ this.localWindDirection = document.getElementById(
+ 'cockpit-local-wind-direction',
+ );
this.localCondition = document.getElementById('cockpit-local-condition');
this.localCloud = document.getElementById('cockpit-local-cloud');
- this.localPrecipitation = document.getElementById('cockpit-local-precipitation');
+ this.localPrecipitation = document.getElementById(
+ 'cockpit-local-precipitation',
+ );
this.signalCollapsed = false;
this.signalUserCollapsed = false;
this.signalItems = [];
@@ -166,32 +240,50 @@ export class CockpitViewController {
this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1));
this._listen(this.visionCurrent, 'click', () => this.cycleVisionMode(1));
this._listen(this.visionNext, 'click', () => this.cycleVisionMode(1));
- this._listen(this.contextPrevious, 'click', () => this.navigateContext(-1, { origin: 'user' }));
- this._listen(this.contextNext, 'click', () => this.navigateContext(1, { origin: 'user' }));
- this._listen(this.contextToggle, 'click', () => this.setContextCollapsed(!this.contextCollapsed));
+ this._listen(this.contextPrevious, 'click', () =>
+ this.navigateContext(-1, { origin: 'user' }),
+ );
+ this._listen(this.contextNext, 'click', () =>
+ this.navigateContext(1, { origin: 'user' }),
+ );
+ this._listen(this.contextToggle, 'click', () =>
+ this.setContextCollapsed(!this.contextCollapsed),
+ );
this._listen(this.weatherToggle, 'click', () => {
- const enabled = this.weatherToggle.getAttribute('aria-pressed') !== 'true';
+ const enabled =
+ this.weatherToggle.getAttribute('aria-pressed') !== 'true';
this.syncWeatherToggle(enabled);
- window.dispatchEvent(new CustomEvent('gev:cockpit-weather-toggle', {
- detail: { enabled },
- }));
+ window.dispatchEvent(
+ new CustomEvent('gev:cockpit-weather-toggle', {
+ detail: { enabled },
+ }),
+ );
});
this._listen(window, 'gev:cockpit-weather-state', (event) => {
this.syncWeatherToggle(event?.detail?.enabled !== false);
});
- this._listen(this.signalToggle, 'click', () => this.setSignalCollapsed(
- !this.signalCollapsed,
- { user: true },
- ));
- this._listen(this.signalList, 'click', (event) => this.handleSignalClick(event));
- this._listen(this.briefPrevious, 'click', () => this.showBriefPage(this.briefPageIndex - 1, { manual: true }));
- this._listen(this.briefNext, 'click', () => this.showBriefPage(this.briefPageIndex + 1, { manual: true }));
+ this._listen(this.signalToggle, 'click', () =>
+ this.setSignalCollapsed(!this.signalCollapsed, { user: true }),
+ );
+ this._listen(this.signalList, 'click', (event) =>
+ this.handleSignalClick(event),
+ );
+ this._listen(this.briefPrevious, 'click', () =>
+ this.showBriefPage(this.briefPageIndex - 1, { manual: true }),
+ );
+ this._listen(this.briefNext, 'click', () =>
+ this.showBriefPage(this.briefPageIndex + 1, { manual: true }),
+ );
this._listen(this.briefAutoToggle, 'click', () => {
this.setBriefAutoRotate(!this.briefAutoRotateEnabled);
});
- this.briefTabs.forEach((button) => this._listen(button, 'click', () => {
- this.showBriefPage(Number(button.dataset.cockpitBriefIndex), { manual: true });
- }));
+ this.briefTabs.forEach((button) =>
+ this._listen(button, 'click', () => {
+ this.showBriefPage(Number(button.dataset.cockpitBriefIndex), {
+ manual: true,
+ });
+ }),
+ );
this._listen(document, 'visibilitychange', () => {
if (document.hidden) this.stopBriefRotation();
else if (this.briefAutoRotateEnabled) this.startBriefRotation();
@@ -203,45 +295,117 @@ export class CockpitViewController {
_listen(target, type, handler, options) {
if (!target?.addEventListener) return;
target.addEventListener(type, handler, options);
- this._listenerRemovers.push(() => target.removeEventListener(type, handler, options));
+ this._listenerRemovers.push(() =>
+ target.removeEventListener(type, handler, options),
+ );
}
- readAircraftInfo() { return readAircraftInfo.call(this); }
- dispatchCockpitModeChanged(active, info) { return dispatchCockpitModeChanged.call(this, active, info); }
- toggleTrackedTr3b() { return toggleTrackedTr3b.call(this); }
- syncTr3bToggle(info) { return syncTr3bToggle.call(this, info); }
- syncEntry() { return syncEntry.call(this); }
- navigateContext(direction, options) { return navigateContext.call(this, direction, options); }
- _adoptTrackedEntity(nowMs, suppliedInfo) { return _adoptTrackedEntity.call(this, nowMs, suppliedInfo); }
- enter() { return enter.call(this); }
- exit(options) { return exit.call(this, options); }
- update() { return update.call(this); }
- updateHud(info, nowMs, forceContext) { return updateHud.call(this, info, nowMs, forceContext); }
- updateRoute(info) { return updateRoute.call(this, info); }
- syncWeatherToggle(enabled) { return syncWeatherToggle.call(this, enabled); }
- setVisionMode(mode, options) { return setVisionMode.call(this, mode, options); }
- cycleVisionMode(direction) { return cycleVisionMode.call(this, direction); }
- clearPredictiveRoute() { return clearPredictiveRoute.call(this); }
- updateContext(info, heading) { return updateContext.call(this, info, heading); }
- showBriefPage(index, options) { return showBriefPage.call(this, index, options); }
- setBriefAutoRotate(enabled) { return setBriefAutoRotate.call(this, enabled); }
- startBriefRotation(options) { return startBriefRotation.call(this, options); }
- stopBriefRotation() { return stopBriefRotation.call(this); }
- updateLocalPosition(info) { return updateLocalPosition.call(this, info); }
- maybeRefreshRegionalBrief(info) { return maybeRefreshRegionalBrief.call(this, info); }
- renderRegionalBriefStatus(status, info) { return renderRegionalBriefStatus.call(this, status, info); }
- renderRegionalBrief(payload, info) { return renderRegionalBrief.call(this, payload, info); }
- renderCockpitSignals() { return renderCockpitSignals.call(this); }
- pushCockpitSignal(key, tone, title, detail, target) { return pushCockpitSignal.call(this, key, tone, title, detail, target); }
- updateCockpitSignals(snapshot, unknownCount) { return updateCockpitSignals.call(this, snapshot, unknownCount); }
- scheduleContextLayout() { return scheduleContextLayout.call(this); }
- setContextCollapsed(collapsed) { return setContextCollapsed.call(this, collapsed); }
- setSignalCollapsed(collapsed, options) { return setSignalCollapsed.call(this, collapsed, options); }
- syncContextLayout() { return syncContextLayout.call(this); }
- syncSignalLayout() { return syncSignalLayout.call(this); }
- onKeyDown(event) { return onKeyDown.call(this, event); }
+ readAircraftInfo() {
+ return readAircraftInfo.call(this);
+ }
+ dispatchCockpitModeChanged(active, info) {
+ return dispatchCockpitModeChanged.call(this, active, info);
+ }
+ toggleTrackedTr3b() {
+ return toggleTrackedTr3b.call(this);
+ }
+ syncTr3bToggle(info) {
+ return syncTr3bToggle.call(this, info);
+ }
+ syncEntry() {
+ return syncEntry.call(this);
+ }
+ navigateContext(direction, options) {
+ return navigateContext.call(this, direction, options);
+ }
+ _adoptTrackedEntity(nowMs, suppliedInfo) {
+ return _adoptTrackedEntity.call(this, nowMs, suppliedInfo);
+ }
+ enter() {
+ return enter.call(this);
+ }
+ exit(options) {
+ return exit.call(this, options);
+ }
+ update() {
+ return update.call(this);
+ }
+ updateHud(info, nowMs, forceContext) {
+ return updateHud.call(this, info, nowMs, forceContext);
+ }
+ updateRoute(info) {
+ return updateRoute.call(this, info);
+ }
+ syncWeatherToggle(enabled) {
+ return syncWeatherToggle.call(this, enabled);
+ }
+ setVisionMode(mode, options) {
+ return setVisionMode.call(this, mode, options);
+ }
+ cycleVisionMode(direction) {
+ return cycleVisionMode.call(this, direction);
+ }
+ clearPredictiveRoute() {
+ return clearPredictiveRoute.call(this);
+ }
+ updateContext(info, heading) {
+ return updateContext.call(this, info, heading);
+ }
+ showBriefPage(index, options) {
+ return showBriefPage.call(this, index, options);
+ }
+ setBriefAutoRotate(enabled) {
+ return setBriefAutoRotate.call(this, enabled);
+ }
+ startBriefRotation(options) {
+ return startBriefRotation.call(this, options);
+ }
+ stopBriefRotation() {
+ return stopBriefRotation.call(this);
+ }
+ updateLocalPosition(info) {
+ return updateLocalPosition.call(this, info);
+ }
+ maybeRefreshRegionalBrief(info) {
+ return maybeRefreshRegionalBrief.call(this, info);
+ }
+ renderRegionalBriefStatus(status, info) {
+ return renderRegionalBriefStatus.call(this, status, info);
+ }
+ renderRegionalBrief(payload, info) {
+ return renderRegionalBrief.call(this, payload, info);
+ }
+ renderCockpitSignals() {
+ return renderCockpitSignals.call(this);
+ }
+ pushCockpitSignal(key, tone, title, detail, target) {
+ return pushCockpitSignal.call(this, key, tone, title, detail, target);
+ }
+ updateCockpitSignals(snapshot, unknownCount) {
+ return updateCockpitSignals.call(this, snapshot, unknownCount);
+ }
+ scheduleContextLayout() {
+ return scheduleContextLayout.call(this);
+ }
+ setContextCollapsed(collapsed) {
+ return setContextCollapsed.call(this, collapsed);
+ }
+ setSignalCollapsed(collapsed, options) {
+ return setSignalCollapsed.call(this, collapsed, options);
+ }
+ syncContextLayout() {
+ return syncContextLayout.call(this);
+ }
+ syncSignalLayout() {
+ return syncSignalLayout.call(this);
+ }
+ onKeyDown(event) {
+ return onKeyDown.call(this, event);
+ }
- handleSignalClick(event) { return handleSignalClick.call(this, event); }
+ handleSignalClick(event) {
+ return handleSignalClick.call(this, event);
+ }
dispose() {
if (this.destroyed) return;
@@ -251,9 +415,10 @@ export class CockpitViewController {
this.regionalBriefAbort = null;
this.regionalBriefRequestToken += 1;
this.stopBriefRotation();
- if (this.contextLayoutFrame !== null) cancelAnimationFrame(this.contextLayoutFrame);
+ if (this.contextLayoutFrame !== null)
+ cancelAnimationFrame(this.contextLayoutFrame);
this.contextLayoutFrame = null;
- for (const removeListener of this._listenerRemovers.splice(0)) removeListener?.();
+ for (const removeListener of this._listenerRemovers.splice(0))
+ removeListener?.();
}
}
-
diff --git a/src/ui/cockpitController.test.mjs b/src/ui/cockpitController.test.mjs
index 31fdb5a4f..4f8018cbf 100644
--- a/src/ui/cockpitController.test.mjs
+++ b/src/ui/cockpitController.test.mjs
@@ -13,13 +13,27 @@ function environment() {
globalThis.document = document;
globalThis.window = window;
const callbacks = new Set();
- const event = { addEventListener(fn) { callbacks.add(fn); return () => callbacks.delete(fn); } };
+ const event = {
+ addEventListener(fn) {
+ callbacks.add(fn);
+ return () => callbacks.delete(fn);
+ },
+ };
const viewer = {
- scene: { preUpdate: event, screenSpaceCameraController: { enableInputs: false } },
+ scene: {
+ preUpdate: event,
+ screenSpaceCameraController: { enableInputs: false },
+ },
trackedEntityChanged: event,
entities: { contains: () => false },
};
- return { viewer, callbacks, restore() { Object.assign(globalThis, original); } };
+ return {
+ viewer,
+ callbacks,
+ restore() {
+ Object.assign(globalThis, original);
+ },
+ };
}
test('Cockpit subscriptions are released once and retained actions cannot restart it', () => {
@@ -35,7 +49,9 @@ test('Cockpit subscriptions are released once and retained actions cannot restar
assert.equal(owner.toggleTrackedTr3b(), false);
assert.equal(owner.update(), false);
assert.equal(owner.active, false);
- } finally { env.restore(); }
+ } finally {
+ env.restore();
+ }
});
test('disposing an active Cockpit releases the supplied render owner without retracking', () => {
@@ -43,33 +59,50 @@ test('disposing an active Cockpit releases the supplied render owner without ret
try {
const released = [];
const owner = new CockpitViewController(env.viewer, {
- services: { releaseContinuousRender: reason => released.push(reason) },
+ services: { releaseContinuousRender: (reason) => released.push(reason) },
});
owner.active = true;
owner.dispose();
owner.dispose();
assert.deepEqual(released, ['cockpit']);
- assert.equal(env.viewer.scene.screenSpaceCameraController.enableInputs, true);
+ assert.equal(
+ env.viewer.scene.screenSpaceCameraController.enableInputs,
+ true,
+ );
assert.equal(env.viewer.trackedEntity, undefined);
assert.equal(owner.active, false);
- } finally { env.restore(); }
+ } finally {
+ env.restore();
+ }
});
test('aircraft identity is read from the supplied layer instances', () => {
const env = environment();
try {
- const civilian = { icao24: 'abc123', layerId: 'flights', callsign: 'CIVIL' };
- const military = { icao24: 'def456', layerId: 'military', callsign: 'MILITARY' };
- const owner = new CockpitViewController(env.viewer, { services: {
- flightsLayer: { getTrackedInfo: () => civilian },
- militaryFlightsLayer: { getTrackedInfo: () => military },
- } });
+ const civilian = {
+ icao24: 'abc123',
+ layerId: 'flights',
+ callsign: 'CIVIL',
+ };
+ const military = {
+ icao24: 'def456',
+ layerId: 'military',
+ callsign: 'MILITARY',
+ };
+ const owner = new CockpitViewController(env.viewer, {
+ services: {
+ flightsLayer: { getTrackedInfo: () => civilian },
+ militaryFlightsLayer: { getTrackedInfo: () => military },
+ },
+ });
env.viewer.trackedEntity = { gevTrackedId: 'military:def456' };
assert.deepEqual(owner.readAircraftInfo(), military);
env.viewer.trackedEntity = { gevTrackedId: 'flights:abc123' };
assert.deepEqual(owner.readAircraftInfo(), civilian);
owner.dispose();
- } finally { env.restore(); }
+ } finally {
+ env.restore();
+ }
});
test('an obsolete regional briefing cannot publish after Cockpit disposal', async () => {
@@ -77,23 +110,34 @@ test('an obsolete regional briefing cannot publish after Cockpit disposal', asyn
try {
let finish;
let signal;
- const owner = new CockpitViewController(env.viewer, { services: {
- regionalDistanceM: () => Infinity,
- fetchRegionalBrief: (_lat, _lon, options) => {
- signal = options.signal;
- return new Promise(resolve => { finish = resolve; });
+ const owner = new CockpitViewController(env.viewer, {
+ services: {
+ regionalDistanceM: () => Infinity,
+ fetchRegionalBrief: (_lat, _lon, options) => {
+ signal = options.signal;
+ return new Promise((resolve) => {
+ finish = resolve;
+ });
+ },
+ releaseContinuousRender() {},
},
- releaseContinuousRender() {},
- } });
+ });
const published = [];
- owner.renderRegionalBrief = payload => published.push(payload);
+ owner.renderRegionalBrief = (payload) => published.push(payload);
owner.active = true;
- owner.maybeRefreshRegionalBrief({ layerId: 'flights', icao24: 'abc123', latitude: 30, longitude: -97 });
+ owner.maybeRefreshRegionalBrief({
+ layerId: 'flights',
+ icao24: 'abc123',
+ latitude: 30,
+ longitude: -97,
+ });
owner.dispose();
assert.equal(signal.aborted, true);
finish({ articles: [] });
- await new Promise(resolve => setImmediate(resolve));
+ await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(published, []);
assert.equal(owner.regionalBriefAbort, null);
- } finally { env.restore(); }
+ } finally {
+ env.restore();
+ }
});
diff --git a/src/ui/cockpitDisplayPortal.js b/src/ui/cockpitDisplayPortal.js
index 4da1dc13f..50b13d8b6 100644
--- a/src/ui/cockpitDisplayPortal.js
+++ b/src/ui/cockpitDisplayPortal.js
@@ -11,7 +11,9 @@ export class CockpitDisplayPortal {
this.frames = new Set();
this.listeners = new AbortController();
this.records = groups.flatMap(([name, group]) => {
- const slot = cockpitPanel?.querySelector(`[data-cockpit-display-slot="${name}"]`);
+ const slot = cockpitPanel?.querySelector(
+ `[data-cockpit-display-slot="${name}"]`,
+ );
if (!group || !slot || !group.parentNode) return [];
const anchor = document.createComment(`cockpit-display-home:${name}`);
group.before(anchor);
@@ -20,15 +22,27 @@ export class CockpitDisplayPortal {
this.standardScrollTop = standardPanel?.scrollTop || 0;
this.cockpitScrollTop = cockpitPanel?.scrollTop || 0;
const options = { passive: true, signal: this.listeners.signal };
- standardPanel?.addEventListener('scroll', () => {
- if (!this.active) this.standardScrollTop = standardPanel.scrollTop;
- }, options);
- cockpitPanel?.addEventListener('scroll', () => {
- if (this.active) this.cockpitScrollTop = cockpitPanel.scrollTop;
- }, options);
- window.addEventListener('gev:cockpit-mode-changed', event => {
- this.setActive(event?.detail?.active === true);
- }, { signal: this.listeners.signal });
+ standardPanel?.addEventListener(
+ 'scroll',
+ () => {
+ if (!this.active) this.standardScrollTop = standardPanel.scrollTop;
+ },
+ options,
+ );
+ cockpitPanel?.addEventListener(
+ 'scroll',
+ () => {
+ if (this.active) this.cockpitScrollTop = cockpitPanel.scrollTop;
+ },
+ options,
+ );
+ window.addEventListener(
+ 'gev:cockpit-mode-changed',
+ (event) => {
+ this.setActive(event?.detail?.active === true);
+ },
+ { signal: this.listeners.signal },
+ );
this.setActive(document.body.classList.contains('cockpit-mode'));
}
@@ -52,7 +66,9 @@ export class CockpitDisplayPortal {
if (this.active === nextActive) return;
this._cancelFrames();
const generation = this.generation;
- const focusedRecord = this.records.find(record => record.group.contains(document.activeElement));
+ const focusedRecord = this.records.find((record) =>
+ record.group.contains(document.activeElement),
+ );
const focusedElement = focusedRecord ? document.activeElement : null;
this.restoreOwner = nextActive ? 'cockpit' : 'standard';
this.active = nextActive;
@@ -60,12 +76,21 @@ export class CockpitDisplayPortal {
if (nextActive) record.slot.append(record.group);
else if (record.anchor.parentNode) record.anchor.after(record.group);
}
- this.cockpitPanel?.classList.toggle('uses-shared-display-controls', nextActive);
+ this.cockpitPanel?.classList.toggle(
+ 'uses-shared-display-controls',
+ nextActive,
+ );
const restoreScroll = () => {
- if (nextActive && this.cockpitPanel) this.cockpitPanel.scrollTop = this.cockpitScrollTop;
- if (!nextActive && this.standardPanel) this.standardPanel.scrollTop = this.standardScrollTop;
+ if (nextActive && this.cockpitPanel)
+ this.cockpitPanel.scrollTop = this.cockpitScrollTop;
+ if (!nextActive && this.standardPanel)
+ this.standardPanel.scrollTop = this.standardScrollTop;
};
- if (!settle) { restoreScroll(); this.restoreOwner = null; return; }
+ if (!settle) {
+ restoreScroll();
+ this.restoreOwner = null;
+ return;
+ }
this._frame(generation, () => {
restoreScroll();
focusedElement?.focus?.({ preventScroll: true });
diff --git a/src/ui/cockpitDisplayPortal.test.mjs b/src/ui/cockpitDisplayPortal.test.mjs
index aed7d0c74..219508ced 100644
--- a/src/ui/cockpitDisplayPortal.test.mjs
+++ b/src/ui/cockpitDisplayPortal.test.mjs
@@ -3,49 +3,115 @@ import assert from 'node:assert/strict';
import { CockpitDisplayPortal } from './cockpitDisplayPortal.js';
function fixture() {
- const prior = Object.fromEntries(['document', 'window', 'requestAnimationFrame', 'cancelAnimationFrame'].map(key => [key, globalThis[key]]));
+ const prior = Object.fromEntries(
+ ['document', 'window', 'requestAnimationFrame', 'cancelAnimationFrame'].map(
+ (key) => [key, globalThis[key]],
+ ),
+ );
const frames = new Map();
let nextFrame = 0;
let focusCalls = 0;
let layouts = 0;
class Node extends EventTarget {
constructor(name) {
- super(); this.name = name; this.children = []; this.parentNode = null; this.scrollTop = 0;
+ super();
+ this.name = name;
+ this.children = [];
+ this.parentNode = null;
+ this.scrollTop = 0;
this.classList = { toggle() {} };
}
append(node) {
- node.remove(); this.children.push(node); node.parentNode = this;
+ node.remove();
+ this.children.push(node);
+ node.parentNode = this;
}
remove() {
- if (this.parentNode) this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
+ if (this.parentNode)
+ this.parentNode.children.splice(
+ this.parentNode.children.indexOf(this),
+ 1,
+ );
this.parentNode = null;
}
before(node) {
- node.remove(); const parent = this.parentNode;
- parent.children.splice(parent.children.indexOf(this), 0, node); node.parentNode = parent;
+ node.remove();
+ const parent = this.parentNode;
+ parent.children.splice(parent.children.indexOf(this), 0, node);
+ node.parentNode = parent;
}
after(node) {
- node.remove(); const parent = this.parentNode;
- parent.children.splice(parent.children.indexOf(this) + 1, 0, node); node.parentNode = parent;
+ node.remove();
+ const parent = this.parentNode;
+ parent.children.splice(parent.children.indexOf(this) + 1, 0, node);
+ node.parentNode = parent;
+ }
+ contains(node) {
+ return (
+ this === node || this.children.some((child) => child.contains(node))
+ );
+ }
+ focus() {
+ focusCalls += 1;
+ document.activeElement = this;
}
- contains(node) { return this === node || this.children.some(child => child.contains(node)); }
- focus() { focusCalls += 1; document.activeElement = this; }
}
- const standard = new Node('standard'); standard.scrollTop = 71;
- const cockpit = new Node('cockpit'); cockpit.scrollTop = 19;
- const group = new Node('group'); const sibling = new Node('sibling');
+ const standard = new Node('standard');
+ standard.scrollTop = 71;
+ const cockpit = new Node('cockpit');
+ cockpit.scrollTop = 19;
+ const group = new Node('group');
+ const sibling = new Node('sibling');
const slot = new Node('slot');
- standard.append(group); standard.append(sibling); cockpit.append(slot);
+ standard.append(group);
+ standard.append(sibling);
+ cockpit.append(slot);
cockpit.querySelector = () => slot;
- globalThis.document = { createComment: name => new Node(name), activeElement: group, body: { classList: { contains: () => false } } };
+ globalThis.document = {
+ createComment: (name) => new Node(name),
+ activeElement: group,
+ body: { classList: { contains: () => false } },
+ };
globalThis.window = new EventTarget();
- globalThis.requestAnimationFrame = fn => { const id = ++nextFrame; frames.set(id, fn); return id; };
- globalThis.cancelAnimationFrame = id => frames.delete(id);
- const create = () => new CockpitDisplayPortal({ standardPanel: standard, cockpitPanel: cockpit, groups: [['hud', group]], layout: () => { layouts += 1; } });
- return { standard, cockpit, group, sibling, slot, frames, create,
- get focusCalls() { return focusCalls; }, get layouts() { return layouts; },
- flush() { while (frames.size) { const batch = [...frames.values()]; frames.clear(); batch.forEach(fn => fn()); } },
- restore() { Object.assign(globalThis, prior); },
+ globalThis.requestAnimationFrame = (fn) => {
+ const id = ++nextFrame;
+ frames.set(id, fn);
+ return id;
+ };
+ globalThis.cancelAnimationFrame = (id) => frames.delete(id);
+ const create = () =>
+ new CockpitDisplayPortal({
+ standardPanel: standard,
+ cockpitPanel: cockpit,
+ groups: [['hud', group]],
+ layout: () => {
+ layouts += 1;
+ },
+ });
+ return {
+ standard,
+ cockpit,
+ group,
+ sibling,
+ slot,
+ frames,
+ create,
+ get focusCalls() {
+ return focusCalls;
+ },
+ get layouts() {
+ return layouts;
+ },
+ flush() {
+ while (frames.size) {
+ const batch = [...frames.values()];
+ frames.clear();
+ batch.forEach((fn) => fn());
+ }
+ },
+ restore() {
+ Object.assign(globalThis, prior);
+ },
};
}
@@ -66,7 +132,9 @@ test('Display groups return to their exact home with independent scroll position
owner.destroy();
assert.deepEqual(f.standard.children, [f.group, f.sibling]);
assert.equal(f.frames.size, 0);
- } finally { f.restore(); }
+ } finally {
+ f.restore();
+ }
});
test('superseded portal frames cannot restore obsolete focus or scroll', () => {
@@ -77,30 +145,39 @@ test('superseded portal frames cannot restore obsolete focus or scroll', () => {
const obsolete = [...f.frames.values()];
owner.setActive(false);
f.cockpit.scrollTop = 93;
- obsolete.forEach(callback => callback());
+ obsolete.forEach((callback) => callback());
assert.equal(f.focusCalls, 0);
assert.equal(f.cockpit.scrollTop, 93);
f.flush();
assert.equal(f.focusCalls, 1);
assert.equal(owner.restoreOwner, null);
owner.destroy();
- } finally { f.restore(); }
+ } finally {
+ f.restore();
+ }
});
test('disposal restores groups and revokes mode listeners and queued focus work', () => {
const f = fixture();
try {
const owner = f.create();
- window.dispatchEvent(new CustomEvent('gev:cockpit-mode-changed', { detail: { active: true } }));
+ window.dispatchEvent(
+ new CustomEvent('gev:cockpit-mode-changed', { detail: { active: true } }),
+ );
assert.equal(f.group.parentNode, f.slot);
const obsolete = [...f.frames.values()];
- owner.destroy(); owner.destroy();
- obsolete.forEach(callback => callback());
- window.dispatchEvent(new CustomEvent('gev:cockpit-mode-changed', { detail: { active: true } }));
+ owner.destroy();
+ owner.destroy();
+ obsolete.forEach((callback) => callback());
+ window.dispatchEvent(
+ new CustomEvent('gev:cockpit-mode-changed', { detail: { active: true } }),
+ );
owner.setActive(true);
assert.deepEqual(f.standard.children, [f.group, f.sibling]);
assert.equal(f.frames.size, 0);
assert.equal(f.focusCalls, 0);
assert.equal(f.layouts, 1);
- } finally { f.restore(); }
+ } finally {
+ f.restore();
+ }
});
diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js
index 21c55a2b8..aed87f483 100644
--- a/src/ui/cockpitInput.js
+++ b/src/ui/cockpitInput.js
@@ -1,54 +1,70 @@
/** Route Cockpit keys while preserving the active disclosure and form-control owner. */
-
export function onKeyDown(event) {
- if (this.destroyed) return false;
- if (event.repeat || event.isComposing) return;
- if (event.key === 'Escape' && this.active) {
- // The credit lightbox owns Escape while its Close control or links hold
- // focus. Its target handler closes the overlay and restores attribution
- // focus; Cockpit must stay active behind it.
- if (event.target?.closest?.('.cesium-credit-lightbox')) return;
- if (document.getElementById('context-radio-dock')?.classList.contains('disclosure-open')) return;
- if (document.querySelector('#cockpit-utility-controls [aria-expanded="true"]')) return;
- if (this.context?.contains(event.target) && !this.contextCollapsed) {
- event.preventDefault();
- event.stopImmediatePropagation();
- this.setContextCollapsed(true);
- if (event.target === this.contextToggle || this.contextToggle?.contains?.(event.target)) {
- this.contextToggle?.blur?.();
- } else {
- this.contextToggle?.focus({ preventScroll: true });
- }
- return;
- }
- if (this.signalStream?.contains(event.target) && !this.signalCollapsed) {
- event.preventDefault();
- event.stopImmediatePropagation();
- this.setSignalCollapsed(true, { user: true });
- if (event.target === this.signalToggle || this.signalToggle?.contains?.(event.target)) {
- this.signalToggle?.blur?.();
- } else {
- this.signalToggle?.focus({ preventScroll: true });
- }
- return;
- }
+ if (this.destroyed) return false;
+ if (event.repeat || event.isComposing) return;
+ if (event.key === 'Escape' && this.active) {
+ // The credit lightbox owns Escape while its Close control or links hold
+ // focus. Its target handler closes the overlay and restores attribution
+ // focus; Cockpit must stay active behind it.
+ if (event.target?.closest?.('.cesium-credit-lightbox')) return;
+ if (
+ document
+ .getElementById('context-radio-dock')
+ ?.classList.contains('disclosure-open')
+ )
+ return;
+ if (
+ document.querySelector('#cockpit-utility-controls [aria-expanded="true"]')
+ )
+ return;
+ if (this.context?.contains(event.target) && !this.contextCollapsed) {
event.preventDefault();
event.stopImmediatePropagation();
- this.exit();
+ this.setContextCollapsed(true);
+ if (
+ event.target === this.contextToggle ||
+ this.contextToggle?.contains?.(event.target)
+ ) {
+ this.contextToggle?.blur?.();
+ } else {
+ this.contextToggle?.focus({ preventScroll: true });
+ }
return;
}
- if (event.target?.closest?.('input, textarea, select, [contenteditable]')) return;
- const key = event.key?.toLowerCase();
- if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) {
- if (!this.active) {
- const cockpitAttempt = !!(this.readAircraftInfo() && this.viewer.trackedEntity?.position);
- if (!cockpitAttempt) return;
- }
+ if (this.signalStream?.contains(event.target) && !this.signalCollapsed) {
event.preventDefault();
event.stopImmediatePropagation();
- if (!this.active && !this.isEntryAllowed()) return;
- const changed = this.active ? this.exit() : this.enter();
+ this.setSignalCollapsed(true, { user: true });
+ if (
+ event.target === this.signalToggle ||
+ this.signalToggle?.contains?.(event.target)
+ ) {
+ this.signalToggle?.blur?.();
+ } else {
+ this.signalToggle?.focus({ preventScroll: true });
+ }
return;
}
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ this.exit();
+ return;
}
+ if (event.target?.closest?.('input, textarea, select, [contenteditable]'))
+ return;
+ const key = event.key?.toLowerCase();
+ if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) {
+ if (!this.active) {
+ const cockpitAttempt = !!(
+ this.readAircraftInfo() && this.viewer.trackedEntity?.position
+ );
+ if (!cockpitAttempt) return;
+ }
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ if (!this.active && !this.isEntryAllowed()) return;
+ const changed = this.active ? this.exit() : this.enter();
+ return;
+ }
+}
diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js
index 07ac2e637..5cb0a558a 100644
--- a/src/ui/cockpitInstruments.js
+++ b/src/ui/cockpitInstruments.js
@@ -1,182 +1,260 @@
/** Instrument values, routes and vision controls for the Cockpit controller. */
-import { COCKPIT_VISION_MODES, normalizeCockpitVisionMode } from '../cockpitVisionPolicy.js';
-import { altitudeRulerCurveInset, altitudeRulerTicks, bearingBetweenCoordinates, cockpitAltitudeDisplayFt, cockpitUiUpdateDue, compassDivisions, formatAltitudeRulerTick, formatCompassDivision, formatSpeedRulerTick, normalizeHeading, relativeBearing, speedRulerTicks } from '../cockpitMath.js';
-import { COCKPIT_CONTEXT_UPDATE_MS, setCockpitRollingValue } from './cockpitPresentation.js';
+import {
+ COCKPIT_VISION_MODES,
+ normalizeCockpitVisionMode,
+} from '../cockpitVisionPolicy.js';
+import {
+ altitudeRulerCurveInset,
+ altitudeRulerTicks,
+ bearingBetweenCoordinates,
+ cockpitAltitudeDisplayFt,
+ cockpitUiUpdateDue,
+ compassDivisions,
+ formatAltitudeRulerTick,
+ formatCompassDivision,
+ formatSpeedRulerTick,
+ normalizeHeading,
+ relativeBearing,
+ speedRulerTicks,
+} from '../cockpitMath.js';
+import {
+ COCKPIT_CONTEXT_UPDATE_MS,
+ setCockpitRollingValue,
+} from './cockpitPresentation.js';
-export function updateHud(info, nowMs = performance.now(), forceContext = false) {
- this.lastAircraftInfo = info;
- const heading = normalizeHeading(this.heading ?? info.track ?? 0);
- if (this.callsign) {
- this.callsign.textContent = info.callsign || info.registration || info.icao24 || 'AIRCRAFT';
- }
- const speedKt = Number.isFinite(info.velocityMps) ? info.velocityMps * 1.94384 : null;
+export function updateHud(
+ info,
+ nowMs = performance.now(),
+ forceContext = false,
+) {
+ this.lastAircraftInfo = info;
+ const heading = normalizeHeading(this.heading ?? info.track ?? 0);
+ if (this.callsign) {
+ this.callsign.textContent =
+ info.callsign || info.registration || info.icao24 || 'AIRCRAFT';
+ }
+ const speedKt = Number.isFinite(info.velocityMps)
+ ? info.velocityMps * 1.94384
+ : null;
+ setCockpitRollingValue(this.speed, formatSpeedRulerTick(speedKt), 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);
+ });
+ const altitudeFt = cockpitAltitudeDisplayFt(info.altitudeM, info.onGround);
+ if (this.altitude) {
+ const displayedAltitudeFt = Number.isFinite(altitudeFt)
+ ? Math.round(altitudeFt)
+ : null;
setCockpitRollingValue(
- this.speed,
- formatSpeedRulerTick(speedKt),
- speedKt,
+ this.altitude,
+ displayedAltitudeFt !== null
+ ? displayedAltitudeFt.toLocaleString('en-US')
+ : '-----',
+ displayedAltitudeFt,
{ 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);
- });
- const altitudeFt = cockpitAltitudeDisplayFt(info.altitudeM, info.onGround);
- if (this.altitude) {
- const displayedAltitudeFt = Number.isFinite(altitudeFt)
- ? Math.round(altitudeFt)
- : null;
- setCockpitRollingValue(
- this.altitude,
- displayedAltitudeFt !== null
- ? displayedAltitudeFt.toLocaleString('en-US')
- : '-----',
- 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);
- 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 `${formatCompassDivision(division)}`;
- })
- .join('');
- }
- }
- if (this.clock) this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z';
- if (this.position) {
- const lat = Number.isFinite(info.latitude)
- ? `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}` : '--';
- const lon = Number.isFinite(info.longitude)
- ? `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}` : '--';
- this.position.textContent = `${lat} · ${lon}`;
- }
- if (this.aircraftMeta) {
- const feedState = this.surfaceAcquiring
- ? 'ACQUIRING SURFACE'
- : (this.surfaceFallback ? 'SURFACE FALLBACK' : (info.stale ? 'STALE FEED' : 'LIVE TRACK'));
- this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`;
- }
- 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);
- }
- if (this.hud) this.hud.dataset.layer = info.layerId || 'flights';
}
+ 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,
+ );
+ 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 `${formatCompassDivision(division)}`;
+ })
+ .join('');
+ }
+ }
+ if (this.clock)
+ this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z';
+ if (this.position) {
+ const lat = Number.isFinite(info.latitude)
+ ? `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`
+ : '--';
+ const lon = Number.isFinite(info.longitude)
+ ? `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`
+ : '--';
+ this.position.textContent = `${lat} · ${lon}`;
+ }
+ if (this.aircraftMeta) {
+ const feedState = this.surfaceAcquiring
+ ? 'ACQUIRING SURFACE'
+ : this.surfaceFallback
+ ? 'SURFACE FALLBACK'
+ : info.stale
+ ? 'STALE FEED'
+ : 'LIVE TRACK';
+ this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`;
+ }
+ 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);
+ }
+ if (this.hud) this.hud.dataset.layer = info.layerId || 'flights';
+}
export function updateRoute(info) {
- 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(' · ') || 'UNKNOWN';
- if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin);
- if (this.routeTo) this.routeTo.textContent = routeLabel(destination);
- if (this.routeStatus) {
- this.routeStatus.textContent = validDestination
- ? 'ARROW · ESTIMATED DIRECTION'
- : 'ROUTE DATA UNAVAILABLE';
- }
- if (this.route) this.route.hidden = !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;
- }
- 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 = `DEST ${String(Math.round(destinationBearing)).padStart(3, '0')}°`;
- }
+ 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(' · ') || 'UNKNOWN';
+ if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin);
+ if (this.routeTo) this.routeTo.textContent = routeLabel(destination);
+ if (this.routeStatus) {
+ this.routeStatus.textContent = validDestination
+ ? 'ARROW · ESTIMATED DIRECTION'
+ : 'ROUTE DATA UNAVAILABLE';
}
+ if (this.route) this.route.hidden = !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;
+ }
+ 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 = `DEST ${String(Math.round(destinationBearing)).padStart(3, '0')}°`;
+ }
+}
export function syncWeatherToggle(enabled) {
- if (!this.weatherToggle) return;
- const active = !!enabled;
- this.weatherToggle.setAttribute('aria-pressed', String(active));
- this.weatherToggle.setAttribute(
- 'aria-label',
- `${active ? 'Disable' : 'Enable'} cockpit weather effects`,
- );
- this.weatherToggle.title = `${active ? 'Disable' : 'Enable'} cockpit weather effects`;
- if (this.weatherState) this.weatherState.textContent = active ? 'ON' : 'OFF';
- }
+ if (!this.weatherToggle) return;
+ const active = !!enabled;
+ this.weatherToggle.setAttribute('aria-pressed', String(active));
+ this.weatherToggle.setAttribute(
+ 'aria-label',
+ `${active ? 'Disable' : 'Enable'} cockpit weather effects`,
+ );
+ this.weatherToggle.title = `${active ? 'Disable' : 'Enable'} cockpit weather effects`;
+ if (this.weatherState) this.weatherState.textContent = active ? 'ON' : 'OFF';
+}
export function setVisionMode(mode, { revealParameters = false } = {}) {
- const next = normalizeCockpitVisionMode(mode);
- this.visionMode = next;
- const inherited = String(this.getInheritedVisionLabel?.() || 'NORMAL').toUpperCase();
- const labels = { optical: inherited, crt: 'CRT', nvg: 'NVG', thermal: 'FLIR', noir: 'NOIR' };
- const names = { optical: inherited, crt: 'CRT', nvg: 'Night vision', thermal: 'Thermal', noir: 'Noir' };
- if (this.visionCurrent) {
- this.visionCurrent.dataset.cockpitVision = next;
- this.visionCurrent.setAttribute('aria-label', `Current cockpit vision style: ${names[next]}. Activate for next style.`);
- this.visionCurrent.title = `Current style: ${names[next]} — click for next`;
- }
- if (this.visionCurrentLabel) this.visionCurrentLabel.textContent = labels[next];
- this.onVisionChange?.(next, this.active, { revealParameters });
+ const next = normalizeCockpitVisionMode(mode);
+ this.visionMode = next;
+ const inherited = String(
+ this.getInheritedVisionLabel?.() || 'NORMAL',
+ ).toUpperCase();
+ const labels = {
+ optical: inherited,
+ crt: 'CRT',
+ nvg: 'NVG',
+ thermal: 'FLIR',
+ noir: 'NOIR',
+ };
+ const names = {
+ optical: inherited,
+ crt: 'CRT',
+ nvg: 'Night vision',
+ thermal: 'Thermal',
+ noir: 'Noir',
+ };
+ if (this.visionCurrent) {
+ this.visionCurrent.dataset.cockpitVision = next;
+ this.visionCurrent.setAttribute(
+ 'aria-label',
+ `Current cockpit vision style: ${names[next]}. Activate for next style.`,
+ );
+ this.visionCurrent.title = `Current style: ${names[next]} — click for next`;
}
+ if (this.visionCurrentLabel)
+ this.visionCurrentLabel.textContent = labels[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 });
- }
+ 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() {
- if (this.routeDirection) this.routeDirection.hidden = true;
- }
+ if (this.routeDirection) this.routeDirection.hidden = true;
+}
diff --git a/src/ui/cockpitLayout.js b/src/ui/cockpitLayout.js
index 949f60a7f..058c69c99 100644
--- a/src/ui/cockpitLayout.js
+++ b/src/ui/cockpitLayout.js
@@ -1,152 +1,212 @@
/** Measure Cockpit panels against visible HUD and viewport obstacles. */
-import { resolveCockpitUtilityAnchor, resolveCockpitUtilityLayout } from '../cockpitUtilityLayout.js';
-import { COCKPIT_UTILITY_REC_GAP_PX, COCKPIT_UTILITY_SIGNAL_GAP_PX, COCKPIT_UTILITY_MIN_TOP_PX, COCKPIT_UTILITY_MIN_TOP_RATIO, COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX, isRenderedOnScreen } from './cockpitPresentation.js';
+import {
+ resolveCockpitUtilityAnchor,
+ resolveCockpitUtilityLayout,
+} from '../cockpitUtilityLayout.js';
+import {
+ COCKPIT_UTILITY_REC_GAP_PX,
+ COCKPIT_UTILITY_SIGNAL_GAP_PX,
+ COCKPIT_UTILITY_MIN_TOP_PX,
+ COCKPIT_UTILITY_MIN_TOP_RATIO,
+ COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX,
+ isRenderedOnScreen,
+} from './cockpitPresentation.js';
export function scheduleContextLayout() {
- if (this.destroyed) return false;
- const contextVisible = this.context && !this.context.hidden;
- const signalVisible = this.signalStream && !this.signalStream.hidden;
- if ((!contextVisible && !signalVisible) || this.contextLayoutFrame !== null) return;
- this.contextLayoutFrame = requestAnimationFrame(() => {
- this.contextLayoutFrame = null;
- this.syncContextLayout();
- this.syncSignalLayout();
- });
- }
+ if (this.destroyed) return false;
+ const contextVisible = this.context && !this.context.hidden;
+ const signalVisible = this.signalStream && !this.signalStream.hidden;
+ if ((!contextVisible && !signalVisible) || this.contextLayoutFrame !== null)
+ return;
+ this.contextLayoutFrame = requestAnimationFrame(() => {
+ this.contextLayoutFrame = null;
+ this.syncContextLayout();
+ this.syncSignalLayout();
+ });
+}
export function setContextCollapsed(collapsed) {
- const wasCollapsed = this.contextCollapsed;
- this.contextCollapsed = Boolean(collapsed);
- if (this.context) this.context.dataset.collapsed = String(this.contextCollapsed);
- if (this.contextToggle) {
- const expanded = !this.contextCollapsed;
- this.contextToggle.setAttribute('aria-expanded', String(expanded));
- this.contextToggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} Contact panel`);
- this.contextToggle.title = `${expanded ? 'Collapse' : 'Expand'} contact panel`;
- const icon = this.contextToggle.querySelector('.material-symbols-outlined');
- if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right';
- }
- if (this.active && wasCollapsed && !this.contextCollapsed) {
- window.dispatchEvent(new CustomEvent('gev:cockpit-context-expanded'));
- }
- this.scheduleContextLayout();
+ const wasCollapsed = this.contextCollapsed;
+ this.contextCollapsed = Boolean(collapsed);
+ if (this.context)
+ this.context.dataset.collapsed = String(this.contextCollapsed);
+ if (this.contextToggle) {
+ const expanded = !this.contextCollapsed;
+ this.contextToggle.setAttribute('aria-expanded', String(expanded));
+ this.contextToggle.setAttribute(
+ 'aria-label',
+ `${expanded ? 'Collapse' : 'Expand'} Contact panel`,
+ );
+ this.contextToggle.title = `${expanded ? 'Collapse' : 'Expand'} contact panel`;
+ const icon = this.contextToggle.querySelector('.material-symbols-outlined');
+ if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right';
}
+ if (this.active && wasCollapsed && !this.contextCollapsed) {
+ window.dispatchEvent(new CustomEvent('gev:cockpit-context-expanded'));
+ }
+ this.scheduleContextLayout();
+}
export function setSignalCollapsed(collapsed, { user = false } = {}) {
- const wasCollapsed = this.signalCollapsed;
- this.signalCollapsed = Boolean(collapsed);
- if (user) this.signalUserCollapsed = this.signalCollapsed;
- if (this.signalStream) this.signalStream.dataset.collapsed = String(this.signalCollapsed);
- if (this.signalToggle) {
- const expanded = !this.signalCollapsed;
- this.signalToggle.setAttribute('aria-expanded', String(expanded));
- this.signalToggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} cockpit briefing panel`);
- this.signalToggle.title = `${expanded ? 'Collapse' : 'Expand'} briefing panel`;
- const icon = this.signalToggle.querySelector('.material-symbols-outlined');
- if (icon) icon.textContent = expanded ? 'right_panel_close' : 'right_panel_open';
- }
- if (this.signalCollapsed) this.stopBriefRotation();
- else this.startBriefRotation({ reset: true });
- if (this.active && wasCollapsed && !this.signalCollapsed) {
- window.dispatchEvent(new CustomEvent('gev:cockpit-signal-expanded'));
- }
- this.scheduleContextLayout();
+ const wasCollapsed = this.signalCollapsed;
+ this.signalCollapsed = Boolean(collapsed);
+ if (user) this.signalUserCollapsed = this.signalCollapsed;
+ if (this.signalStream)
+ this.signalStream.dataset.collapsed = String(this.signalCollapsed);
+ if (this.signalToggle) {
+ const expanded = !this.signalCollapsed;
+ this.signalToggle.setAttribute('aria-expanded', String(expanded));
+ this.signalToggle.setAttribute(
+ 'aria-label',
+ `${expanded ? 'Collapse' : 'Expand'} cockpit briefing panel`,
+ );
+ this.signalToggle.title = `${expanded ? 'Collapse' : 'Expand'} briefing panel`;
+ const icon = this.signalToggle.querySelector('.material-symbols-outlined');
+ if (icon)
+ icon.textContent = expanded ? 'right_panel_close' : 'right_panel_open';
}
+ if (this.signalCollapsed) this.stopBriefRotation();
+ else this.startBriefRotation({ reset: true });
+ if (this.active && wasCollapsed && !this.signalCollapsed) {
+ window.dispatchEvent(new CustomEvent('gev:cockpit-signal-expanded'));
+ }
+ this.scheduleContextLayout();
+}
export function syncContextLayout() {
- if (!this.context || this.context.hidden) return;
- if (window.matchMedia('(max-width: 760px)').matches) {
- this.context.dataset.layoutMode = 'compact-bottom';
- this.context.style.removeProperty('--cockpit-context-left');
- this.context.style.removeProperty('--cockpit-context-top');
- this.context.style.removeProperty('--cockpit-context-max-height');
- return;
- }
-
- const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04));
- this.context.dataset.layoutMode = 'bottom-left';
- this.context.style.setProperty('--cockpit-context-left', `${desktopInset.toFixed(1)}px`);
+ if (!this.context || this.context.hidden) return;
+ if (window.matchMedia('(max-width: 760px)').matches) {
+ this.context.dataset.layoutMode = 'compact-bottom';
+ this.context.style.removeProperty('--cockpit-context-left');
this.context.style.removeProperty('--cockpit-context-top');
this.context.style.removeProperty('--cockpit-context-max-height');
+ return;
}
+ const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04));
+ this.context.dataset.layoutMode = 'bottom-left';
+ this.context.style.setProperty(
+ '--cockpit-context-left',
+ `${desktopInset.toFixed(1)}px`,
+ );
+ this.context.style.removeProperty('--cockpit-context-top');
+ this.context.style.removeProperty('--cockpit-context-max-height');
+}
+
export function syncSignalLayout() {
- if (!this.signalStream || this.signalStream.hidden) return;
- const utilityControls = document.getElementById('cockpit-utility-controls');
- if (window.matchMedia('(max-width: 760px)').matches) {
- this.signalStream.dataset.layoutMode = 'compact-top';
- utilityControls?.classList.remove('layout-primary-only');
- utilityControls?.querySelectorAll('.cockpit-utility-control').forEach((control) => {
+ if (!this.signalStream || this.signalStream.hidden) return;
+ const utilityControls = document.getElementById('cockpit-utility-controls');
+ if (window.matchMedia('(max-width: 760px)').matches) {
+ this.signalStream.dataset.layoutMode = 'compact-top';
+ utilityControls?.classList.remove('layout-primary-only');
+ utilityControls
+ ?.querySelectorAll('.cockpit-utility-control')
+ .forEach((control) => {
const hiddenSibling = Boolean(
- utilityControls.querySelector('.cockpit-utility-control.is-expanded')
- && !control.classList.contains('is-expanded')
+ utilityControls.querySelector(
+ '.cockpit-utility-control.is-expanded',
+ ) && !control.classList.contains('is-expanded'),
);
control.setAttribute('aria-hidden', String(hiddenSibling));
});
- this.hud?.style.removeProperty('--cockpit-utility-top');
- this.hud?.style.removeProperty('--cockpit-utility-max-height');
- this.hud?.style.removeProperty('--cockpit-utility-expanded-max-height');
- this.signalStream.style.removeProperty('--cockpit-signal-right');
- this.signalStream.style.removeProperty('--cockpit-signal-top');
- this.signalStream.style.removeProperty('--cockpit-signal-max-height');
- return;
- }
-
- const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04));
- this.signalStream.dataset.layoutMode = 'bottom-right';
- this.signalStream.style.setProperty('--cockpit-signal-right', `${desktopInset.toFixed(1)}px`);
+ this.hud?.style.removeProperty('--cockpit-utility-top');
+ this.hud?.style.removeProperty('--cockpit-utility-max-height');
+ this.hud?.style.removeProperty('--cockpit-utility-expanded-max-height');
+ this.signalStream.style.removeProperty('--cockpit-signal-right');
this.signalStream.style.removeProperty('--cockpit-signal-top');
this.signalStream.style.removeProperty('--cockpit-signal-max-height');
- const signalBounds = this.signalStream.getBoundingClientRect();
- const utilityBounds = utilityControls?.getBoundingClientRect();
- if (utilityBounds) {
- const expandedControl = utilityControls?.querySelector('.cockpit-utility-control.is-expanded');
- const collapsedControl = utilityControls?.querySelector(
- '.cockpit-utility-control:not(.is-expanded)',
- );
- const collapsedLauncher = collapsedControl?.querySelector('.cockpit-utility-launcher');
- const expandedHeight = expandedControl
- ? Math.max(expandedControl.scrollHeight, expandedControl.getBoundingClientRect().height)
- : 0;
- const collapsedHeight = collapsedLauncher
- ? Math.max(COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX, collapsedLauncher.scrollHeight)
- : 0;
- // Cockpit owns this anchor outright. The strip used to inherit the left
- // accordion's committed top, which is solved against left-lane obstacles
- // and dropped the strip straight through the briefing card below it.
- // The readout only anchors the strip while it is genuinely on screen:
- // the Minimal variant drops it with `display:none`, but HUD Off hides the
- // whole Intel HUD with `visibility`/`opacity`, which keeps its rect.
- const recReadout = document.querySelector('#intel-hud .hud-top-right');
- const recBounds = isRenderedOnScreen(recReadout) ? recReadout.getBoundingClientRect() : null;
- const utilityAnchor = resolveCockpitUtilityAnchor({
- recBottom: recBounds ? recBounds.bottom : 0,
- signalTop: signalBounds.top,
- stripHeight: utilityBounds.height,
- viewportHeight: window.innerHeight,
- collapsedHeight,
- recGap: COCKPIT_UTILITY_REC_GAP_PX,
- signalGap: COCKPIT_UTILITY_SIGNAL_GAP_PX,
- minTopFloor: COCKPIT_UTILITY_MIN_TOP_PX,
- minTopRatio: COCKPIT_UTILITY_MIN_TOP_RATIO,
- });
- const availableHeight = utilityAnchor.maxHeight;
- this.hud?.style.setProperty('--cockpit-utility-top', `${utilityAnchor.top.toFixed(1)}px`);
- this.hud?.style.setProperty('--cockpit-utility-max-height', `${availableHeight.toFixed(2)}px`);
- const utilityLayout = expandedControl && collapsedControl
- ? resolveCockpitUtilityLayout({ availableHeight, expandedHeight, collapsedHeight })
+ return;
+ }
+
+ const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04));
+ this.signalStream.dataset.layoutMode = 'bottom-right';
+ this.signalStream.style.setProperty(
+ '--cockpit-signal-right',
+ `${desktopInset.toFixed(1)}px`,
+ );
+ this.signalStream.style.removeProperty('--cockpit-signal-top');
+ this.signalStream.style.removeProperty('--cockpit-signal-max-height');
+ const signalBounds = this.signalStream.getBoundingClientRect();
+ const utilityBounds = utilityControls?.getBoundingClientRect();
+ if (utilityBounds) {
+ const expandedControl = utilityControls?.querySelector(
+ '.cockpit-utility-control.is-expanded',
+ );
+ const collapsedControl = utilityControls?.querySelector(
+ '.cockpit-utility-control:not(.is-expanded)',
+ );
+ const collapsedLauncher = collapsedControl?.querySelector(
+ '.cockpit-utility-launcher',
+ );
+ const expandedHeight = expandedControl
+ ? Math.max(
+ expandedControl.scrollHeight,
+ expandedControl.getBoundingClientRect().height,
+ )
+ : 0;
+ const collapsedHeight = collapsedLauncher
+ ? Math.max(
+ COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX,
+ collapsedLauncher.scrollHeight,
+ )
+ : 0;
+ // Cockpit owns this anchor outright. The strip used to inherit the left
+ // accordion's committed top, which is solved against left-lane obstacles
+ // and dropped the strip straight through the briefing card below it.
+ // The readout only anchors the strip while it is genuinely on screen:
+ // the Minimal variant drops it with `display:none`, but HUD Off hides the
+ // whole Intel HUD with `visibility`/`opacity`, which keeps its rect.
+ const recReadout = document.querySelector('#intel-hud .hud-top-right');
+ const recBounds = isRenderedOnScreen(recReadout)
+ ? recReadout.getBoundingClientRect()
+ : null;
+ const utilityAnchor = resolveCockpitUtilityAnchor({
+ recBottom: recBounds ? recBounds.bottom : 0,
+ signalTop: signalBounds.top,
+ stripHeight: utilityBounds.height,
+ viewportHeight: window.innerHeight,
+ collapsedHeight,
+ recGap: COCKPIT_UTILITY_REC_GAP_PX,
+ signalGap: COCKPIT_UTILITY_SIGNAL_GAP_PX,
+ minTopFloor: COCKPIT_UTILITY_MIN_TOP_PX,
+ minTopRatio: COCKPIT_UTILITY_MIN_TOP_RATIO,
+ });
+ const availableHeight = utilityAnchor.maxHeight;
+ this.hud?.style.setProperty(
+ '--cockpit-utility-top',
+ `${utilityAnchor.top.toFixed(1)}px`,
+ );
+ this.hud?.style.setProperty(
+ '--cockpit-utility-max-height',
+ `${availableHeight.toFixed(2)}px`,
+ );
+ const utilityLayout =
+ expandedControl && collapsedControl
+ ? resolveCockpitUtilityLayout({
+ availableHeight,
+ expandedHeight,
+ collapsedHeight,
+ })
: { primaryOnly: false, expandedMaxHeight: availableHeight };
- utilityControls?.classList.toggle('layout-primary-only', utilityLayout.primaryOnly);
- this.hud?.style.setProperty(
- '--cockpit-utility-expanded-max-height',
- `${utilityLayout.expandedMaxHeight.toFixed(2)}px`,
- );
- utilityControls?.querySelectorAll('.cockpit-utility-control').forEach((control) => {
- const hiddenSibling = utilityLayout.primaryOnly && control === collapsedControl;
+ utilityControls?.classList.toggle(
+ 'layout-primary-only',
+ utilityLayout.primaryOnly,
+ );
+ this.hud?.style.setProperty(
+ '--cockpit-utility-expanded-max-height',
+ `${utilityLayout.expandedMaxHeight.toFixed(2)}px`,
+ );
+ utilityControls
+ ?.querySelectorAll('.cockpit-utility-control')
+ .forEach((control) => {
+ const hiddenSibling =
+ utilityLayout.primaryOnly && control === collapsedControl;
control.setAttribute('aria-hidden', String(hiddenSibling));
if (hiddenSibling && control.contains(document.activeElement)) {
- expandedControl?.querySelector('.cockpit-utility-glyph')?.focus({ preventScroll: true });
+ expandedControl
+ ?.querySelector('.cockpit-utility-glyph')
+ ?.focus({ preventScroll: true });
}
});
- }
}
+}
diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js
index 924e7765e..c77d9cb5b 100644
--- a/src/ui/cockpitPresentation.js
+++ b/src/ui/cockpitPresentation.js
@@ -31,9 +31,11 @@ export const COCKPIT_GROUND_WAIT_TIMEOUT_MS = 5000;
export const COCKPIT_BRIEF_ROTATE_MS = 9000;
-export const COCKPIT_BRIEF_CYCLE_OFF_HELP = 'Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.';
+export const COCKPIT_BRIEF_CYCLE_OFF_HELP =
+ 'Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.';
-export const COCKPIT_BRIEF_CYCLE_ON_HELP = 'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.';
+export const COCKPIT_BRIEF_CYCLE_ON_HELP =
+ 'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.';
export const COCKPIT_REGIONAL_REFRESH_MS = 5 * 60_000;
@@ -64,7 +66,11 @@ export function isRenderedOnScreen(element) {
if (!element) return false;
for (let node = element; node instanceof Element; node = node.parentElement) {
const style = getComputedStyle(node);
- if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
+ if (
+ style.display === 'none' ||
+ style.visibility === 'hidden' ||
+ Number(style.opacity) === 0
+ ) {
return false;
}
}
@@ -88,45 +94,56 @@ export function formatCockpitWindDirection(value) {
return `${labels[Math.round(normalized / 45) % labels.length]} · ${Math.round(normalized)}°`;
}
-export function setCockpitRollingValue(element, text, numericValue, {
- circularRange = null,
- immediate = false,
-} = {}) {
+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) {
+ 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);
+ 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 = 'cockpit-roll-token';
- token.setAttribute('aria-hidden', 'true');
- token.textContent = character;
- return token;
- }));
+ if (
+ previousText !== nextText ||
+ !element.querySelector('.cockpit-roll-token')
+ ) {
+ element.replaceChildren(
+ ...Array.from(nextText, (character) => {
+ const token = document.createElement('span');
+ token.className = 'cockpit-roll-token';
+ token.setAttribute('aria-hidden', 'true');
+ token.textContent = character;
+ return token;
+ }),
+ );
}
return;
}
- let delta = Number.isFinite(numericValue) && Number.isFinite(previousValue)
- ? numericValue - previousValue
- : 0;
+ 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;
@@ -145,9 +162,11 @@ export function setCockpitRollingValue(element, text, numericValue, {
token.className = 'cockpit-roll-token';
token.setAttribute('aria-hidden', 'true');
- if (previousCharacter === nextCharacter
- || !/\d/.test(previousCharacter)
- || !/\d/.test(nextCharacter)) {
+ if (
+ previousCharacter === nextCharacter ||
+ !/\d/.test(previousCharacter) ||
+ !/\d/.test(nextCharacter)
+ ) {
token.textContent = nextCharacter === ' ' ? '\u00a0' : nextCharacter;
fragment.append(token);
continue;
diff --git a/src/ui/cockpitSignals.js b/src/ui/cockpitSignals.js
index 6229a0172..b8d6fe18b 100644
--- a/src/ui/cockpitSignals.js
+++ b/src/ui/cockpitSignals.js
@@ -1,174 +1,198 @@
/** Reconcile actionable signal rows without disturbing native keyboard focus. */
-
export function renderCockpitSignals() {
- if (!this.signalList) return;
- const existing = [...this.signalList.children];
- const focusedEntry = existing.find((entry) => entry.contains(document.activeElement));
- const entriesByKey = new Map();
- for (const entry of existing) {
- const key = entry.dataset.signalKey;
- if (!entriesByKey.has(key)) entriesByKey.set(key, []);
- entriesByKey.get(key).push(entry);
- }
- const setText = (element, value) => {
- if (element.textContent !== value) element.textContent = value;
- };
- const entries = this.signalItems.map((item) => {
- // Identity includes the action target: a reused status key must never
- // silently turn a focused flight button into a different selection.
- const key = JSON.stringify([
- item.key, Boolean(item.target), item.target?.layerId || '', String(item.target?.id ?? ''),
- ]);
- let entry = entriesByKey.get(key)?.shift();
- if (!entry) {
- entry = document.createElement('li');
- entry.dataset.signalKey = key;
- const time = document.createElement('time');
- const body = document.createElement('div');
- const heading = document.createElement(item.target ? 'button' : 'strong');
- if (item.target) {
- heading.type = 'button';
- heading.className = 'cockpit-signal-target';
- const label = document.createElement('span');
- label.className = 'cockpit-signal-target-label';
- const rule = document.createElement('span');
- rule.className = 'cockpit-signal-target-rule';
- rule.setAttribute('aria-hidden', 'true');
- const chevron = document.createElement('span');
- chevron.className = 'material-symbols-outlined cockpit-signal-target-chevron';
- chevron.setAttribute('aria-hidden', 'true');
- chevron.textContent = 'chevron_right';
- heading.append(label, rule, chevron);
- }
- body.append(heading, document.createElement('span'));
- entry.append(time, body);
- }
- const [time, body] = entry.children;
- const [heading, copy] = body.children;
- const className = item.target ? `${item.tone} actionable` : item.tone;
- if (entry.className !== className) entry.className = className;
- setText(time, new Date(item.timestamp).toISOString().slice(11, 19) + 'Z');
- if (item.target) {
- heading.dataset.signalLayer = item.target.layerId;
- heading.dataset.signalId = item.target.id;
- const label = `Select flight ${item.title}`;
- if (heading.getAttribute('aria-label') !== label) heading.setAttribute('aria-label', label);
- setText(heading.children[0], item.title);
- } else {
- setText(heading, item.title);
- }
- setText(copy, item.detail);
- return entry;
- });
- const retainedFocus = entries.includes(focusedEntry) ? focusedEntry : null;
- if (focusedEntry && !retainedFocus) {
- // A departed contact cannot remain selectable. Continue from the stable
- // briefing footer instead of dropping keyboard traversal to the page top.
- (this.briefTabs?.[this.briefPageIndex] || this.signalToggle)?.focus({ preventScroll: true });
- }
- for (const entry of existing) if (!entries.includes(entry)) entry.remove();
-
- // Ordinary insertBefore moves disconnect an element and lose its focus.
- // Reorder the other rows around the focused row, which stays connected.
- const focusIndex = retainedFocus ? entries.indexOf(retainedFocus) : -1;
- if (retainedFocus) {
- let anchor = retainedFocus;
- for (let index = focusIndex - 1; index >= 0; index -= 1) {
- const entry = entries[index];
- if (entry.nextElementSibling !== anchor) this.signalList.insertBefore(entry, anchor);
- anchor = entry;
- }
- }
- let anchor = retainedFocus ? retainedFocus.nextElementSibling : this.signalList.firstElementChild;
- for (let index = focusIndex + 1; index < entries.length; index += 1) {
- const entry = entries[index];
- if (entry === anchor) anchor = anchor.nextElementSibling;
- else this.signalList.insertBefore(entry, anchor);
- }
- this.scheduleContextLayout();
+ if (!this.signalList) return;
+ const existing = [...this.signalList.children];
+ const focusedEntry = existing.find((entry) =>
+ entry.contains(document.activeElement),
+ );
+ const entriesByKey = new Map();
+ for (const entry of existing) {
+ const key = entry.dataset.signalKey;
+ if (!entriesByKey.has(key)) entriesByKey.set(key, []);
+ entriesByKey.get(key).push(entry);
}
+ const setText = (element, value) => {
+ if (element.textContent !== value) element.textContent = value;
+ };
+ const entries = this.signalItems.map((item) => {
+ // Identity includes the action target: a reused status key must never
+ // silently turn a focused flight button into a different selection.
+ const key = JSON.stringify([
+ item.key,
+ Boolean(item.target),
+ item.target?.layerId || '',
+ String(item.target?.id ?? ''),
+ ]);
+ let entry = entriesByKey.get(key)?.shift();
+ if (!entry) {
+ entry = document.createElement('li');
+ entry.dataset.signalKey = key;
+ const time = document.createElement('time');
+ const body = document.createElement('div');
+ const heading = document.createElement(item.target ? 'button' : 'strong');
+ if (item.target) {
+ heading.type = 'button';
+ heading.className = 'cockpit-signal-target';
+ const label = document.createElement('span');
+ label.className = 'cockpit-signal-target-label';
+ const rule = document.createElement('span');
+ rule.className = 'cockpit-signal-target-rule';
+ rule.setAttribute('aria-hidden', 'true');
+ const chevron = document.createElement('span');
+ chevron.className =
+ 'material-symbols-outlined cockpit-signal-target-chevron';
+ chevron.setAttribute('aria-hidden', 'true');
+ chevron.textContent = 'chevron_right';
+ heading.append(label, rule, chevron);
+ }
+ body.append(heading, document.createElement('span'));
+ entry.append(time, body);
+ }
+ const [time, body] = entry.children;
+ const [heading, copy] = body.children;
+ const className = item.target ? `${item.tone} actionable` : item.tone;
+ if (entry.className !== className) entry.className = className;
+ setText(time, new Date(item.timestamp).toISOString().slice(11, 19) + 'Z');
+ if (item.target) {
+ heading.dataset.signalLayer = item.target.layerId;
+ heading.dataset.signalId = item.target.id;
+ const label = `Select flight ${item.title}`;
+ if (heading.getAttribute('aria-label') !== label)
+ heading.setAttribute('aria-label', label);
+ setText(heading.children[0], item.title);
+ } else {
+ setText(heading, item.title);
+ }
+ setText(copy, item.detail);
+ return entry;
+ });
+ const retainedFocus = entries.includes(focusedEntry) ? focusedEntry : null;
+ if (focusedEntry && !retainedFocus) {
+ // A departed contact cannot remain selectable. Continue from the stable
+ // briefing footer instead of dropping keyboard traversal to the page top.
+ (this.briefTabs?.[this.briefPageIndex] || this.signalToggle)?.focus({
+ preventScroll: true,
+ });
+ }
+ for (const entry of existing) if (!entries.includes(entry)) entry.remove();
+
+ // Ordinary insertBefore moves disconnect an element and lose its focus.
+ // Reorder the other rows around the focused row, which stays connected.
+ const focusIndex = retainedFocus ? entries.indexOf(retainedFocus) : -1;
+ if (retainedFocus) {
+ let anchor = retainedFocus;
+ for (let index = focusIndex - 1; index >= 0; index -= 1) {
+ const entry = entries[index];
+ if (entry.nextElementSibling !== anchor)
+ this.signalList.insertBefore(entry, anchor);
+ anchor = entry;
+ }
+ }
+ let anchor = retainedFocus
+ ? retainedFocus.nextElementSibling
+ : this.signalList.firstElementChild;
+ for (let index = focusIndex + 1; index < entries.length; index += 1) {
+ const entry = entries[index];
+ if (entry === anchor) anchor = anchor.nextElementSibling;
+ else this.signalList.insertBefore(entry, anchor);
+ }
+ this.scheduleContextLayout();
+}
export function pushCockpitSignal(key, tone, title, detail, target = null) {
- if (!this.signalList || !title || !detail) return;
- const signature = `${title}|${detail}|${target?.layerId || ''}|${target?.id || ''}`;
- if (this.signalSignatures.get(key) === signature) return;
- this.signalSignatures.set(key, signature);
- this.signalItems.unshift({ key, tone, title, detail, target, timestamp: Date.now() });
- this.signalItems = this.signalItems.slice(0, 5);
- this.renderCockpitSignals();
- }
+ if (!this.signalList || !title || !detail) return;
+ const signature = `${title}|${detail}|${target?.layerId || ''}|${target?.id || ''}`;
+ if (this.signalSignatures.get(key) === signature) return;
+ this.signalSignatures.set(key, signature);
+ this.signalItems.unshift({
+ key,
+ tone,
+ title,
+ detail,
+ target,
+ timestamp: Date.now(),
+ });
+ this.signalItems = this.signalItems.slice(0, 5);
+ this.renderCockpitSignals();
+}
export function updateCockpitSignals(snapshot, unknownCount) {
- const previous = new Map(this.signalItems.map((item) => [item.key, item]));
- const contacts = [];
- const subject = snapshot.subject;
- if (['flights', 'military'].includes(subject?.layerId) && subject?.id) {
- contacts.push({
- key: `flight:${subject.layerId}:${subject.id}`,
- tone: 'track',
- title: subject.label || subject.id,
- detail: `${subject.layerId === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · CURRENT`,
- target: { layerId: subject.layerId, id: String(subject.id) },
- distanceM: -1,
- });
- }
- for (const cohort of snapshot.cohorts) {
- if (!['flights', 'military'].includes(cohort.id)) continue;
- for (const item of cohort.nearest) {
- const id = item.icao24 || item.id;
- if (!id) continue;
- contacts.push({
- key: `flight:${cohort.id}:${id}`,
- tone: cohort.id === 'military' ? 'nearby military' : 'nearby',
- // `id` above is IDENTITY (the ICAO hex). Display must not reuse it:
- // the row's own `id` already carries the layer's label convention
- // (callsign → registration → hex), so a callsign-less enriched
- // contact reads as its registration here too. Same helper the
- // Context panel's nearest list uses.
- title: this.services.formatAwarenessLabel(item),
- detail: `${cohort.id === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · ${
- Number.isFinite(item.distanceM)
- ? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM`
- : 'DISTANCE UNKNOWN'
- }`,
- target: { layerId: cohort.id, id: String(id) },
- distanceM: item.distanceM ?? Infinity,
- });
- }
- }
- contacts.sort((a, b) => a.distanceM - b.distanceM);
- const nextItems = contacts.slice(0, 5).map((item) => ({
- ...item,
- timestamp: previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(),
- }));
- if (unknownCount) {
- const sources = snapshot.cohorts
- .filter((cohort) => cohort.count === null)
- .map((cohort) => cohort.source)
- .join(' · ');
- nextItems.splice(4, Math.max(0, nextItems.length - 4), {
- key: 'input-status',
- tone: 'warning',
- title: `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN`,
- detail: sources || 'SOURCE STATUS UNAVAILABLE',
- target: null,
- timestamp: previous.get('input-status')?.timestamp || snapshot.evaluatedAt || Date.now(),
- });
- }
- this.signalItems = nextItems;
- this.signalSignatures.clear();
- this.renderCockpitSignals();
+ const previous = new Map(this.signalItems.map((item) => [item.key, item]));
+ const contacts = [];
+ const subject = snapshot.subject;
+ if (['flights', 'military'].includes(subject?.layerId) && subject?.id) {
+ contacts.push({
+ key: `flight:${subject.layerId}:${subject.id}`,
+ tone: 'track',
+ title: subject.label || subject.id,
+ detail: `${subject.layerId === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · CURRENT`,
+ target: { layerId: subject.layerId, id: String(subject.id) },
+ distanceM: -1,
+ });
}
+ for (const cohort of snapshot.cohorts) {
+ if (!['flights', 'military'].includes(cohort.id)) continue;
+ for (const item of cohort.nearest) {
+ const id = item.icao24 || item.id;
+ if (!id) continue;
+ contacts.push({
+ key: `flight:${cohort.id}:${id}`,
+ tone: cohort.id === 'military' ? 'nearby military' : 'nearby',
+ // `id` above is IDENTITY (the ICAO hex). Display must not reuse it:
+ // the row's own `id` already carries the layer's label convention
+ // (callsign → registration → hex), so a callsign-less enriched
+ // contact reads as its registration here too. Same helper the
+ // Context panel's nearest list uses.
+ title: this.services.formatAwarenessLabel(item),
+ detail: `${cohort.id === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · ${
+ Number.isFinite(item.distanceM)
+ ? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM`
+ : 'DISTANCE UNKNOWN'
+ }`,
+ target: { layerId: cohort.id, id: String(id) },
+ distanceM: item.distanceM ?? Infinity,
+ });
+ }
+ }
+ contacts.sort((a, b) => a.distanceM - b.distanceM);
+ const nextItems = contacts.slice(0, 5).map((item) => ({
+ ...item,
+ timestamp:
+ previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(),
+ }));
+ if (unknownCount) {
+ const sources = snapshot.cohorts
+ .filter((cohort) => cohort.count === null)
+ .map((cohort) => cohort.source)
+ .join(' · ');
+ nextItems.splice(4, Math.max(0, nextItems.length - 4), {
+ key: 'input-status',
+ tone: 'warning',
+ title: `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN`,
+ detail: sources || 'SOURCE STATUS UNAVAILABLE',
+ target: null,
+ timestamp:
+ previous.get('input-status')?.timestamp ||
+ snapshot.evaluatedAt ||
+ Date.now(),
+ });
+ }
+ this.signalItems = nextItems;
+ this.signalSignatures.clear();
+ this.renderCockpitSignals();
+}
export function handleSignalClick(event) {
if (this.destroyed) return;
- const target = event.target.closest('button[data-signal-layer][data-signal-id]');
- if (!target) return;
- event.preventDefault();
- this.services.militaryAwarenessLayer.focusTarget?.(
- target.dataset.signalLayer,
- target.dataset.signalId,
- { origin: 'user' },
- );
+ const target = event.target.closest(
+ 'button[data-signal-layer][data-signal-id]',
+ );
+ if (!target) return;
+ event.preventDefault();
+ this.services.militaryAwarenessLayer.focusTarget?.(
+ target.dataset.signalLayer,
+ target.dataset.signalId,
+ { origin: 'user' },
+ );
}
diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js
index 78ddfc0fc..5a919ec46 100644
--- a/src/ui/cockpitTrackingController.js
+++ b/src/ui/cockpitTrackingController.js
@@ -1,226 +1,254 @@
/** Tracking identity, entry, exit and contact navigation for the Cockpit controller. */
-import { normalizeHeading, resolveTrackedAircraftInfo } from '../cockpitMath.js';
+import {
+ normalizeHeading,
+ resolveTrackedAircraftInfo,
+} from '../cockpitMath.js';
export function readAircraftInfo() {
- // In cockpit mode the controller takes the entity off `viewer.trackedEntity`
- // (see update()), so the cockpit's own handle is the tracked identity there.
- const trackedEntity = this.viewer?.trackedEntity || this.trackedEntity;
- return resolveTrackedAircraftInfo({
- civilian: this.services.flightsLayer.getTrackedInfo?.() || null,
- military: this.services.militaryFlightsLayer.getTrackedInfo?.() || null,
- trackedId: trackedEntity?.gevTrackedId || '',
- });
- }
+ // In cockpit mode the controller takes the entity off `viewer.trackedEntity`
+ // (see update()), so the cockpit's own handle is the tracked identity there.
+ const trackedEntity = this.viewer?.trackedEntity || this.trackedEntity;
+ return resolveTrackedAircraftInfo({
+ civilian: this.services.flightsLayer.getTrackedInfo?.() || null,
+ military: this.services.militaryFlightsLayer.getTrackedInfo?.() || null,
+ trackedId: trackedEntity?.gevTrackedId || '',
+ });
+}
export function dispatchCockpitModeChanged(active, info = null) {
- const subjectId = active
- ? String(info?.icao24 || '').trim().toLowerCase() || null
- : null;
- const layerId = active && ['flights', 'military'].includes(info?.layerId)
+ const subjectId = active
+ ? String(info?.icao24 || '')
+ .trim()
+ .toLowerCase() || null
+ : null;
+ const layerId =
+ active && ['flights', 'military'].includes(info?.layerId)
? info.layerId
: null;
- window.dispatchEvent(new CustomEvent('gev:cockpit-mode-changed', {
+ window.dispatchEvent(
+ new CustomEvent('gev:cockpit-mode-changed', {
detail: { active: active === true, subjectId, layerId },
- }));
- }
+ }),
+ );
+}
export function toggleTrackedTr3b() {
- if (this.destroyed) return false;
- const info = this.readAircraftInfo();
- const icao24 = String(info?.icao24 || '').trim();
- if (!icao24) return false;
- this.services.toggleTr3b(icao24);
- const layer = info.layerId === 'military' ? this.services.militaryFlightsLayer : this.services.flightsLayer;
- layer.refreshTr3b?.(icao24);
- this._tr3bSignature = null; // force the chip to repaint on the next sync
- this.syncTr3bToggle(info);
- return true;
- }
+ if (this.destroyed) return false;
+ const info = this.readAircraftInfo();
+ const icao24 = String(info?.icao24 || '').trim();
+ if (!icao24) return false;
+ this.services.toggleTr3b(icao24);
+ const layer =
+ info.layerId === 'military'
+ ? this.services.militaryFlightsLayer
+ : this.services.flightsLayer;
+ layer.refreshTr3b?.(icao24);
+ this._tr3bSignature = null; // force the chip to repaint on the next sync
+ this.syncTr3bToggle(info);
+ return true;
+}
export function syncTr3bToggle(info) {
- if (!this.tr3bToggle) return;
- const icao24 = String(info?.icao24 || '').trim();
- const converted = !!icao24 && this.services.isTr3b(icao24);
- const signature = icao24 ? `${icao24}:${converted ? 1 : 0}` : '';
- if (this._tr3bSignature === signature) return;
- this._tr3bSignature = signature;
- this.tr3bToggle.hidden = !icao24;
- this.tr3bToggle.setAttribute('aria-pressed', converted ? 'true' : 'false');
- this.tr3bToggle.title = converted ? 'Restore real aircraft' : 'Reclassify as TR-3B';
- }
+ if (!this.tr3bToggle) return;
+ const icao24 = String(info?.icao24 || '').trim();
+ const converted = !!icao24 && this.services.isTr3b(icao24);
+ const signature = icao24 ? `${icao24}:${converted ? 1 : 0}` : '';
+ if (this._tr3bSignature === signature) return;
+ this._tr3bSignature = signature;
+ this.tr3bToggle.hidden = !icao24;
+ this.tr3bToggle.setAttribute('aria-pressed', converted ? 'true' : 'false');
+ this.tr3bToggle.title = converted
+ ? 'Restore real aircraft'
+ : 'Reclassify as TR-3B';
+}
export function syncEntry() {
- if (this.destroyed) return false;
- if (this.active) return;
- const info = this.readAircraftInfo();
- const trackedContact = !!(info && this.viewer.trackedEntity?.position);
- this.syncTr3bToggle(trackedContact ? info : null);
- const available = !!(this.isEntryAllowed() && trackedContact);
- // Change-only DOM writes: this runs on a preUpdate cadence, and
- // unconditional `hidden` assignments invalidate style/layout every frame
- // even when nothing changed. (perf item 9)
- if (this._entryAvailable === available) return;
- this._entryAvailable = available;
- if (this.entry) this.entry.hidden = !available;
- if (this.mapViewButton) this.mapViewButton.hidden = true;
- if (this.resetGlobeButton) this.resetGlobeButton.hidden = true;
- }
+ if (this.destroyed) return false;
+ if (this.active) return;
+ const info = this.readAircraftInfo();
+ const trackedContact = !!(info && this.viewer.trackedEntity?.position);
+ this.syncTr3bToggle(trackedContact ? info : null);
+ const available = !!(this.isEntryAllowed() && trackedContact);
+ // Change-only DOM writes: this runs on a preUpdate cadence, and
+ // unconditional `hidden` assignments invalidate style/layout every frame
+ // even when nothing changed. (perf item 9)
+ if (this._entryAvailable === available) return;
+ this._entryAvailable = available;
+ if (this.entry) this.entry.hidden = !available;
+ if (this.mapViewButton) this.mapViewButton.hidden = true;
+ if (this.resetGlobeButton) this.resetGlobeButton.hidden = true;
+}
export function navigateContext(direction, options = {}) {
- if (this.destroyed) return false;
- const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';
- const wasActive = this.active;
- if (wasActive) this.contextNavigationDeadlineMs = performance.now() + 1500;
- const navigationOptions = wasActive ? { ...options, aircraftOnly: true } : options;
- const changed = Boolean(this.services.militaryAwarenessLayer?.[method]?.(navigationOptions));
- if (!changed) {
- this.contextNavigationDeadlineMs = 0;
- return false;
- }
- if (wasActive) this._adoptTrackedEntity(performance.now());
- return true;
+ if (this.destroyed) return false;
+ const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';
+ const wasActive = this.active;
+ if (wasActive) this.contextNavigationDeadlineMs = performance.now() + 1500;
+ const navigationOptions = wasActive
+ ? { ...options, aircraftOnly: true }
+ : options;
+ const changed = Boolean(
+ this.services.militaryAwarenessLayer?.[method]?.(navigationOptions),
+ );
+ if (!changed) {
+ this.contextNavigationDeadlineMs = 0;
+ return false;
}
+ if (wasActive) this._adoptTrackedEntity(performance.now());
+ return true;
+}
export function _adoptTrackedEntity(nowMs, suppliedInfo = null) {
- const nextEntity = this.viewer.trackedEntity;
- if (!this.active || !nextEntity?.position || nextEntity === this.trackedEntity) return false;
- const info = suppliedInfo || this.readAircraftInfo();
- if (!info) return false;
- if (this.trackedEntity && this.viewer.entities.contains(this.trackedEntity)) {
- this.trackedEntity.show = this.trackedEntityWasShown;
- }
- this.trackedEntity = nextEntity;
- this.trackedEntityWasShown = nextEntity.show;
- nextEntity.show = false;
- this.viewer.trackedEntity = undefined;
- this.cockpitAnchorValid = false;
- this.heading = normalizeHeading(info.track ?? 0);
- this.lastFrameMs = nowMs;
- this.lastHudUpdateMs = 0;
- this.lastContextUpdateMs = 0;
- this.lastCameraUpdateMs = 0;
- this.contextNavigationDeadlineMs = 0;
- this.dispatchCockpitModeChanged(true, info);
- return true;
+ const nextEntity = this.viewer.trackedEntity;
+ if (
+ !this.active ||
+ !nextEntity?.position ||
+ nextEntity === this.trackedEntity
+ )
+ return false;
+ const info = suppliedInfo || this.readAircraftInfo();
+ if (!info) return false;
+ if (this.trackedEntity && this.viewer.entities.contains(this.trackedEntity)) {
+ this.trackedEntity.show = this.trackedEntityWasShown;
}
+ this.trackedEntity = nextEntity;
+ this.trackedEntityWasShown = nextEntity.show;
+ nextEntity.show = false;
+ this.viewer.trackedEntity = undefined;
+ this.cockpitAnchorValid = false;
+ this.heading = normalizeHeading(info.track ?? 0);
+ this.lastFrameMs = nowMs;
+ this.lastHudUpdateMs = 0;
+ this.lastContextUpdateMs = 0;
+ this.lastCameraUpdateMs = 0;
+ this.contextNavigationDeadlineMs = 0;
+ this.dispatchCockpitModeChanged(true, info);
+ return true;
+}
export function enter() {
- if (this.destroyed) return false;
- if (this.active) return false;
- if (!this.isEntryAllowed()) return false;
- const info = this.readAircraftInfo();
- const entity = this.viewer.trackedEntity;
- if (!info || !entity?.position) return false;
- this.entryFocusOrigin = document.activeElement instanceof HTMLElement
+ if (this.destroyed) return false;
+ if (this.active) return false;
+ if (!this.isEntryAllowed()) return false;
+ const info = this.readAircraftInfo();
+ const entity = this.viewer.trackedEntity;
+ if (!info || !entity?.position) return false;
+ this.entryFocusOrigin =
+ document.activeElement instanceof HTMLElement
? document.activeElement
: null;
- // Retire deferred navigation before cancelFlight can run its callbacks.
- this.onCameraTakeover?.();
- this.viewer.camera.cancelFlight();
- this.trackedEntity = entity;
- this.trackedEntityWasShown = entity.show;
- entity.show = false;
- this.heading = normalizeHeading(info.track ?? 0);
- this.lastFrameMs = performance.now();
- this.lastCameraUpdateMs = 0;
- this.lastHudUpdateMs = 0;
- this.lastContextUpdateMs = 0;
- this.contextNavigationDeadlineMs = 0;
- this.lastGroundProbeMs = 0;
- this.surfaceWaitStartedMs = performance.now();
- this.surfaceAcquiring = false;
- this.surfaceFallback = false;
- this.lastCompassSignature = '';
- this.cockpitAnchorValid = false;
- this.lastCameraUpdateMs = 0;
- this.active = true;
- // Cockpit animates the camera from preUpdate every frame — preUpdate only
- // runs on rendered frames, so idle mode would freeze the cockpit solid.
- // (perf wave 2)
- this.services.holdContinuousRender('cockpit');
- this.viewer.trackedEntity = undefined;
- this.viewer.scene.screenSpaceCameraController.enableInputs = false;
- document.body.classList.add('cockpit-mode');
- // Activation writes entry/quick/map visibility directly, bypassing
- // syncEntry's change-only cache — invalidate it so the exit-path
- // syncEntry re-applies every write (notably re-hiding mapViewButton).
- this._entryAvailable = undefined;
- if (this.entry) this.entry.hidden = true;
- if (this.tr3bToggle) {
- this.tr3bToggle.hidden = true;
- this._tr3bSignature = null;
- }
- if (this.mapViewButton) this.mapViewButton.hidden = false;
- if (this.resetGlobeButton) this.resetGlobeButton.hidden = false;
- if (this.hud) this.hud.hidden = false;
- if (this.signalStream) this.signalStream.hidden = false;
- this.hud?.classList.add('signals-active');
- this.signalItems = [];
- this.signalSignatures.clear();
- this.showBriefPage(0);
- this.startBriefRotation();
- const trackLabel = info.callsign || info.registration || info.icao24 || 'AIRCRAFT';
- const trackHeading = String(Math.round(normalizeHeading(info.track ?? 0))).padStart(3, '0');
- this.pushCockpitSignal(
- 'track',
- 'track',
- 'TRACK ACQUIRED',
- `${trackLabel} · COURSE ${trackHeading}°`,
- );
- this.updateHud(info, performance.now(), true);
- this.setVisionMode(this.visionMode);
- this.scheduleContextLayout();
- this.mapViewButton?.focus({ preventScroll: true });
- this.onEntered?.();
- this.dispatchCockpitModeChanged(true, info);
- return true;
+ // Retire deferred navigation before cancelFlight can run its callbacks.
+ this.onCameraTakeover?.();
+ this.viewer.camera.cancelFlight();
+ this.trackedEntity = entity;
+ this.trackedEntityWasShown = entity.show;
+ entity.show = false;
+ this.heading = normalizeHeading(info.track ?? 0);
+ this.lastFrameMs = performance.now();
+ this.lastCameraUpdateMs = 0;
+ this.lastHudUpdateMs = 0;
+ this.lastContextUpdateMs = 0;
+ this.contextNavigationDeadlineMs = 0;
+ this.lastGroundProbeMs = 0;
+ this.surfaceWaitStartedMs = performance.now();
+ this.surfaceAcquiring = false;
+ this.surfaceFallback = false;
+ this.lastCompassSignature = '';
+ this.cockpitAnchorValid = false;
+ this.lastCameraUpdateMs = 0;
+ this.active = true;
+ // Cockpit animates the camera from preUpdate every frame — preUpdate only
+ // runs on rendered frames, so idle mode would freeze the cockpit solid.
+ // (perf wave 2)
+ this.services.holdContinuousRender('cockpit');
+ this.viewer.trackedEntity = undefined;
+ this.viewer.scene.screenSpaceCameraController.enableInputs = false;
+ document.body.classList.add('cockpit-mode');
+ // Activation writes entry/quick/map visibility directly, bypassing
+ // syncEntry's change-only cache — invalidate it so the exit-path
+ // syncEntry re-applies every write (notably re-hiding mapViewButton).
+ this._entryAvailable = undefined;
+ if (this.entry) this.entry.hidden = true;
+ if (this.tr3bToggle) {
+ this.tr3bToggle.hidden = true;
+ this._tr3bSignature = null;
}
+ if (this.mapViewButton) this.mapViewButton.hidden = false;
+ if (this.resetGlobeButton) this.resetGlobeButton.hidden = false;
+ if (this.hud) this.hud.hidden = false;
+ if (this.signalStream) this.signalStream.hidden = false;
+ this.hud?.classList.add('signals-active');
+ this.signalItems = [];
+ this.signalSignatures.clear();
+ this.showBriefPage(0);
+ this.startBriefRotation();
+ const trackLabel =
+ info.callsign || info.registration || info.icao24 || 'AIRCRAFT';
+ const trackHeading = String(
+ Math.round(normalizeHeading(info.track ?? 0)),
+ ).padStart(3, '0');
+ this.pushCockpitSignal(
+ 'track',
+ 'track',
+ 'TRACK ACQUIRED',
+ `${trackLabel} · COURSE ${trackHeading}°`,
+ );
+ this.updateHud(info, performance.now(), true);
+ this.setVisionMode(this.visionMode);
+ this.scheduleContextLayout();
+ this.mapViewButton?.focus({ preventScroll: true });
+ this.onEntered?.();
+ this.dispatchCockpitModeChanged(true, info);
+ return true;
+}
export function exit({ restoreTracking = true } = {}) {
- if (!this.active) return false;
- const entity = this.trackedEntity;
- this.active = false;
- this.services.releaseContinuousRender('cockpit');
- this.trackedEntity = null;
- this.heading = null;
- this.cockpitAnchorValid = false;
- this.surfaceWaitStartedMs = 0;
- this.surfaceAcquiring = false;
- this.surfaceFallback = false;
- this.lastHudUpdateMs = 0;
- this.lastContextUpdateMs = 0;
- this.contextNavigationDeadlineMs = 0;
- this.lastCompassSignature = '';
- this.stopBriefRotation();
- this.regionalBriefAbort?.abort();
- this.regionalBriefAbort = null;
- this.regionalBriefRequestToken += 1;
- this.regionalBriefSubjectId = null;
- document.body.classList.remove('cockpit-mode');
- this.onExited?.();
- 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.clearPredictiveRoute();
- this.setVisionMode('optical');
- if (this.signalStream) this.signalStream.hidden = true;
- this.hud?.classList.remove('signals-active');
- this.viewer.scene.screenSpaceCameraController.enableInputs = true;
- if (entity && this.viewer.entities.contains(entity)) entity.show = this.trackedEntityWasShown;
- this.trackedEntityWasShown = true;
- this.dispatchCockpitModeChanged(false);
- if (restoreTracking && entity && this.viewer.entities.contains(entity)) {
- this.viewer.trackedEntity = entity;
- this.restoreTrackingFrame(entity);
- }
- this.syncEntry();
- const restoreTarget = this.entryFocusOrigin === this.entry
- ? this.entry
- : (this.entry || this.entryFocusOrigin);
- this.entryFocusOrigin = null;
- if (restoreTarget?.isConnected && !restoreTarget.hidden) {
- restoreTarget.focus({ preventScroll: true });
- }
- return true;
+ if (!this.active) return false;
+ const entity = this.trackedEntity;
+ this.active = false;
+ this.services.releaseContinuousRender('cockpit');
+ this.trackedEntity = null;
+ this.heading = null;
+ this.cockpitAnchorValid = false;
+ this.surfaceWaitStartedMs = 0;
+ this.surfaceAcquiring = false;
+ this.surfaceFallback = false;
+ this.lastHudUpdateMs = 0;
+ this.lastContextUpdateMs = 0;
+ this.contextNavigationDeadlineMs = 0;
+ this.lastCompassSignature = '';
+ this.stopBriefRotation();
+ this.regionalBriefAbort?.abort();
+ this.regionalBriefAbort = null;
+ this.regionalBriefRequestToken += 1;
+ this.regionalBriefSubjectId = null;
+ document.body.classList.remove('cockpit-mode');
+ this.onExited?.();
+ 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.clearPredictiveRoute();
+ this.setVisionMode('optical');
+ if (this.signalStream) this.signalStream.hidden = true;
+ this.hud?.classList.remove('signals-active');
+ this.viewer.scene.screenSpaceCameraController.enableInputs = true;
+ if (entity && this.viewer.entities.contains(entity))
+ entity.show = this.trackedEntityWasShown;
+ this.trackedEntityWasShown = true;
+ this.dispatchCockpitModeChanged(false);
+ if (restoreTracking && entity && this.viewer.entities.contains(entity)) {
+ this.viewer.trackedEntity = entity;
+ this.restoreTrackingFrame(entity);
}
+ this.syncEntry();
+ const restoreTarget =
+ this.entryFocusOrigin === this.entry
+ ? this.entry
+ : this.entry || this.entryFocusOrigin;
+ this.entryFocusOrigin = null;
+ if (restoreTarget?.isConnected && !restoreTarget.hidden) {
+ restoreTarget.focus({ preventScroll: true });
+ }
+ return true;
+}