style: format Cockpit components and pure policies

This commit is contained in:
Sameh Khamis committed 2026-09-13 02:37:44 -07:00
1 parent 8075c70e6e
commit e04aefe863
18 files changed
+1248 -428

No files matched your search

+18 -1
View File
@@ -191,5 +191,22 @@
"src/ui/contextPresentation.js", "src/ui/contextPresentation.js",
"src/ui/contextSession.js", "src/ui/contextSession.js",
"src/ui/contextSubscriptions.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"
] ]
+1 -3
View File
@@ -413,8 +413,6 @@
"src/cockpitVisionPolicy.js", "src/cockpitVisionPolicy.js",
"src/ui/panelRailGeometry.js" "src/ui/panelRailGeometry.js"
], ],
"external": [ "external": ["cesium"]
"cesium"
]
} }
} }
+73 -33
View File
@@ -25,8 +25,13 @@ export function slewHeading(current, target, maxStepDeg) {
* becoming a first-person surge or reversal while still removing drift. * becoming a first-person surge or reversal while still removing drift.
*/ */
export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) { export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
if (!Number.isFinite(distanceM) || distanceM <= 0 if (
|| !Number.isFinite(dtSec) || dtSec <= 0) return 0; !Number.isFinite(distanceM) ||
distanceM <= 0 ||
!Number.isFinite(dtSec) ||
dtSec <= 0
)
return 0;
const dt = Math.min(0.1, dtSec); const dt = Math.min(0.1, dtSec);
const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0; const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0;
const eased = distanceM * (1 - Math.exp(-1.25 * dt)); 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. */ /** Return whether a throttled cockpit presentation update is due. */
export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) { export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) {
if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0) return false; if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0)
if (!Number.isFinite(lastUpdateMs) || lastUpdateMs <= 0 || nowMs < lastUpdateMs) return true; return false;
if (
!Number.isFinite(lastUpdateMs) ||
lastUpdateMs <= 0 ||
nowMs < lastUpdateMs
)
return true;
return nowMs - lastUpdateMs >= intervalMs; return nowMs - lastUpdateMs >= intervalMs;
} }
/** Return whether the bounded rendered-surface acquisition window has elapsed. */ /** Return whether the bounded rendered-surface acquisition window has elapsed. */
export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) { 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; 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. * Keep the cockpit camera above the shared rendered-surface floor.
* Unknown floors preserve the proposed camera height. * 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(proposedHeightM)) return proposedHeightM;
if (!Number.isFinite(groundHeightM)) return proposedHeightM; if (!Number.isFinite(groundHeightM)) return proposedHeightM;
const clearance = Number.isFinite(clearanceM) ? Math.max(0, clearanceM) : 0; 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. */ /** Format the cockpit Context scope without overstating installation coverage. */
export function formatCockpitContextScope(subjectLabel, radiusM, installationCoverage = null) { export function formatCockpitContextScope(
const normalizedLabel = typeof subjectLabel === 'string' subjectLabel,
radiusM,
installationCoverage = null,
) {
const normalizedLabel =
typeof subjectLabel === 'string'
? subjectLabel.trim() || '—' ? subjectLabel.trim() || '—'
: Number.isFinite(subjectLabel) ? String(subjectLabel) : '—'; : Number.isFinite(subjectLabel)
const radiusKm = Number.isFinite(radiusM) && radiusM >= 0 ? String(subjectLabel)
: '—';
const radiusKm =
Number.isFinite(radiusM) && radiusM >= 0
? String(Math.round(radiusM / 1000)) ? String(Math.round(radiusM / 1000))
: '—'; : '—';
const coverage = typeof installationCoverage === 'string' const coverage =
? installationCoverage.trim() typeof installationCoverage === 'string' ? installationCoverage.trim() : '';
: '';
const base = `${normalizedLabel} · ${radiusKm} KM AIR/SEA WINDOW`; const base = `${normalizedLabel} · ${radiusKm} KM AIR/SEA WINDOW`;
return coverage return coverage ? `${base} · INSTALLATIONS ${coverage}` : base;
? `${base} · INSTALLATIONS ${coverage}`
: base;
} }
/** Return seven 30-degree compass divisions centered on a heading. */ /** Return seven 30-degree compass divisions centered on a heading. */
export function compassDivisions(heading) { export function compassDivisions(heading) {
const center = Math.round(normalizeHeading(heading) / 30) * 30; 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. */ /** Format a compass division as a cardinal/intercardinal label or degrees. */
export function formatCompassDivision(heading) { export function formatCompassDivision(heading) {
const normalized = normalizeHeading(heading); const normalized = normalizeHeading(heading);
const labels = new Map([ const labels = new Map([
[0, 'N'], [45, 'NE'], [90, 'E'], [135, 'SE'], [0, 'N'],
[180, 'S'], [225, 'SW'], [270, 'W'], [315, 'NW'], [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. */ /** 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. */ /** Return the initial great-circle bearing between two latitude/longitude points. */
export function bearingBetweenCoordinates(fromLat, fromLon, toLat, toLon) { export function bearingBetweenCoordinates(fromLat, fromLon, toLat, toLon) {
if (![fromLat, fromLon, toLat, toLon].every(Number.isFinite)) return null; if (![fromLat, fromLon, toLat, toLon].every(Number.isFinite)) return null;
const φ1 = fromLat * Math.PI / 180; const φ1 = (fromLat * Math.PI) / 180;
const φ2 = toLat * Math.PI / 180; const φ2 = (toLat * Math.PI) / 180;
const Δλ = (toLon - fromLon) * Math.PI / 180; const Δλ = ((toLon - fromLon) * Math.PI) / 180;
const y = Math.sin(Δλ) * Math.cos(φ2); const y = Math.sin(Δλ) * Math.cos(φ2);
const x = Math.cos(φ1) * Math.sin(φ2) const x =
- Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ); 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; 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. */ /** Return a target bearing relative to the current heading in the [-180, 180) range. */
export function relativeBearing(bearing, heading) { export function relativeBearing(bearing, heading) {
if (!Number.isFinite(bearing) || !Number.isFinite(heading)) return null; 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 (civilian) candidates.push({ ...civilian, layerId: 'flights' });
if (military) candidates.push({ ...military, layerId: 'military' }); if (military) candidates.push({ ...military, layerId: 'military' });
if (!candidates.length) return null; if (!candidates.length) return null;
const trackedKey = String(trackedId || '').trim().toLowerCase(); const trackedKey = String(trackedId || '')
.trim()
.toLowerCase();
if (trackedKey) { if (trackedKey) {
const owner = candidates.find( const owner = candidates.find(
(candidate) => `${candidate.layerId}:${candidate.icao24}`.toLowerCase() === trackedKey, (candidate) =>
`${candidate.layerId}:${candidate.icao24}`.toLowerCase() === trackedKey,
); );
if (owner) return owner; if (owner) return owner;
} }
@@ -253,7 +289,10 @@ export function resolveTrackedAircraftInfo({
* aircraftRelative: boolean, contactLost: boolean}} * aircraftRelative: boolean, contactLost: boolean}}
* Panel visibility plus the per-field rendering rules for this frame. * 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) { if (!snapshot) {
return { return {
visible: false, visible: false,
@@ -264,14 +303,15 @@ export function resolveCockpitContextReadout({ snapshot = null, info = null } =
}; };
} }
const trackedId = info?.icao24 ?? info?.id; const trackedId = info?.icao24 ?? info?.id;
const subjectMatchesTracked = snapshot.subject?.layerId === info?.layerId const subjectMatchesTracked =
&& String(snapshot.subject?.id) === String(trackedId); snapshot.subject?.layerId === info?.layerId &&
String(snapshot.subject?.id) === String(trackedId);
// `subjectPresent` is absent on snapshots built before the presence signal // `subjectPresent` is absent on snapshots built before the presence signal
// existed; only an explicit `false` means the subject left its source. // existed; only an explicit `false` means the subject left its source.
const contactLost = snapshot.subjectPresent === false; const contactLost = snapshot.subjectPresent === false;
return { return {
visible: true, visible: true,
mode: contactLost ? 'lost' : (subjectMatchesTracked ? 'tracked' : 'foreign'), mode: contactLost ? 'lost' : subjectMatchesTracked ? 'tracked' : 'foreign',
subjectMatchesTracked, subjectMatchesTracked,
// The nose-relative arrow and BRG readout are measured in the tracked // 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 // aircraft's own frame; every other value in that row is measured from the
+4 -2
View File
@@ -62,7 +62,10 @@ export function resolveCockpitUtilityLayout({
gap = 7, gap = 7,
minimumExpandedHeight = 120, 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 expanded = Math.max(0, Number(expandedHeight) || 0);
const collapsed = Math.max(0, Number(collapsedHeight) || 0); const collapsed = Math.max(0, Number(collapsedHeight) || 0);
const spacing = Math.max(0, Number(gap) || 0); const spacing = Math.max(0, Number(gap) || 0);
@@ -76,4 +79,3 @@ export function resolveCockpitUtilityLayout({
), ),
}; };
} }
+7 -1
View File
@@ -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({ const TARGET_STYLE_BY_MODE = Object.freeze({
crt: 'retro', crt: 'retro',
+111 -48
View File
@@ -1,5 +1,14 @@
/** Regional briefing requests, pages and rotation for the Cockpit controller. */ /** 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 } = {}) { export function showBriefPage(index, { manual = false } = {}) {
const count = COCKPIT_BRIEF_PAGES.length; const count = COCKPIT_BRIEF_PAGES.length;
@@ -9,25 +18,35 @@ export function showBriefPage(index, { manual = false } = {}) {
element.hidden = element.dataset.cockpitBriefPage !== page.id; element.hidden = element.dataset.cockpitBriefPage !== page.id;
}); });
this.briefTabs.forEach((button) => { this.briefTabs.forEach((button) => {
const current = Number(button.dataset.cockpitBriefIndex) === this.briefPageIndex; const current =
Number(button.dataset.cockpitBriefIndex) === this.briefPageIndex;
button.setAttribute('aria-current', current ? 'true' : 'false'); button.setAttribute('aria-current', current ? 'true' : 'false');
}); });
if (this.briefKicker) { if (this.briefKicker) {
const indicator = this.briefKicker.querySelector('i'); const indicator = this.briefKicker.querySelector('i');
this.briefKicker.replaceChildren(...[indicator, document.createTextNode(` ${page.kicker}`)].filter(Boolean)); this.briefKicker.replaceChildren(
...[indicator, document.createTextNode(` ${page.kicker}`)].filter(
Boolean,
),
);
} }
if (this.briefSubtitle) this.briefSubtitle.textContent = page.subtitle; if (this.briefSubtitle) this.briefSubtitle.textContent = page.subtitle;
if (this.briefPosition) this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`; if (this.briefPosition)
this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`;
if (this.briefSource) this.briefSource.textContent = page.source; if (this.briefSource) this.briefSource.textContent = page.source;
if (this.signalStream) this.signalStream.dataset.briefPage = page.id; if (this.signalStream) this.signalStream.dataset.briefPage = page.id;
if (manual && this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true }); if (manual && this.briefAutoRotateEnabled)
this.startBriefRotation({ reset: true });
this.scheduleContextLayout(); this.scheduleContextLayout();
} }
export function setBriefAutoRotate(enabled) { export function setBriefAutoRotate(enabled) {
this.briefAutoRotateEnabled = Boolean(enabled); this.briefAutoRotateEnabled = Boolean(enabled);
if (this.briefAutoToggle) { if (this.briefAutoToggle) {
this.briefAutoToggle.setAttribute('aria-pressed', String(this.briefAutoRotateEnabled)); this.briefAutoToggle.setAttribute(
'aria-pressed',
String(this.briefAutoRotateEnabled),
);
const label = this.briefAutoRotateEnabled ? 'CYCLE ON' : 'CYCLE OFF'; const label = this.briefAutoRotateEnabled ? 'CYCLE ON' : 'CYCLE OFF';
this.briefAutoToggle.textContent = label; this.briefAutoToggle.textContent = label;
const help = this.briefAutoRotateEnabled const help = this.briefAutoRotateEnabled
@@ -38,31 +57,35 @@ export function setBriefAutoRotate(enabled) {
} }
if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true }); if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true });
else this.stopBriefRotation(); else this.stopBriefRotation();
} }
export function startBriefRotation({ reset = false } = {}) { export function startBriefRotation({ reset = false } = {}) {
if (reset) this.stopBriefRotation(); if (reset) this.stopBriefRotation();
if (!this.briefAutoRotateEnabled if (
|| this.briefTimer !this.briefAutoRotateEnabled ||
|| !this.active this.briefTimer ||
|| this.signalCollapsed !this.active ||
|| document.hidden) return; this.signalCollapsed ||
document.hidden
)
return;
this.briefTimer = window.setTimeout(() => { this.briefTimer = window.setTimeout(() => {
this.briefTimer = null; this.briefTimer = null;
const hasPointer = this.signalStream?.matches(':hover') === true; const hasPointer = this.signalStream?.matches(':hover') === true;
const hasFocus = this.signalStream?.contains(document.activeElement) === true; const hasFocus =
this.signalStream?.contains(document.activeElement) === true;
const isInteracting = hasPointer || hasFocus; const isInteracting = hasPointer || hasFocus;
if (!isInteracting) { if (!isInteracting) {
this.showBriefPage(this.briefPageIndex + 1); this.showBriefPage(this.briefPageIndex + 1);
} }
this.startBriefRotation(); this.startBriefRotation();
}, COCKPIT_BRIEF_ROTATE_MS); }, COCKPIT_BRIEF_ROTATE_MS);
} }
export function stopBriefRotation() { export function stopBriefRotation() {
if (this.briefTimer) window.clearTimeout(this.briefTimer); if (this.briefTimer) window.clearTimeout(this.briefTimer);
this.briefTimer = null; this.briefTimer = null;
} }
export function updateLocalPosition(info) { export function updateLocalPosition(info) {
if (!this.localCoordinates) return; if (!this.localCoordinates) return;
@@ -73,10 +96,15 @@ export function updateLocalPosition(info) {
const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`; 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'}`; const lon = `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`;
this.localCoordinates.textContent = `${lat} · ${lon}`; this.localCoordinates.textContent = `${lat} · ${lon}`;
} }
export function maybeRefreshRegionalBrief(info) { export function maybeRefreshRegionalBrief(info) {
if (!this.active || !Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) return; if (
!this.active ||
!Number.isFinite(info.latitude) ||
!Number.isFinite(info.longitude)
)
return;
const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.registration || info.callsign || 'unknown'}`; const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.registration || info.callsign || 'unknown'}`;
if (subjectId !== this.regionalBriefSubjectId) { if (subjectId !== this.regionalBriefSubjectId) {
this.regionalBriefAbort?.abort(); this.regionalBriefAbort?.abort();
@@ -89,9 +117,16 @@ export function maybeRefreshRegionalBrief(info) {
} }
const point = { latitude: info.latitude, longitude: info.longitude }; const point = { latitude: info.latitude, longitude: info.longitude };
const ageMs = Date.now() - this.regionalBriefFetchedAt; const ageMs = Date.now() - this.regionalBriefFetchedAt;
const distanceM = this.services.regionalDistanceM(this.regionalBriefAnchor, point); const distanceM = this.services.regionalDistanceM(
if (this.regionalBriefAbort || (ageMs < COCKPIT_REGIONAL_REFRESH_MS this.regionalBriefAnchor,
&& distanceM < COCKPIT_REGIONAL_REFRESH_DISTANCE_M)) return; point,
);
if (
this.regionalBriefAbort ||
(ageMs < COCKPIT_REGIONAL_REFRESH_MS &&
distanceM < COCKPIT_REGIONAL_REFRESH_DISTANCE_M)
)
return;
this.regionalBriefAnchor = point; this.regionalBriefAnchor = point;
this.regionalBriefFetchedAt = Date.now(); this.regionalBriefFetchedAt = Date.now();
@@ -99,52 +134,66 @@ export function maybeRefreshRegionalBrief(info) {
const requestToken = ++this.regionalBriefRequestToken; const requestToken = ++this.regionalBriefRequestToken;
this.regionalBriefAbort = controller; this.regionalBriefAbort = controller;
if (!this.regionalBrief) this.renderRegionalBriefStatus('loading', info); if (!this.regionalBrief) this.renderRegionalBriefStatus('loading', info);
this.services.fetchRegionalBrief(point.latitude, point.longitude, { signal: controller.signal }) this.services
.fetchRegionalBrief(point.latitude, point.longitude, {
signal: controller.signal,
})
.then((payload) => { .then((payload) => {
if (!this.active if (
|| requestToken !== this.regionalBriefRequestToken !this.active ||
|| subjectId !== this.regionalBriefSubjectId) return; requestToken !== this.regionalBriefRequestToken ||
subjectId !== this.regionalBriefSubjectId
)
return;
this.regionalBrief = payload; this.regionalBrief = payload;
this.renderRegionalBrief(payload, info); this.renderRegionalBrief(payload, info);
}) })
.catch((error) => { .catch((error) => {
if (error?.name !== 'AbortError' if (
&& this.active error?.name !== 'AbortError' &&
&& requestToken === this.regionalBriefRequestToken this.active &&
&& subjectId === this.regionalBriefSubjectId) { requestToken === this.regionalBriefRequestToken &&
subjectId === this.regionalBriefSubjectId
) {
this.renderRegionalBriefStatus('unavailable', info); this.renderRegionalBriefStatus('unavailable', info);
} }
}) })
.finally(() => { .finally(() => {
if (this.regionalBriefAbort === controller) this.regionalBriefAbort = null; if (this.regionalBriefAbort === controller)
this.regionalBriefAbort = null;
}); });
} }
export function renderRegionalBriefStatus(status, info) { export function renderRegionalBriefStatus(status, info) {
if (this.newsStatus) { if (this.newsStatus) {
this.newsStatus.hidden = false; this.newsStatus.hidden = false;
this.newsStatus.dataset.state = status; this.newsStatus.dataset.state = status;
this.newsStatus.textContent = status === 'loading' this.newsStatus.textContent =
status === 'loading'
? 'ACQUIRING REGIONAL NEWS' ? 'ACQUIRING REGIONAL NEWS'
: 'REGIONAL NEWS UNAVAILABLE'; : 'REGIONAL NEWS UNAVAILABLE';
} }
if (status === 'unavailable') this.newsList?.replaceChildren(); if (status === 'unavailable') this.newsList?.replaceChildren();
if (this.localPlace && status === 'loading') this.localPlace.textContent = 'RESOLVING REGION'; if (this.localPlace && status === 'loading')
if (this.localPlace && status === 'unavailable') this.localPlace.textContent = 'REGION UNAVAILABLE'; this.localPlace.textContent = 'RESOLVING REGION';
if (this.localPlace && status === 'unavailable')
this.localPlace.textContent = 'REGION UNAVAILABLE';
this.updateLocalPosition(info); this.updateLocalPosition(info);
} }
export function renderRegionalBrief(payload, info) { export function renderRegionalBrief(payload, info) {
const articles = Array.isArray(payload?.articles) ? payload.articles : []; const articles = Array.isArray(payload?.articles) ? payload.articles : [];
if (this.newsStatus) { if (this.newsStatus) {
this.newsStatus.hidden = articles.length > 0; this.newsStatus.hidden = articles.length > 0;
this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable'; this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable';
this.newsStatus.textContent = payload?.newsStatus === 'empty' this.newsStatus.textContent =
payload?.newsStatus === 'empty'
? 'NO RECENT LOCATION MATCHES' ? 'NO RECENT LOCATION MATCHES'
: 'REGIONAL NEWS UNAVAILABLE'; : 'REGIONAL NEWS UNAVAILABLE';
} }
if (this.newsList) { if (this.newsList) {
this.newsList.replaceChildren(...articles.slice(0, 4).map((article) => { this.newsList.replaceChildren(
...articles.slice(0, 4).map((article) => {
const entry = document.createElement('li'); const entry = document.createElement('li');
const link = document.createElement('a'); const link = document.createElement('a');
link.href = article.url; link.href = article.url;
@@ -157,36 +206,50 @@ export function renderRegionalBrief(payload, info) {
link.append(title, metadata); link.append(title, metadata);
entry.append(link); entry.append(link);
return entry; return entry;
})); }),
);
} }
const placeLabel = payload?.place?.label || payload?.place?.country || 'REGION UNAVAILABLE'; const placeLabel =
payload?.place?.label || payload?.place?.country || 'REGION UNAVAILABLE';
if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase(); if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase();
this.updateLocalPosition(info); this.updateLocalPosition(info);
const weather = payload?.weather; const weather = payload?.weather;
if (this.localTemperature) { if (this.localTemperature) {
this.localTemperature.textContent = Number.isFinite(weather?.temperatureC) this.localTemperature.textContent = Number.isFinite(weather?.temperatureC)
? `${Math.round(weather.temperatureC)}°C` : '—'; ? `${Math.round(weather.temperatureC)}°C`
: '—';
} }
if (this.localWind) { if (this.localWind) {
this.localWind.textContent = Number.isFinite(weather?.windKph) this.localWind.textContent = Number.isFinite(weather?.windKph)
? `${Math.round(weather.windKph)} KM/H` : '—'; ? `${Math.round(weather.windKph)} KM/H`
: '—';
} }
if (this.localWindDirection) { if (this.localWindDirection) {
this.localWindDirection.textContent = formatCockpitWindDirection(weather?.windDirectionDeg); this.localWindDirection.textContent = formatCockpitWindDirection(
weather?.windDirectionDeg,
);
} }
if (this.localCondition) this.localCondition.textContent = this.services.weatherCodeLabel(weather?.weatherCode); if (this.localCondition)
this.localCondition.textContent = this.services.weatherCodeLabel(
weather?.weatherCode,
);
if (this.localCloud) { if (this.localCloud) {
this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct) this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct)
? `CLOUD ${Math.round(weather.cloudCoverPct)}%` : 'CLOUD UNKNOWN'; ? `CLOUD ${Math.round(weather.cloudCoverPct)}%`
: 'CLOUD UNKNOWN';
} }
if (this.localPrecipitation) { if (this.localPrecipitation) {
this.localPrecipitation.textContent = Number.isFinite(weather?.precipitationMm) this.localPrecipitation.textContent = Number.isFinite(
? weather.precipitationMm.toFixed(1) : '—'; weather?.precipitationMm,
)
? weather.precipitationMm.toFixed(1)
: '—';
} }
if (this.signalStream) this.signalStream.dataset.regionalStatus = payload?.status || 'partial'; if (this.signalStream)
this.signalStream.dataset.regionalStatus = payload?.status || 'partial';
if (this.briefPageIndex === 1 && this.briefSource) { if (this.briefPageIndex === 1 && this.briefSource) {
this.briefSource.textContent = `${String(payload?.newsSource || 'REGIONAL NEWS').toUpperCase()} · LOCATION QUERY`; this.briefSource.textContent = `${String(payload?.newsSource || 'REGIONAL NEWS').toUpperCase()} · LOCATION QUERY`;
} }
this.scheduleContextLayout(); this.scheduleContextLayout();
} }
+176 -41
View File
@@ -1,7 +1,23 @@
/** Camera placement, ground acquisition and motion correction for the Cockpit controller. */ /** Camera placement, ground acquisition and motion correction for the Cockpit controller. */
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { cockpitAnchorCorrectionStep, cockpitGroundSafeHeight, cockpitSurfaceWaitExpired, cockpitUiUpdateDue, slewHeading } from '../cockpitMath.js'; import {
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'; 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() { export function update() {
if (this.destroyed) return false; if (this.destroyed) return false;
@@ -26,21 +42,37 @@ export function update() {
// NEXT/PREV selection can otherwise spend one frame driving the old // NEXT/PREV selection can otherwise spend one frame driving the old
// aircraft with the new aircraft's metadata. // aircraft with the new aircraft's metadata.
this._adoptTrackedEntity(nowMs, info); this._adoptTrackedEntity(nowMs, info);
if (!info || !this.trackedEntity || !this.viewer.entities.contains(this.trackedEntity)) { if (
!info ||
!this.trackedEntity ||
!this.viewer.entities.contains(this.trackedEntity)
) {
if (nowMs < this.contextNavigationDeadlineMs) return; if (nowMs < this.contextNavigationDeadlineMs) return;
this.exit({ restoreTracking: false }); this.exit({ restoreTracking: false });
return; return;
} }
if (!cockpitUiUpdateDue(nowMs, this.lastCameraUpdateMs, COCKPIT_CAMERA_UPDATE_MS)) return; if (
!cockpitUiUpdateDue(
nowMs,
this.lastCameraUpdateMs,
COCKPIT_CAMERA_UPDATE_MS,
)
)
return;
this.lastCameraUpdateMs = nowMs; this.lastCameraUpdateMs = nowMs;
const target = this.trackedEntity.position.getValue(this.viewer.clock.currentTime, this.scratchTarget); const target = this.trackedEntity.position.getValue(
this.viewer.clock.currentTime,
this.scratchTarget,
);
if (!target) return; if (!target) return;
const dtSec = Math.min(0.1, Math.max(0, (nowMs - this.lastFrameMs) / 1000)); const dtSec = Math.min(0.1, Math.max(0, (nowMs - this.lastFrameMs) / 1000));
this.lastFrameMs = nowMs; this.lastFrameMs = nowMs;
if (Number.isFinite(info.track)) { if (Number.isFinite(info.track)) {
this.heading = slewHeading( this.heading = slewHeading(
this.heading ?? info.track, info.track, COCKPIT_HEADING_SLEW_DPS * dtSec, this.heading ?? info.track,
info.track,
COCKPIT_HEADING_SLEW_DPS * dtSec,
); );
} }
@@ -58,7 +90,9 @@ export function update() {
// sample-boundary corrections to the camera. // sample-boundary corrections to the camera.
const headingRad = Cesium.Math.toRadians(this.heading ?? 0); const headingRad = Cesium.Math.toRadians(this.heading ?? 0);
const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG); const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG);
const speedMps = Number.isFinite(info.velocityMps) ? Math.max(0, info.velocityMps) : 0; const speedMps = Number.isFinite(info.velocityMps)
? Math.max(0, info.velocityMps)
: 0;
if (info.stale) { if (info.stale) {
// A feed backoff has no authoritative velocity epoch to advance from. // A feed backoff has no authoritative velocity epoch to advance from.
@@ -67,25 +101,53 @@ export function update() {
Cesium.Cartesian3.clone(target, this.cockpitAnchor); Cesium.Cartesian3.clone(target, this.cockpitAnchor);
} else { } else {
Cesium.Transforms.eastNorthUpToFixedFrame( 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.x = Math.sin(headingRad);
this.scratchLocal.y = Math.cos(headingRad); this.scratchLocal.y = Math.cos(headingRad);
this.scratchLocal.z = 0; 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); Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal);
Cesium.Cartesian3.multiplyByScalar( Cesium.Cartesian3.multiplyByScalar(
this.scratchHorizontal, speedMps * dtSec, this.scratchAdvance, 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,
); );
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) { if (correctionStepM > 0 && correctionDistanceM > 0) {
Cesium.Cartesian3.multiplyByScalar( Cesium.Cartesian3.multiplyByScalar(
this.scratchCorrection, correctionStepM / correctionDistanceM, this.scratchCorrection, this.scratchCorrection,
correctionStepM / correctionDistanceM,
this.scratchCorrection,
);
Cesium.Cartesian3.add(
this.cockpitAnchor,
this.scratchCorrection,
this.cockpitAnchor,
); );
Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchCorrection, this.cockpitAnchor);
} }
} }
@@ -95,18 +157,34 @@ export function update() {
// aircraft rendering. For a slow contact whose floor cell is still cold, // aircraft rendering. For a slow contact whose floor cell is still cold,
// its already-clamped render position is a conservative temporary floor. // its already-clamped render position is a conservative temporary floor.
const anchorCartographic = Cesium.Cartographic.fromCartesian( const anchorCartographic = Cesium.Cartographic.fromCartesian(
this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchAnchorCartographic, this.cockpitAnchor,
Cesium.Ellipsoid.WGS84,
this.scratchAnchorCartographic,
); );
const targetCartographic = Cesium.Cartographic.fromCartesian( const targetCartographic = Cesium.Cartographic.fromCartesian(
target, Cesium.Ellipsoid.WGS84, this.scratchTargetCartographic, target,
Cesium.Ellipsoid.WGS84,
this.scratchTargetCartographic,
);
let cockpitFloorM = this.services.cachedGroundFloor(
info.latitude,
info.longitude,
); );
let cockpitFloorM = this.services.cachedGroundFloor(info.latitude, info.longitude);
if (info.onGround === true) { if (info.onGround === true) {
const groundPoint = [{ lat: info.latitude, lon: info.longitude }]; const groundPoint = [{ lat: info.latitude, lon: info.longitude }];
this.services.warmGroundFloor(groundPoint); this.services.warmGroundFloor(groundPoint);
const meshFloorM = this.services.cachedMeshFloor(info.latitude, info.longitude); const meshFloorM = this.services.cachedMeshFloor(
info.latitude,
info.longitude,
);
if (this.services.meshFloorPreferred() && !Number.isFinite(meshFloorM)) { if (this.services.meshFloorPreferred() && !Number.isFinite(meshFloorM)) {
if (cockpitUiUpdateDue(nowMs, this.lastGroundProbeMs, COCKPIT_GROUND_PROBE_MS)) { if (
cockpitUiUpdateDue(
nowMs,
this.lastGroundProbeMs,
COCKPIT_GROUND_PROBE_MS,
)
) {
this.lastGroundProbeMs = nowMs; this.lastGroundProbeMs = nowMs;
const viewerCartographic = this.viewer.camera.positionCartographic; const viewerCartographic = this.viewer.camera.positionCartographic;
this.services.sampleMeshFloorCells(this.viewer.scene, groundPoint, { this.services.sampleMeshFloorCells(this.viewer.scene, groundPoint, {
@@ -115,16 +193,31 @@ export function update() {
viewerLon: Cesium.Math.toDegrees(viewerCartographic.longitude), viewerLon: Cesium.Math.toDegrees(viewerCartographic.longitude),
}); });
} }
cockpitFloorM = this.services.cachedMeshFloor(info.latitude, info.longitude); cockpitFloorM = this.services.cachedMeshFloor(
info.latitude,
info.longitude,
);
if (!Number.isFinite(cockpitFloorM)) { if (!Number.isFinite(cockpitFloorM)) {
if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs; if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs;
if (!cockpitSurfaceWaitExpired(nowMs, this.surfaceWaitStartedMs, COCKPIT_GROUND_WAIT_TIMEOUT_MS)) { if (
!cockpitSurfaceWaitExpired(
nowMs,
this.surfaceWaitStartedMs,
COCKPIT_GROUND_WAIT_TIMEOUT_MS,
)
) {
// Keep the already-safe map camera in place while the photoreal // Keep the already-safe map camera in place while the photoreal
// surface under a parked aircraft is acquired. The bounded wait // surface under a parked aircraft is acquired. The bounded wait
// prevents a permanently cold mesh cell from freezing cockpit. // prevents a permanently cold mesh cell from freezing cockpit.
this.surfaceAcquiring = true; this.surfaceAcquiring = true;
this.surfaceFallback = false; this.surfaceFallback = false;
if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) { if (
cockpitUiUpdateDue(
nowMs,
this.lastHudUpdateMs,
COCKPIT_HUD_UPDATE_MS,
)
) {
this.lastHudUpdateMs = nowMs; this.lastHudUpdateMs = nowMs;
this.updateHud(info, nowMs); this.updateHud(info, nowMs);
} }
@@ -143,10 +236,13 @@ export function update() {
this.surfaceAcquiring = false; this.surfaceAcquiring = false;
this.surfaceFallback = false; this.surfaceFallback = false;
} }
if (!Number.isFinite(cockpitFloorM) if (
&& speedMps < 90 !Number.isFinite(cockpitFloorM) &&
&& Number.isFinite(targetCartographic?.height)) { speedMps < 90 &&
cockpitFloorM = targetCartographic.height - this.services.GROUND_FLOOR_LIFT_M; Number.isFinite(targetCartographic?.height)
) {
cockpitFloorM =
targetCartographic.height - this.services.GROUND_FLOOR_LIFT_M;
} }
if (anchorCartographic && Number.isFinite(cockpitFloorM)) { if (anchorCartographic && Number.isFinite(cockpitFloorM)) {
const minimumAnchorHeightM = cockpitGroundSafeHeight( const minimumAnchorHeightM = cockpitGroundSafeHeight(
@@ -156,52 +252,88 @@ export function update() {
); );
if (minimumAnchorHeightM !== anchorCartographic.height) { if (minimumAnchorHeightM !== anchorCartographic.height) {
anchorCartographic.height = minimumAnchorHeightM; anchorCartographic.height = minimumAnchorHeightM;
Cesium.Ellipsoid.WGS84.cartographicToCartesian(anchorCartographic, this.cockpitAnchor); Cesium.Ellipsoid.WGS84.cartographicToCartesian(
anchorCartographic,
this.cockpitAnchor,
);
} }
} }
// Rebuild the local frame at the stabilized anchor after advancing it. // Rebuild the local frame at the stabilized anchor after advancing it.
Cesium.Transforms.eastNorthUpToFixedFrame( 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.x = Math.sin(headingRad);
this.scratchLocal.y = Math.cos(headingRad); this.scratchLocal.y = Math.cos(headingRad);
this.scratchLocal.z = 0; 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); Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal);
this.scratchLocal.x = Math.sin(headingRad) * Math.cos(pitchRad); this.scratchLocal.x = Math.sin(headingRad) * Math.cos(pitchRad);
this.scratchLocal.y = Math.cos(headingRad) * Math.cos(pitchRad); this.scratchLocal.y = Math.cos(headingRad) * Math.cos(pitchRad);
this.scratchLocal.z = Math.sin(pitchRad); this.scratchLocal.z = Math.sin(pitchRad);
Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchForward); Cesium.Matrix4.multiplyByPointAsVector(
this.scratchEnu,
this.scratchLocal,
this.scratchForward,
);
Cesium.Cartesian3.normalize(this.scratchForward, this.scratchForward); Cesium.Cartesian3.normalize(this.scratchForward, this.scratchForward);
this.scratchLocal.x = -Math.sin(headingRad) * Math.sin(pitchRad); this.scratchLocal.x = -Math.sin(headingRad) * Math.sin(pitchRad);
this.scratchLocal.y = -Math.cos(headingRad) * Math.sin(pitchRad); this.scratchLocal.y = -Math.cos(headingRad) * Math.sin(pitchRad);
this.scratchLocal.z = Math.cos(pitchRad); this.scratchLocal.z = Math.cos(pitchRad);
Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchUp); Cesium.Matrix4.multiplyByPointAsVector(
this.scratchEnu,
this.scratchLocal,
this.scratchUp,
);
Cesium.Cartesian3.normalize(this.scratchUp, this.scratchUp); Cesium.Cartesian3.normalize(this.scratchUp, this.scratchUp);
Cesium.Cartesian3.multiplyByScalar( Cesium.Cartesian3.multiplyByScalar(
this.scratchHorizontal, COCKPIT_FORWARD_OFFSET_M, this.scratchCamera, this.scratchHorizontal,
COCKPIT_FORWARD_OFFSET_M,
this.scratchCamera,
);
Cesium.Cartesian3.add(
this.cockpitAnchor,
this.scratchCamera,
this.scratchCamera,
); );
Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchCamera, this.scratchCamera);
Cesium.Matrix4.getTranslation(this.scratchEnu, this.scratchTarget); Cesium.Matrix4.getTranslation(this.scratchEnu, this.scratchTarget);
Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget); Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget);
Cesium.Cartesian3.multiplyByScalar(this.scratchTarget, COCKPIT_UP_OFFSET_M, this.scratchTarget); Cesium.Cartesian3.multiplyByScalar(
Cesium.Cartesian3.add(this.scratchCamera, this.scratchTarget, this.scratchCamera); 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 // Recheck at the final forward-offset camera coordinate because a taxiing
// aircraft can cross into an adjacent coarse floor cell between updates. // aircraft can cross into an adjacent coarse floor cell between updates.
const cameraCartographic = Cesium.Cartographic.fromCartesian( const cameraCartographic = Cesium.Cartographic.fromCartesian(
this.scratchCamera, Cesium.Ellipsoid.WGS84, this.scratchCameraCartographic, this.scratchCamera,
Cesium.Ellipsoid.WGS84,
this.scratchCameraCartographic,
); );
if (cameraCartographic) { if (cameraCartographic) {
const cameraLat = Cesium.Math.toDegrees(cameraCartographic.latitude); const cameraLat = Cesium.Math.toDegrees(cameraCartographic.latitude);
const cameraLon = Cesium.Math.toDegrees(cameraCartographic.longitude); const cameraLon = Cesium.Math.toDegrees(cameraCartographic.longitude);
const cameraFloorM = this.services.cachedGroundFloor(cameraLat, cameraLon); const cameraFloorM = this.services.cachedGroundFloor(cameraLat, cameraLon);
if (Number.isFinite(cameraFloorM)) { if (Number.isFinite(cameraFloorM)) {
cockpitFloorM = Math.max(cockpitFloorM ?? Number.NEGATIVE_INFINITY, cameraFloorM); cockpitFloorM = Math.max(
cockpitFloorM ?? Number.NEGATIVE_INFINITY,
cameraFloorM,
);
} }
const safeHeightM = cockpitGroundSafeHeight( const safeHeightM = cockpitGroundSafeHeight(
cameraCartographic.height, cameraCartographic.height,
@@ -210,7 +342,10 @@ export function update() {
); );
if (safeHeightM !== cameraCartographic.height) { if (safeHeightM !== cameraCartographic.height) {
cameraCartographic.height = safeHeightM; cameraCartographic.height = safeHeightM;
Cesium.Ellipsoid.WGS84.cartographicToCartesian(cameraCartographic, this.scratchCamera); Cesium.Ellipsoid.WGS84.cartographicToCartesian(
cameraCartographic,
this.scratchCamera,
);
} }
} }
@@ -222,4 +357,4 @@ export function update() {
this.lastHudUpdateMs = nowMs; this.lastHudUpdateMs = nowMs;
this.updateHud(info, nowMs); this.updateHud(info, nowMs);
} }
} }
+41 -18
View File
@@ -1,10 +1,16 @@
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */ /** Contact readouts and nearby-signal presentation for the Cockpit controller. */
import * as Cesium from 'cesium'; 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) { export function updateContext(info, heading) {
if (!this.context) return; if (!this.context) return;
const snapshot = this.services.militaryAwarenessLayer.getContextSnapshot?.() || null; const snapshot =
this.services.militaryAwarenessLayer.getContextSnapshot?.() || null;
const trackedId = info.icao24 || info.id; const trackedId = info.icao24 || info.id;
const readout = resolveCockpitContextReadout({ snapshot, info }); const readout = resolveCockpitContextReadout({ snapshot, info });
if (!readout.visible) { if (!readout.visible) {
@@ -23,8 +29,9 @@ export function updateContext(info, heading) {
this.context.hidden = false; this.context.hidden = false;
this.hud?.classList.add('context-active'); this.hud?.classList.add('context-active');
if (this.contextSubject) { if (this.contextSubject) {
const installationCoverage = snapshot.cohorts const installationCoverage = snapshot.cohorts.find(
.find((cohort) => cohort.id === 'military-installations')?.coverage; (cohort) => cohort.id === 'military-installations',
)?.coverage;
this.contextSubject.textContent = formatCockpitContextScope( this.contextSubject.textContent = formatCockpitContextScope(
snapshot.subject.label || trackedId, snapshot.subject.label || trackedId,
snapshot.radiusM, snapshot.radiusM,
@@ -33,8 +40,10 @@ export function updateContext(info, heading) {
} }
// Navigation stays wired in every state — the operator must always be able // 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. // to step off the current contact from the panel that hosts the controls.
if (this.contextPrevious) this.contextPrevious.disabled = !snapshot.navigation?.canPrevious; if (this.contextPrevious)
if (this.contextNext) this.contextNext.disabled = !snapshot.navigation?.canNext; this.contextPrevious.disabled = !snapshot.navigation?.canPrevious;
if (this.contextNext)
this.contextNext.disabled = !snapshot.navigation?.canNext;
if (readout.contactLost) { if (readout.contactLost) {
// The subject left its source. Every number below is measured against a // The subject left its source. Every number below is measured against a
@@ -43,7 +52,8 @@ export function updateContext(info, heading) {
const enteringLost = this.context.dataset.state !== 'lost'; const enteringLost = this.context.dataset.state !== 'lost';
this.context.dataset.state = 'lost'; this.context.dataset.state = 'lost';
if (this.contextUncertainty) { if (this.contextUncertainty) {
this.contextUncertainty.textContent = 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR'; 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 // The cue changes the footer's height; re-run layout once on the way in
// rather than every frame the contact stays lost. // rather than every frame the contact stays lost.
@@ -62,7 +72,8 @@ export function updateContext(info, heading) {
for (const cohort of snapshot.cohorts) { for (const cohort of snapshot.cohorts) {
const element = this.contextCohorts.get(cohort.id); const element = this.contextCohorts.get(cohort.id);
const value = element?.querySelector('strong'); const value = element?.querySelector('strong');
if (value) value.textContent = cohort.count === null ? '?' : String(cohort.count); if (value)
value.textContent = cohort.count === null ? '?' : String(cohort.count);
element?.classList.toggle('unknown', cohort.relationship === 'UNKNOWN'); element?.classList.toggle('unknown', cohort.relationship === 'UNKNOWN');
if (cohort.count === null) unknownCount += 1; if (cohort.count === null) unknownCount += 1;
for (const item of cohort.nearest) nearest.push({ ...item, cohort }); for (const item of cohort.nearest) nearest.push({ ...item, cohort });
@@ -72,7 +83,8 @@ export function updateContext(info, heading) {
const closestLabel = this.services.formatAwarenessLabel(closest); const closestLabel = this.services.formatAwarenessLabel(closest);
if (this.contextNearestLabel) { if (this.contextNearestLabel) {
this.contextNearestLabel.textContent = closest this.contextNearestLabel.textContent = closest
? `${closest.cohort.label.toUpperCase()} · ${closestLabel}` : 'NO AVAILABLE EXAMPLE'; ? `${closest.cohort.label.toUpperCase()} · ${closestLabel}`
: 'NO AVAILABLE EXAMPLE';
this.contextNearestLabel.setAttribute( this.contextNearestLabel.setAttribute(
'aria-label', 'aria-label',
closest && closestLabel === '—' closest && closestLabel === '—'
@@ -83,10 +95,13 @@ export function updateContext(info, heading) {
if (this.contextDistance) { if (this.contextDistance) {
const distanceM = closest?.distanceM; const distanceM = closest?.distanceM;
this.contextDistance.textContent = Number.isFinite(distanceM) this.contextDistance.textContent = Number.isFinite(distanceM)
? `${distanceM < 10000 ? (distanceM / 1000).toFixed(1) : Math.round(distanceM / 1000)} KM` : '—'; ? `${distanceM < 10000 ? (distanceM / 1000).toFixed(1) : Math.round(distanceM / 1000)} KM`
: '—';
this.contextDistance.setAttribute( this.contextDistance.setAttribute(
'aria-label', 'aria-label',
Number.isFinite(distanceM) ? this.contextDistance.textContent : 'Unavailable', Number.isFinite(distanceM)
? this.contextDistance.textContent
: 'Unavailable',
); );
} }
@@ -96,15 +111,21 @@ export function updateContext(info, heading) {
// dashed rather than presented alongside subject-frame distances as if the // dashed rather than presented alongside subject-frame distances as if the
// two shared an origin. // two shared an origin.
let relative = null; let relative = null;
if (readout.aircraftRelative if (
&& closest?.position && Number.isFinite(info.latitude) && Number.isFinite(info.longitude)) { readout.aircraftRelative &&
closest?.position &&
Number.isFinite(info.latitude) &&
Number.isFinite(info.longitude)
) {
const cartographic = Cesium.Cartographic.fromCartesian(closest.position); const cartographic = Cesium.Cartographic.fromCartesian(closest.position);
const bearing = cartographic ? bearingBetweenCoordinates( const bearing = cartographic
? bearingBetweenCoordinates(
info.latitude, info.latitude,
info.longitude, info.longitude,
Cesium.Math.toDegrees(cartographic.latitude), Cesium.Math.toDegrees(cartographic.latitude),
Cesium.Math.toDegrees(cartographic.longitude), Cesium.Math.toDegrees(cartographic.longitude),
) : null; )
: null;
relative = relativeBearing(bearing, heading); relative = relativeBearing(bearing, heading);
} }
if (this.contextDirection) { if (this.contextDirection) {
@@ -114,7 +135,8 @@ export function updateContext(info, heading) {
if (this.contextBearing) { if (this.contextBearing) {
if (relative === null) this.contextBearing.textContent = 'BRG —'; if (relative === null) this.contextBearing.textContent = 'BRG —';
else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD'; 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')}°`; else
this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`;
} }
if (this.contextUncertainty) { if (this.contextUncertainty) {
this.contextUncertainty.textContent = unknownCount this.contextUncertainty.textContent = unknownCount
@@ -123,7 +145,8 @@ export function updateContext(info, heading) {
} }
if (this.contextUpdated) { if (this.contextUpdated) {
this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt) this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt)
? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z' : '--:--:--Z'; ? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z'
: '--:--:--Z';
} }
this.context.dataset.state = unknownCount ? 'uncertain' : 'current'; this.context.dataset.state = unknownCount ? 'uncertain' : 'current';
this.updateCockpitSignals(snapshot, unknownCount); this.updateCockpitSignals(snapshot, unknownCount);
@@ -131,4 +154,4 @@ export function updateContext(info, heading) {
this.contextLayoutStamp = snapshot.evaluatedAt; this.contextLayoutStamp = snapshot.evaluatedAt;
this.scheduleContextLayout(); this.scheduleContextLayout();
} }
} }
+246 -81
View File
@@ -1,16 +1,55 @@
/** Coordinate Cockpit lifecycle through supplied aircraft, terrain, rendering and briefing operations. */ /** 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 { 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 { updateContext } from './cockpitContext.js';
import { showBriefPage, setBriefAutoRotate, startBriefRotation, stopBriefRotation, updateLocalPosition, maybeRefreshRegionalBrief, renderRegionalBriefStatus, renderRegionalBrief } from './cockpitBriefing.js'; import {
import { handleSignalClick, renderCockpitSignals, pushCockpitSignal, updateCockpitSignals } from './cockpitSignals.js'; showBriefPage,
import { scheduleContextLayout, setContextCollapsed, setSignalCollapsed, syncContextLayout, syncSignalLayout } from './cockpitLayout.js'; 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 { onKeyDown } from './cockpitInput.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
export class CockpitViewController { export class CockpitViewController {
constructor(viewer, { constructor(
viewer,
{
services, services,
onVisionChange = null, onVisionChange = null,
onCameraTakeover = null, onCameraTakeover = null,
@@ -19,7 +58,8 @@ export class CockpitViewController {
onExited = null, onExited = null,
getInheritedVisionLabel = null, getInheritedVisionLabel = null,
restoreTrackingFrame = null, restoreTrackingFrame = null,
} = {}) { } = {},
) {
this.viewer = viewer; this.viewer = viewer;
this.services = services; this.services = services;
this.destroyed = false; this.destroyed = false;
@@ -48,11 +88,17 @@ export class CockpitViewController {
this.speed = document.getElementById('cockpit-speed-value'); this.speed = document.getElementById('cockpit-speed-value');
this.speedRim = document.getElementById('cockpit-speed-rim'); this.speedRim = document.getElementById('cockpit-speed-rim');
this.speedRimValue = document.getElementById('cockpit-speed-rim-value'); 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.altitude = document.getElementById('cockpit-altitude-value');
this.altitudeRim = document.getElementById('cockpit-altitude-rim'); this.altitudeRim = document.getElementById('cockpit-altitude-rim');
this.altitudeRimValue = document.getElementById('cockpit-altitude-rim-value'); this.altitudeRimValue = document.getElementById(
this.altitudeRimTicks = Array.from(document.querySelectorAll('[data-altitude-rim-tick]')); 'cockpit-altitude-rim-value',
);
this.altitudeRimTicks = Array.from(
document.querySelectorAll('[data-altitude-rim-tick]'),
);
this.headingValue = document.getElementById('cockpit-heading-value'); this.headingValue = document.getElementById('cockpit-heading-value');
this.compassTape = document.getElementById('cockpit-compass-tape'); this.compassTape = document.getElementById('cockpit-compass-tape');
this.clock = document.getElementById('cockpit-clock'); this.clock = document.getElementById('cockpit-clock');
@@ -63,33 +109,49 @@ export class CockpitViewController {
this.routeTo = document.getElementById('cockpit-route-to'); this.routeTo = document.getElementById('cockpit-route-to');
this.routeStatus = document.getElementById('cockpit-route-status'); this.routeStatus = document.getElementById('cockpit-route-status');
this.routeDirection = document.getElementById('cockpit-route-direction'); 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.visionPrevious = document.getElementById('cockpit-vision-previous');
this.visionCurrent = document.getElementById('cockpit-vision-current'); 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.visionNext = document.getElementById('cockpit-vision-next');
this.visionMode = 'optical'; this.visionMode = 'optical';
this.onVisionChange = onVisionChange; this.onVisionChange = onVisionChange;
this.onCameraTakeover = onCameraTakeover; this.onCameraTakeover = onCameraTakeover;
this.onEntered = onEntered; this.onEntered = onEntered;
this.onExited = onExited; this.onExited = onExited;
this.getInheritedVisionLabel = typeof getInheritedVisionLabel === 'function' this.getInheritedVisionLabel =
typeof getInheritedVisionLabel === 'function'
? getInheritedVisionLabel ? getInheritedVisionLabel
: () => 'NORMAL'; : () => 'NORMAL';
this.restoreTrackingFrame = typeof restoreTrackingFrame === 'function' this.restoreTrackingFrame =
typeof restoreTrackingFrame === 'function'
? restoreTrackingFrame ? restoreTrackingFrame
: () => false; : () => false;
this.isEntryAllowed = typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true; this.isEntryAllowed =
typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true;
this.context = document.getElementById('cockpit-context'); this.context = document.getElementById('cockpit-context');
this.contextSubject = document.getElementById('cockpit-context-subject'); 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.contextBearing = document.getElementById('cockpit-context-bearing');
this.contextDistance = document.getElementById('cockpit-context-distance'); this.contextDistance = document.getElementById('cockpit-context-distance');
this.contextDirection = document.getElementById('cockpit-context-direction'); this.contextDirection = document.getElementById(
this.contextUncertainty = document.getElementById('cockpit-context-uncertainty'); 'cockpit-context-direction',
);
this.contextUncertainty = document.getElementById(
'cockpit-context-uncertainty',
);
this.contextUpdated = document.getElementById('cockpit-context-updated'); this.contextUpdated = document.getElementById('cockpit-context-updated');
this.contextCohorts = new Map(Array.from(document.querySelectorAll('[data-context-cohort]')) this.contextCohorts = new Map(
.map((element) => [element.dataset.contextCohort, element])); Array.from(document.querySelectorAll('[data-context-cohort]')).map(
(element) => [element.dataset.contextCohort, element],
),
);
this.contextPrevious = document.getElementById('cockpit-context-previous'); this.contextPrevious = document.getElementById('cockpit-context-previous');
this.contextNext = document.getElementById('cockpit-context-next'); this.contextNext = document.getElementById('cockpit-context-next');
this.contextToggle = document.getElementById('cockpit-context-toggle'); this.contextToggle = document.getElementById('cockpit-context-toggle');
@@ -106,18 +168,30 @@ export class CockpitViewController {
this.briefAutoToggle = document.getElementById('cockpit-brief-auto'); this.briefAutoToggle = document.getElementById('cockpit-brief-auto');
this.briefPosition = document.getElementById('cockpit-brief-position'); this.briefPosition = document.getElementById('cockpit-brief-position');
this.briefSource = document.getElementById('cockpit-brief-source'); this.briefSource = document.getElementById('cockpit-brief-source');
this.briefPages = Array.from(document.querySelectorAll('[data-cockpit-brief-page]')); this.briefPages = Array.from(
this.briefTabs = Array.from(document.querySelectorAll('[data-cockpit-brief-index]')); document.querySelectorAll('[data-cockpit-brief-page]'),
);
this.briefTabs = Array.from(
document.querySelectorAll('[data-cockpit-brief-index]'),
);
this.newsStatus = document.getElementById('cockpit-news-status'); this.newsStatus = document.getElementById('cockpit-news-status');
this.newsList = document.getElementById('cockpit-news-list'); this.newsList = document.getElementById('cockpit-news-list');
this.localPlace = document.getElementById('cockpit-local-place'); this.localPlace = document.getElementById('cockpit-local-place');
this.localCoordinates = document.getElementById('cockpit-local-coordinates'); this.localCoordinates = document.getElementById(
this.localTemperature = document.getElementById('cockpit-local-temperature'); 'cockpit-local-coordinates',
);
this.localTemperature = document.getElementById(
'cockpit-local-temperature',
);
this.localWind = document.getElementById('cockpit-local-wind'); 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.localCondition = document.getElementById('cockpit-local-condition');
this.localCloud = document.getElementById('cockpit-local-cloud'); 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.signalCollapsed = false;
this.signalUserCollapsed = false; this.signalUserCollapsed = false;
this.signalItems = []; this.signalItems = [];
@@ -166,32 +240,50 @@ export class CockpitViewController {
this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1)); this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1));
this._listen(this.visionCurrent, 'click', () => this.cycleVisionMode(1)); this._listen(this.visionCurrent, 'click', () => this.cycleVisionMode(1));
this._listen(this.visionNext, '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.contextPrevious, 'click', () =>
this._listen(this.contextNext, 'click', () => this.navigateContext(1, { origin: 'user' })); this.navigateContext(-1, { origin: 'user' }),
this._listen(this.contextToggle, 'click', () => this.setContextCollapsed(!this.contextCollapsed)); );
this._listen(this.contextNext, 'click', () =>
this.navigateContext(1, { origin: 'user' }),
);
this._listen(this.contextToggle, 'click', () =>
this.setContextCollapsed(!this.contextCollapsed),
);
this._listen(this.weatherToggle, 'click', () => { this._listen(this.weatherToggle, 'click', () => {
const enabled = this.weatherToggle.getAttribute('aria-pressed') !== 'true'; const enabled =
this.weatherToggle.getAttribute('aria-pressed') !== 'true';
this.syncWeatherToggle(enabled); this.syncWeatherToggle(enabled);
window.dispatchEvent(new CustomEvent('gev:cockpit-weather-toggle', { window.dispatchEvent(
new CustomEvent('gev:cockpit-weather-toggle', {
detail: { enabled }, detail: { enabled },
})); }),
);
}); });
this._listen(window, 'gev:cockpit-weather-state', (event) => { this._listen(window, 'gev:cockpit-weather-state', (event) => {
this.syncWeatherToggle(event?.detail?.enabled !== false); this.syncWeatherToggle(event?.detail?.enabled !== false);
}); });
this._listen(this.signalToggle, 'click', () => this.setSignalCollapsed( this._listen(this.signalToggle, 'click', () =>
!this.signalCollapsed, this.setSignalCollapsed(!this.signalCollapsed, { user: true }),
{ user: true }, );
)); this._listen(this.signalList, 'click', (event) =>
this._listen(this.signalList, 'click', (event) => this.handleSignalClick(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.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._listen(this.briefAutoToggle, 'click', () => {
this.setBriefAutoRotate(!this.briefAutoRotateEnabled); this.setBriefAutoRotate(!this.briefAutoRotateEnabled);
}); });
this.briefTabs.forEach((button) => this._listen(button, 'click', () => { this.briefTabs.forEach((button) =>
this.showBriefPage(Number(button.dataset.cockpitBriefIndex), { manual: true }); this._listen(button, 'click', () => {
})); this.showBriefPage(Number(button.dataset.cockpitBriefIndex), {
manual: true,
});
}),
);
this._listen(document, 'visibilitychange', () => { this._listen(document, 'visibilitychange', () => {
if (document.hidden) this.stopBriefRotation(); if (document.hidden) this.stopBriefRotation();
else if (this.briefAutoRotateEnabled) this.startBriefRotation(); else if (this.briefAutoRotateEnabled) this.startBriefRotation();
@@ -203,45 +295,117 @@ export class CockpitViewController {
_listen(target, type, handler, options) { _listen(target, type, handler, options) {
if (!target?.addEventListener) return; if (!target?.addEventListener) return;
target.addEventListener(type, handler, options); 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); } readAircraftInfo() {
dispatchCockpitModeChanged(active, info) { return dispatchCockpitModeChanged.call(this, active, info); } return readAircraftInfo.call(this);
toggleTrackedTr3b() { return toggleTrackedTr3b.call(this); } }
syncTr3bToggle(info) { return syncTr3bToggle.call(this, info); } dispatchCockpitModeChanged(active, info) {
syncEntry() { return syncEntry.call(this); } return dispatchCockpitModeChanged.call(this, active, info);
navigateContext(direction, options) { return navigateContext.call(this, direction, options); } }
_adoptTrackedEntity(nowMs, suppliedInfo) { return _adoptTrackedEntity.call(this, nowMs, suppliedInfo); } toggleTrackedTr3b() {
enter() { return enter.call(this); } return toggleTrackedTr3b.call(this);
exit(options) { return exit.call(this, options); } }
update() { return update.call(this); } syncTr3bToggle(info) {
updateHud(info, nowMs, forceContext) { return updateHud.call(this, info, nowMs, forceContext); } return syncTr3bToggle.call(this, info);
updateRoute(info) { return updateRoute.call(this, info); } }
syncWeatherToggle(enabled) { return syncWeatherToggle.call(this, enabled); } syncEntry() {
setVisionMode(mode, options) { return setVisionMode.call(this, mode, options); } return syncEntry.call(this);
cycleVisionMode(direction) { return cycleVisionMode.call(this, direction); } }
clearPredictiveRoute() { return clearPredictiveRoute.call(this); } navigateContext(direction, options) {
updateContext(info, heading) { return updateContext.call(this, info, heading); } return navigateContext.call(this, direction, options);
showBriefPage(index, options) { return showBriefPage.call(this, index, options); } }
setBriefAutoRotate(enabled) { return setBriefAutoRotate.call(this, enabled); } _adoptTrackedEntity(nowMs, suppliedInfo) {
startBriefRotation(options) { return startBriefRotation.call(this, options); } return _adoptTrackedEntity.call(this, nowMs, suppliedInfo);
stopBriefRotation() { return stopBriefRotation.call(this); } }
updateLocalPosition(info) { return updateLocalPosition.call(this, info); } enter() {
maybeRefreshRegionalBrief(info) { return maybeRefreshRegionalBrief.call(this, info); } return enter.call(this);
renderRegionalBriefStatus(status, info) { return renderRegionalBriefStatus.call(this, status, info); } }
renderRegionalBrief(payload, info) { return renderRegionalBrief.call(this, payload, info); } exit(options) {
renderCockpitSignals() { return renderCockpitSignals.call(this); } return exit.call(this, options);
pushCockpitSignal(key, tone, title, detail, target) { return pushCockpitSignal.call(this, key, tone, title, detail, target); } }
updateCockpitSignals(snapshot, unknownCount) { return updateCockpitSignals.call(this, snapshot, unknownCount); } update() {
scheduleContextLayout() { return scheduleContextLayout.call(this); } return update.call(this);
setContextCollapsed(collapsed) { return setContextCollapsed.call(this, collapsed); } }
setSignalCollapsed(collapsed, options) { return setSignalCollapsed.call(this, collapsed, options); } updateHud(info, nowMs, forceContext) {
syncContextLayout() { return syncContextLayout.call(this); } return updateHud.call(this, info, nowMs, forceContext);
syncSignalLayout() { return syncSignalLayout.call(this); } }
onKeyDown(event) { return onKeyDown.call(this, event); } 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() { dispose() {
if (this.destroyed) return; if (this.destroyed) return;
@@ -251,9 +415,10 @@ export class CockpitViewController {
this.regionalBriefAbort = null; this.regionalBriefAbort = null;
this.regionalBriefRequestToken += 1; this.regionalBriefRequestToken += 1;
this.stopBriefRotation(); this.stopBriefRotation();
if (this.contextLayoutFrame !== null) cancelAnimationFrame(this.contextLayoutFrame); if (this.contextLayoutFrame !== null)
cancelAnimationFrame(this.contextLayoutFrame);
this.contextLayoutFrame = null; this.contextLayoutFrame = null;
for (const removeListener of this._listenerRemovers.splice(0)) removeListener?.(); for (const removeListener of this._listenerRemovers.splice(0))
removeListener?.();
} }
} }
+63 -19
View File
@@ -13,13 +13,27 @@ function environment() {
globalThis.document = document; globalThis.document = document;
globalThis.window = window; globalThis.window = window;
const callbacks = new Set(); 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 = { const viewer = {
scene: { preUpdate: event, screenSpaceCameraController: { enableInputs: false } }, scene: {
preUpdate: event,
screenSpaceCameraController: { enableInputs: false },
},
trackedEntityChanged: event, trackedEntityChanged: event,
entities: { contains: () => false }, 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', () => { 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.toggleTrackedTr3b(), false);
assert.equal(owner.update(), false); assert.equal(owner.update(), false);
assert.equal(owner.active, false); assert.equal(owner.active, false);
} finally { env.restore(); } } finally {
env.restore();
}
}); });
test('disposing an active Cockpit releases the supplied render owner without retracking', () => { 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 { try {
const released = []; const released = [];
const owner = new CockpitViewController(env.viewer, { const owner = new CockpitViewController(env.viewer, {
services: { releaseContinuousRender: reason => released.push(reason) }, services: { releaseContinuousRender: (reason) => released.push(reason) },
}); });
owner.active = true; owner.active = true;
owner.dispose(); owner.dispose();
owner.dispose(); owner.dispose();
assert.deepEqual(released, ['cockpit']); 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(env.viewer.trackedEntity, undefined);
assert.equal(owner.active, false); assert.equal(owner.active, false);
} finally { env.restore(); } } finally {
env.restore();
}
}); });
test('aircraft identity is read from the supplied layer instances', () => { test('aircraft identity is read from the supplied layer instances', () => {
const env = environment(); const env = environment();
try { try {
const civilian = { icao24: 'abc123', layerId: 'flights', callsign: 'CIVIL' }; const civilian = {
const military = { icao24: 'def456', layerId: 'military', callsign: 'MILITARY' }; icao24: 'abc123',
const owner = new CockpitViewController(env.viewer, { services: { layerId: 'flights',
callsign: 'CIVIL',
};
const military = {
icao24: 'def456',
layerId: 'military',
callsign: 'MILITARY',
};
const owner = new CockpitViewController(env.viewer, {
services: {
flightsLayer: { getTrackedInfo: () => civilian }, flightsLayer: { getTrackedInfo: () => civilian },
militaryFlightsLayer: { getTrackedInfo: () => military }, militaryFlightsLayer: { getTrackedInfo: () => military },
} }); },
});
env.viewer.trackedEntity = { gevTrackedId: 'military:def456' }; env.viewer.trackedEntity = { gevTrackedId: 'military:def456' };
assert.deepEqual(owner.readAircraftInfo(), military); assert.deepEqual(owner.readAircraftInfo(), military);
env.viewer.trackedEntity = { gevTrackedId: 'flights:abc123' }; env.viewer.trackedEntity = { gevTrackedId: 'flights:abc123' };
assert.deepEqual(owner.readAircraftInfo(), civilian); assert.deepEqual(owner.readAircraftInfo(), civilian);
owner.dispose(); owner.dispose();
} finally { env.restore(); } } finally {
env.restore();
}
}); });
test('an obsolete regional briefing cannot publish after Cockpit disposal', async () => { 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 { try {
let finish; let finish;
let signal; let signal;
const owner = new CockpitViewController(env.viewer, { services: { const owner = new CockpitViewController(env.viewer, {
services: {
regionalDistanceM: () => Infinity, regionalDistanceM: () => Infinity,
fetchRegionalBrief: (_lat, _lon, options) => { fetchRegionalBrief: (_lat, _lon, options) => {
signal = options.signal; signal = options.signal;
return new Promise(resolve => { finish = resolve; }); return new Promise((resolve) => {
finish = resolve;
});
}, },
releaseContinuousRender() {}, releaseContinuousRender() {},
} }); },
});
const published = []; const published = [];
owner.renderRegionalBrief = payload => published.push(payload); owner.renderRegionalBrief = (payload) => published.push(payload);
owner.active = true; 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(); owner.dispose();
assert.equal(signal.aborted, true); assert.equal(signal.aborted, true);
finish({ articles: [] }); finish({ articles: [] });
await new Promise(resolve => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(published, []); assert.deepEqual(published, []);
assert.equal(owner.regionalBriefAbort, null); assert.equal(owner.regionalBriefAbort, null);
} finally { env.restore(); } } finally {
env.restore();
}
}); });
+37 -12
View File
@@ -11,7 +11,9 @@ export class CockpitDisplayPortal {
this.frames = new Set(); this.frames = new Set();
this.listeners = new AbortController(); this.listeners = new AbortController();
this.records = groups.flatMap(([name, group]) => { 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 []; if (!group || !slot || !group.parentNode) return [];
const anchor = document.createComment(`cockpit-display-home:${name}`); const anchor = document.createComment(`cockpit-display-home:${name}`);
group.before(anchor); group.before(anchor);
@@ -20,15 +22,27 @@ export class CockpitDisplayPortal {
this.standardScrollTop = standardPanel?.scrollTop || 0; this.standardScrollTop = standardPanel?.scrollTop || 0;
this.cockpitScrollTop = cockpitPanel?.scrollTop || 0; this.cockpitScrollTop = cockpitPanel?.scrollTop || 0;
const options = { passive: true, signal: this.listeners.signal }; const options = { passive: true, signal: this.listeners.signal };
standardPanel?.addEventListener('scroll', () => { standardPanel?.addEventListener(
'scroll',
() => {
if (!this.active) this.standardScrollTop = standardPanel.scrollTop; if (!this.active) this.standardScrollTop = standardPanel.scrollTop;
}, options); },
cockpitPanel?.addEventListener('scroll', () => { options,
);
cockpitPanel?.addEventListener(
'scroll',
() => {
if (this.active) this.cockpitScrollTop = cockpitPanel.scrollTop; if (this.active) this.cockpitScrollTop = cockpitPanel.scrollTop;
}, options); },
window.addEventListener('gev:cockpit-mode-changed', event => { options,
);
window.addEventListener(
'gev:cockpit-mode-changed',
(event) => {
this.setActive(event?.detail?.active === true); this.setActive(event?.detail?.active === true);
}, { signal: this.listeners.signal }); },
{ signal: this.listeners.signal },
);
this.setActive(document.body.classList.contains('cockpit-mode')); this.setActive(document.body.classList.contains('cockpit-mode'));
} }
@@ -52,7 +66,9 @@ export class CockpitDisplayPortal {
if (this.active === nextActive) return; if (this.active === nextActive) return;
this._cancelFrames(); this._cancelFrames();
const generation = this.generation; 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; const focusedElement = focusedRecord ? document.activeElement : null;
this.restoreOwner = nextActive ? 'cockpit' : 'standard'; this.restoreOwner = nextActive ? 'cockpit' : 'standard';
this.active = nextActive; this.active = nextActive;
@@ -60,12 +76,21 @@ export class CockpitDisplayPortal {
if (nextActive) record.slot.append(record.group); if (nextActive) record.slot.append(record.group);
else if (record.anchor.parentNode) record.anchor.after(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 = () => { const restoreScroll = () => {
if (nextActive && this.cockpitPanel) this.cockpitPanel.scrollTop = this.cockpitScrollTop; if (nextActive && this.cockpitPanel)
if (!nextActive && this.standardPanel) this.standardPanel.scrollTop = this.standardScrollTop; 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, () => { this._frame(generation, () => {
restoreScroll(); restoreScroll();
focusedElement?.focus?.({ preventScroll: true }); focusedElement?.focus?.({ preventScroll: true });
+107 -30
View File
@@ -3,49 +3,115 @@ import assert from 'node:assert/strict';
import { CockpitDisplayPortal } from './cockpitDisplayPortal.js'; import { CockpitDisplayPortal } from './cockpitDisplayPortal.js';
function fixture() { 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(); const frames = new Map();
let nextFrame = 0; let nextFrame = 0;
let focusCalls = 0; let focusCalls = 0;
let layouts = 0; let layouts = 0;
class Node extends EventTarget { class Node extends EventTarget {
constructor(name) { 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() {} }; this.classList = { toggle() {} };
} }
append(node) { append(node) {
node.remove(); this.children.push(node); node.parentNode = this; node.remove();
this.children.push(node);
node.parentNode = this;
} }
remove() { 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; this.parentNode = null;
} }
before(node) { before(node) {
node.remove(); const parent = this.parentNode; node.remove();
parent.children.splice(parent.children.indexOf(this), 0, node); node.parentNode = parent; const parent = this.parentNode;
parent.children.splice(parent.children.indexOf(this), 0, node);
node.parentNode = parent;
} }
after(node) { after(node) {
node.remove(); const parent = this.parentNode; node.remove();
parent.children.splice(parent.children.indexOf(this) + 1, 0, node); node.parentNode = parent; 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)); } contains(node) {
focus() { focusCalls += 1; document.activeElement = this; } return (
this === node || this.children.some((child) => child.contains(node))
);
} }
const standard = new Node('standard'); standard.scrollTop = 71; focus() {
const cockpit = new Node('cockpit'); cockpit.scrollTop = 19; focusCalls += 1;
const group = new Node('group'); const sibling = new Node('sibling'); 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 slot = new Node('slot'); 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; 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.window = new EventTarget();
globalThis.requestAnimationFrame = fn => { const id = ++nextFrame; frames.set(id, fn); return id; }; globalThis.requestAnimationFrame = (fn) => {
globalThis.cancelAnimationFrame = id => frames.delete(id); const id = ++nextFrame;
const create = () => new CockpitDisplayPortal({ standardPanel: standard, cockpitPanel: cockpit, groups: [['hud', group]], layout: () => { layouts += 1; } }); frames.set(id, fn);
return { standard, cockpit, group, sibling, slot, frames, create, return id;
get focusCalls() { return focusCalls; }, get layouts() { return layouts; }, };
flush() { while (frames.size) { const batch = [...frames.values()]; frames.clear(); batch.forEach(fn => fn()); } }, globalThis.cancelAnimationFrame = (id) => frames.delete(id);
restore() { Object.assign(globalThis, prior); }, 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(); owner.destroy();
assert.deepEqual(f.standard.children, [f.group, f.sibling]); assert.deepEqual(f.standard.children, [f.group, f.sibling]);
assert.equal(f.frames.size, 0); assert.equal(f.frames.size, 0);
} finally { f.restore(); } } finally {
f.restore();
}
}); });
test('superseded portal frames cannot restore obsolete focus or scroll', () => { 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()]; const obsolete = [...f.frames.values()];
owner.setActive(false); owner.setActive(false);
f.cockpit.scrollTop = 93; f.cockpit.scrollTop = 93;
obsolete.forEach(callback => callback()); obsolete.forEach((callback) => callback());
assert.equal(f.focusCalls, 0); assert.equal(f.focusCalls, 0);
assert.equal(f.cockpit.scrollTop, 93); assert.equal(f.cockpit.scrollTop, 93);
f.flush(); f.flush();
assert.equal(f.focusCalls, 1); assert.equal(f.focusCalls, 1);
assert.equal(owner.restoreOwner, null); assert.equal(owner.restoreOwner, null);
owner.destroy(); owner.destroy();
} finally { f.restore(); } } finally {
f.restore();
}
}); });
test('disposal restores groups and revokes mode listeners and queued focus work', () => { test('disposal restores groups and revokes mode listeners and queued focus work', () => {
const f = fixture(); const f = fixture();
try { try {
const owner = f.create(); 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); assert.equal(f.group.parentNode, f.slot);
const obsolete = [...f.frames.values()]; const obsolete = [...f.frames.values()];
owner.destroy(); owner.destroy(); owner.destroy();
obsolete.forEach(callback => callback()); owner.destroy();
window.dispatchEvent(new CustomEvent('gev:cockpit-mode-changed', { detail: { active: true } })); obsolete.forEach((callback) => callback());
window.dispatchEvent(
new CustomEvent('gev:cockpit-mode-changed', { detail: { active: true } }),
);
owner.setActive(true); owner.setActive(true);
assert.deepEqual(f.standard.children, [f.group, f.sibling]); assert.deepEqual(f.standard.children, [f.group, f.sibling]);
assert.equal(f.frames.size, 0); assert.equal(f.frames.size, 0);
assert.equal(f.focusCalls, 0); assert.equal(f.focusCalls, 0);
assert.equal(f.layouts, 1); assert.equal(f.layouts, 1);
} finally { f.restore(); } } finally {
f.restore();
}
}); });
+24 -8
View File
@@ -1,6 +1,5 @@
/** Route Cockpit keys while preserving the active disclosure and form-control owner. */ /** Route Cockpit keys while preserving the active disclosure and form-control owner. */
export function onKeyDown(event) { export function onKeyDown(event) {
if (this.destroyed) return false; if (this.destroyed) return false;
if (event.repeat || event.isComposing) return; if (event.repeat || event.isComposing) return;
@@ -9,13 +8,24 @@ export function onKeyDown(event) {
// focus. Its target handler closes the overlay and restores attribution // focus. Its target handler closes the overlay and restores attribution
// focus; Cockpit must stay active behind it. // focus; Cockpit must stay active behind it.
if (event.target?.closest?.('.cesium-credit-lightbox')) return; if (event.target?.closest?.('.cesium-credit-lightbox')) return;
if (document.getElementById('context-radio-dock')?.classList.contains('disclosure-open')) return; if (
if (document.querySelector('#cockpit-utility-controls [aria-expanded="true"]')) return; 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) { if (this.context?.contains(event.target) && !this.contextCollapsed) {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
this.setContextCollapsed(true); this.setContextCollapsed(true);
if (event.target === this.contextToggle || this.contextToggle?.contains?.(event.target)) { if (
event.target === this.contextToggle ||
this.contextToggle?.contains?.(event.target)
) {
this.contextToggle?.blur?.(); this.contextToggle?.blur?.();
} else { } else {
this.contextToggle?.focus({ preventScroll: true }); this.contextToggle?.focus({ preventScroll: true });
@@ -26,7 +36,10 @@ export function onKeyDown(event) {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
this.setSignalCollapsed(true, { user: true }); this.setSignalCollapsed(true, { user: true });
if (event.target === this.signalToggle || this.signalToggle?.contains?.(event.target)) { if (
event.target === this.signalToggle ||
this.signalToggle?.contains?.(event.target)
) {
this.signalToggle?.blur?.(); this.signalToggle?.blur?.();
} else { } else {
this.signalToggle?.focus({ preventScroll: true }); this.signalToggle?.focus({ preventScroll: true });
@@ -38,11 +51,14 @@ export function onKeyDown(event) {
this.exit(); this.exit();
return; return;
} }
if (event.target?.closest?.('input, textarea, select, [contenteditable]')) return; if (event.target?.closest?.('input, textarea, select, [contenteditable]'))
return;
const key = event.key?.toLowerCase(); const key = event.key?.toLowerCase();
if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) { if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) {
if (!this.active) { if (!this.active) {
const cockpitAttempt = !!(this.readAircraftInfo() && this.viewer.trackedEntity?.position); const cockpitAttempt = !!(
this.readAircraftInfo() && this.viewer.trackedEntity?.position
);
if (!cockpitAttempt) return; if (!cockpitAttempt) return;
} }
event.preventDefault(); event.preventDefault();
@@ -51,4 +67,4 @@ export function onKeyDown(event) {
const changed = this.active ? this.exit() : this.enter(); const changed = this.active ? this.exit() : this.enter();
return; return;
} }
} }
+118 -40
View File
@@ -1,23 +1,48 @@
/** Instrument values, routes and vision controls for the Cockpit controller. */ /** Instrument values, routes and vision controls for the Cockpit controller. */
import { COCKPIT_VISION_MODES, normalizeCockpitVisionMode } from '../cockpitVisionPolicy.js'; import {
import { altitudeRulerCurveInset, altitudeRulerTicks, bearingBetweenCoordinates, cockpitAltitudeDisplayFt, cockpitUiUpdateDue, compassDivisions, formatAltitudeRulerTick, formatCompassDivision, formatSpeedRulerTick, normalizeHeading, relativeBearing, speedRulerTicks } from '../cockpitMath.js'; COCKPIT_VISION_MODES,
import { COCKPIT_CONTEXT_UPDATE_MS, setCockpitRollingValue } from './cockpitPresentation.js'; 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) { export function updateHud(
info,
nowMs = performance.now(),
forceContext = false,
) {
this.lastAircraftInfo = info; this.lastAircraftInfo = info;
const heading = normalizeHeading(this.heading ?? info.track ?? 0); const heading = normalizeHeading(this.heading ?? info.track ?? 0);
if (this.callsign) { if (this.callsign) {
this.callsign.textContent = info.callsign || info.registration || info.icao24 || 'AIRCRAFT'; this.callsign.textContent =
info.callsign || info.registration || info.icao24 || 'AIRCRAFT';
} }
const speedKt = Number.isFinite(info.velocityMps) ? info.velocityMps * 1.94384 : null; const speedKt = Number.isFinite(info.velocityMps)
setCockpitRollingValue( ? info.velocityMps * 1.94384
this.speed, : null;
formatSpeedRulerTick(speedKt), setCockpitRollingValue(this.speed, formatSpeedRulerTick(speedKt), speedKt, {
speedKt, immediate: forceContext,
{ immediate: forceContext }, });
); if (this.speedRim)
if (this.speedRim) this.speedRim.classList.toggle('unavailable', speedKt === null); this.speedRim.classList.toggle('unavailable', speedKt === null);
if (this.speedRimValue) this.speedRimValue.textContent = formatSpeedRulerTick(speedKt); if (this.speedRimValue)
this.speedRimValue.textContent = formatSpeedRulerTick(speedKt);
const speedTicks = speedRulerTicks(speedKt, this.speedRimTicks.length); const speedTicks = speedRulerTicks(speedKt, this.speedRimTicks.length);
this.speedRimTicks.forEach((element, index) => { this.speedRimTicks.forEach((element, index) => {
const tick = speedTicks[index]; const tick = speedTicks[index];
@@ -25,7 +50,10 @@ export function updateHud(info, nowMs = performance.now(), forceContext = false)
if (!tick) return; if (!tick) return;
element.style.setProperty('--slot', tick.slot.toFixed(4)); element.style.setProperty('--slot', tick.slot.toFixed(4));
element.style.setProperty('--depth', tick.depth.toFixed(4)); element.style.setProperty('--depth', tick.depth.toFixed(4));
element.style.setProperty('--curve', altitudeRulerCurveInset(tick.slot).toFixed(5)); element.style.setProperty(
'--curve',
altitudeRulerCurveInset(tick.slot).toFixed(5),
);
element.classList.toggle('major', tick.major); element.classList.toggle('major', tick.major);
const label = element.querySelector('b'); const label = element.querySelector('b');
if (label) label.textContent = formatSpeedRulerTick(tick.valueKt); if (label) label.textContent = formatSpeedRulerTick(tick.valueKt);
@@ -44,18 +72,25 @@ export function updateHud(info, nowMs = performance.now(), forceContext = false)
{ immediate: forceContext }, { immediate: forceContext },
); );
} }
if (this.altitudeRim) this.altitudeRim.classList.toggle('unavailable', altitudeFt === null); if (this.altitudeRim)
this.altitudeRim.classList.toggle('unavailable', altitudeFt === null);
if (this.altitudeRimValue) { if (this.altitudeRimValue) {
this.altitudeRimValue.textContent = formatAltitudeRulerTick(altitudeFt); this.altitudeRimValue.textContent = formatAltitudeRulerTick(altitudeFt);
} }
const altitudeTicks = altitudeRulerTicks(altitudeFt, this.altitudeRimTicks.length); const altitudeTicks = altitudeRulerTicks(
altitudeFt,
this.altitudeRimTicks.length,
);
this.altitudeRimTicks.forEach((element, index) => { this.altitudeRimTicks.forEach((element, index) => {
const tick = altitudeTicks[index]; const tick = altitudeTicks[index];
element.hidden = !tick; element.hidden = !tick;
if (!tick) return; if (!tick) return;
element.style.setProperty('--slot', tick.slot.toFixed(4)); element.style.setProperty('--slot', tick.slot.toFixed(4));
element.style.setProperty('--depth', tick.depth.toFixed(4)); element.style.setProperty('--depth', tick.depth.toFixed(4));
element.style.setProperty('--curve', altitudeRulerCurveInset(tick.slot).toFixed(5)); element.style.setProperty(
'--curve',
altitudeRulerCurveInset(tick.slot).toFixed(5),
);
element.classList.toggle('major', tick.major); element.classList.toggle('major', tick.major);
const label = element.querySelector('b'); const label = element.querySelector('b');
if (label) label.textContent = formatAltitudeRulerTick(tick.valueFt); if (label) label.textContent = formatAltitudeRulerTick(tick.valueFt);
@@ -79,36 +114,51 @@ export function updateHud(info, nowMs = performance.now(), forceContext = false)
.join(''); .join('');
} }
} }
if (this.clock) this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z'; if (this.clock)
this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z';
if (this.position) { if (this.position) {
const lat = Number.isFinite(info.latitude) const lat = Number.isFinite(info.latitude)
? `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}` : '--'; ? `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`
: '--';
const lon = Number.isFinite(info.longitude) const lon = Number.isFinite(info.longitude)
? `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}` : '--'; ? `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`
: '--';
this.position.textContent = `${lat} · ${lon}`; this.position.textContent = `${lat} · ${lon}`;
} }
if (this.aircraftMeta) { if (this.aircraftMeta) {
const feedState = this.surfaceAcquiring const feedState = this.surfaceAcquiring
? 'ACQUIRING SURFACE' ? 'ACQUIRING SURFACE'
: (this.surfaceFallback ? 'SURFACE FALLBACK' : (info.stale ? 'STALE FEED' : 'LIVE TRACK')); : this.surfaceFallback
? 'SURFACE FALLBACK'
: info.stale
? 'STALE FEED'
: 'LIVE TRACK';
this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`; this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`;
} }
this.updateRoute(info); this.updateRoute(info);
if (forceContext if (
|| cockpitUiUpdateDue(nowMs, this.lastContextUpdateMs, COCKPIT_CONTEXT_UPDATE_MS)) { forceContext ||
cockpitUiUpdateDue(
nowMs,
this.lastContextUpdateMs,
COCKPIT_CONTEXT_UPDATE_MS,
)
) {
this.lastContextUpdateMs = nowMs; this.lastContextUpdateMs = nowMs;
this.updateLocalPosition(info); this.updateLocalPosition(info);
this.maybeRefreshRegionalBrief(info); this.maybeRefreshRegionalBrief(info);
this.updateContext(info, heading); this.updateContext(info, heading);
} }
if (this.hud) this.hud.dataset.layer = info.layerId || 'flights'; if (this.hud) this.hud.dataset.layer = info.layerId || 'flights';
} }
export function updateRoute(info) { export function updateRoute(info) {
const origin = info?.route?.origin; const origin = info?.route?.origin;
const destination = info?.route?.destination; const destination = info?.route?.destination;
const validDestination = Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon); const validDestination =
const routeLabel = (airport) => [airport?.code, airport?.name].filter(Boolean).join(' · ') || 'UNKNOWN'; 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.routeFrom) this.routeFrom.textContent = routeLabel(origin);
if (this.routeTo) this.routeTo.textContent = routeLabel(destination); if (this.routeTo) this.routeTo.textContent = routeLabel(destination);
if (this.routeStatus) { if (this.routeStatus) {
@@ -117,7 +167,11 @@ export function updateRoute(info) {
: 'ROUTE DATA UNAVAILABLE'; : 'ROUTE DATA UNAVAILABLE';
} }
if (this.route) this.route.hidden = !origin && !destination; if (this.route) this.route.hidden = !origin && !destination;
if (!validDestination || !Number.isFinite(info?.longitude) || !Number.isFinite(info?.latitude)) { if (
!validDestination ||
!Number.isFinite(info?.longitude) ||
!Number.isFinite(info?.latitude)
) {
this.clearPredictiveRoute(); this.clearPredictiveRoute();
return; return;
} }
@@ -127,7 +181,10 @@ export function updateRoute(info) {
destination.lat, destination.lat,
destination.lon, destination.lon,
); );
const relative = relativeBearing(destinationBearing, this.heading ?? info.track ?? 0); const relative = relativeBearing(
destinationBearing,
this.heading ?? info.track ?? 0,
);
if (!Number.isFinite(destinationBearing) || !Number.isFinite(relative)) { if (!Number.isFinite(destinationBearing) || !Number.isFinite(relative)) {
this.clearPredictiveRoute(); this.clearPredictiveRoute();
return; return;
@@ -135,12 +192,15 @@ export function updateRoute(info) {
if (this.routeDirection) { if (this.routeDirection) {
const displayedRelative = Math.max(-120, Math.min(120, relative)); const displayedRelative = Math.max(-120, Math.min(120, relative));
this.routeDirection.hidden = false; this.routeDirection.hidden = false;
this.routeDirection.style.setProperty('--route-angle', `${displayedRelative.toFixed(2)}deg`); this.routeDirection.style.setProperty(
'--route-angle',
`${displayedRelative.toFixed(2)}deg`,
);
} }
if (this.routeDirectionLabel) { if (this.routeDirectionLabel) {
this.routeDirectionLabel.textContent = `DEST ${String(Math.round(destinationBearing)).padStart(3, '0')}°`; this.routeDirectionLabel.textContent = `DEST ${String(Math.round(destinationBearing)).padStart(3, '0')}°`;
} }
} }
export function syncWeatherToggle(enabled) { export function syncWeatherToggle(enabled) {
if (!this.weatherToggle) return; if (!this.weatherToggle) return;
@@ -152,22 +212,40 @@ export function syncWeatherToggle(enabled) {
); );
this.weatherToggle.title = `${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.weatherState) this.weatherState.textContent = active ? 'ON' : 'OFF';
} }
export function setVisionMode(mode, { revealParameters = false } = {}) { export function setVisionMode(mode, { revealParameters = false } = {}) {
const next = normalizeCockpitVisionMode(mode); const next = normalizeCockpitVisionMode(mode);
this.visionMode = next; this.visionMode = next;
const inherited = String(this.getInheritedVisionLabel?.() || 'NORMAL').toUpperCase(); const inherited = String(
const labels = { optical: inherited, crt: 'CRT', nvg: 'NVG', thermal: 'FLIR', noir: 'NOIR' }; this.getInheritedVisionLabel?.() || 'NORMAL',
const names = { optical: inherited, crt: 'CRT', nvg: 'Night vision', thermal: 'Thermal', noir: 'Noir' }; ).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) { if (this.visionCurrent) {
this.visionCurrent.dataset.cockpitVision = next; this.visionCurrent.dataset.cockpitVision = next;
this.visionCurrent.setAttribute('aria-label', `Current cockpit vision style: ${names[next]}. Activate for next style.`); 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`; this.visionCurrent.title = `Current style: ${names[next]} — click for next`;
} }
if (this.visionCurrentLabel) this.visionCurrentLabel.textContent = labels[next]; if (this.visionCurrentLabel)
this.visionCurrentLabel.textContent = labels[next];
this.onVisionChange?.(next, this.active, { revealParameters }); this.onVisionChange?.(next, this.active, { revealParameters });
} }
export function cycleVisionMode(direction = 1) { export function cycleVisionMode(direction = 1) {
const modes = COCKPIT_VISION_MODES; const modes = COCKPIT_VISION_MODES;
@@ -175,8 +253,8 @@ export function cycleVisionMode(direction = 1) {
const step = direction < 0 ? -1 : 1; const step = direction < 0 ? -1 : 1;
const nextIndex = (currentIndex + step + modes.length) % modes.length; const nextIndex = (currentIndex + step + modes.length) % modes.length;
this.setVisionMode(modes[nextIndex], { revealParameters: true }); this.setVisionMode(modes[nextIndex], { revealParameters: true });
} }
export function clearPredictiveRoute() { export function clearPredictiveRoute() {
if (this.routeDirection) this.routeDirection.hidden = true; if (this.routeDirection) this.routeDirection.hidden = true;
} }
+91 -31
View File
@@ -1,27 +1,42 @@
/** Measure Cockpit panels against visible HUD and viewport obstacles. */ /** Measure Cockpit panels against visible HUD and viewport obstacles. */
import { resolveCockpitUtilityAnchor, resolveCockpitUtilityLayout } from '../cockpitUtilityLayout.js'; import {
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'; 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() { export function scheduleContextLayout() {
if (this.destroyed) return false; if (this.destroyed) return false;
const contextVisible = this.context && !this.context.hidden; const contextVisible = this.context && !this.context.hidden;
const signalVisible = this.signalStream && !this.signalStream.hidden; const signalVisible = this.signalStream && !this.signalStream.hidden;
if ((!contextVisible && !signalVisible) || this.contextLayoutFrame !== null) return; if ((!contextVisible && !signalVisible) || this.contextLayoutFrame !== null)
return;
this.contextLayoutFrame = requestAnimationFrame(() => { this.contextLayoutFrame = requestAnimationFrame(() => {
this.contextLayoutFrame = null; this.contextLayoutFrame = null;
this.syncContextLayout(); this.syncContextLayout();
this.syncSignalLayout(); this.syncSignalLayout();
}); });
} }
export function setContextCollapsed(collapsed) { export function setContextCollapsed(collapsed) {
const wasCollapsed = this.contextCollapsed; const wasCollapsed = this.contextCollapsed;
this.contextCollapsed = Boolean(collapsed); this.contextCollapsed = Boolean(collapsed);
if (this.context) this.context.dataset.collapsed = String(this.contextCollapsed); if (this.context)
this.context.dataset.collapsed = String(this.contextCollapsed);
if (this.contextToggle) { if (this.contextToggle) {
const expanded = !this.contextCollapsed; const expanded = !this.contextCollapsed;
this.contextToggle.setAttribute('aria-expanded', String(expanded)); this.contextToggle.setAttribute('aria-expanded', String(expanded));
this.contextToggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} Contact panel`); this.contextToggle.setAttribute(
'aria-label',
`${expanded ? 'Collapse' : 'Expand'} Contact panel`,
);
this.contextToggle.title = `${expanded ? 'Collapse' : 'Expand'} contact panel`; this.contextToggle.title = `${expanded ? 'Collapse' : 'Expand'} contact panel`;
const icon = this.contextToggle.querySelector('.material-symbols-outlined'); const icon = this.contextToggle.querySelector('.material-symbols-outlined');
if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right'; if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right';
@@ -30,20 +45,25 @@ export function setContextCollapsed(collapsed) {
window.dispatchEvent(new CustomEvent('gev:cockpit-context-expanded')); window.dispatchEvent(new CustomEvent('gev:cockpit-context-expanded'));
} }
this.scheduleContextLayout(); this.scheduleContextLayout();
} }
export function setSignalCollapsed(collapsed, { user = false } = {}) { export function setSignalCollapsed(collapsed, { user = false } = {}) {
const wasCollapsed = this.signalCollapsed; const wasCollapsed = this.signalCollapsed;
this.signalCollapsed = Boolean(collapsed); this.signalCollapsed = Boolean(collapsed);
if (user) this.signalUserCollapsed = this.signalCollapsed; if (user) this.signalUserCollapsed = this.signalCollapsed;
if (this.signalStream) this.signalStream.dataset.collapsed = String(this.signalCollapsed); if (this.signalStream)
this.signalStream.dataset.collapsed = String(this.signalCollapsed);
if (this.signalToggle) { if (this.signalToggle) {
const expanded = !this.signalCollapsed; const expanded = !this.signalCollapsed;
this.signalToggle.setAttribute('aria-expanded', String(expanded)); this.signalToggle.setAttribute('aria-expanded', String(expanded));
this.signalToggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} cockpit briefing panel`); this.signalToggle.setAttribute(
'aria-label',
`${expanded ? 'Collapse' : 'Expand'} cockpit briefing panel`,
);
this.signalToggle.title = `${expanded ? 'Collapse' : 'Expand'} briefing panel`; this.signalToggle.title = `${expanded ? 'Collapse' : 'Expand'} briefing panel`;
const icon = this.signalToggle.querySelector('.material-symbols-outlined'); const icon = this.signalToggle.querySelector('.material-symbols-outlined');
if (icon) icon.textContent = expanded ? 'right_panel_close' : 'right_panel_open'; if (icon)
icon.textContent = expanded ? 'right_panel_close' : 'right_panel_open';
} }
if (this.signalCollapsed) this.stopBriefRotation(); if (this.signalCollapsed) this.stopBriefRotation();
else this.startBriefRotation({ reset: true }); else this.startBriefRotation({ reset: true });
@@ -51,7 +71,7 @@ export function setSignalCollapsed(collapsed, { user = false } = {}) {
window.dispatchEvent(new CustomEvent('gev:cockpit-signal-expanded')); window.dispatchEvent(new CustomEvent('gev:cockpit-signal-expanded'));
} }
this.scheduleContextLayout(); this.scheduleContextLayout();
} }
export function syncContextLayout() { export function syncContextLayout() {
if (!this.context || this.context.hidden) return; if (!this.context || this.context.hidden) return;
@@ -65,10 +85,13 @@ export function syncContextLayout() {
const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04)); const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04));
this.context.dataset.layoutMode = 'bottom-left'; this.context.dataset.layoutMode = 'bottom-left';
this.context.style.setProperty('--cockpit-context-left', `${desktopInset.toFixed(1)}px`); this.context.style.setProperty(
'--cockpit-context-left',
`${desktopInset.toFixed(1)}px`,
);
this.context.style.removeProperty('--cockpit-context-top'); this.context.style.removeProperty('--cockpit-context-top');
this.context.style.removeProperty('--cockpit-context-max-height'); this.context.style.removeProperty('--cockpit-context-max-height');
} }
export function syncSignalLayout() { export function syncSignalLayout() {
if (!this.signalStream || this.signalStream.hidden) return; if (!this.signalStream || this.signalStream.hidden) return;
@@ -76,10 +99,13 @@ export function syncSignalLayout() {
if (window.matchMedia('(max-width: 760px)').matches) { if (window.matchMedia('(max-width: 760px)').matches) {
this.signalStream.dataset.layoutMode = 'compact-top'; this.signalStream.dataset.layoutMode = 'compact-top';
utilityControls?.classList.remove('layout-primary-only'); utilityControls?.classList.remove('layout-primary-only');
utilityControls?.querySelectorAll('.cockpit-utility-control').forEach((control) => { utilityControls
?.querySelectorAll('.cockpit-utility-control')
.forEach((control) => {
const hiddenSibling = Boolean( const hiddenSibling = Boolean(
utilityControls.querySelector('.cockpit-utility-control.is-expanded') utilityControls.querySelector(
&& !control.classList.contains('is-expanded') '.cockpit-utility-control.is-expanded',
) && !control.classList.contains('is-expanded'),
); );
control.setAttribute('aria-hidden', String(hiddenSibling)); control.setAttribute('aria-hidden', String(hiddenSibling));
}); });
@@ -94,22 +120,35 @@ export function syncSignalLayout() {
const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04)); const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04));
this.signalStream.dataset.layoutMode = 'bottom-right'; this.signalStream.dataset.layoutMode = 'bottom-right';
this.signalStream.style.setProperty('--cockpit-signal-right', `${desktopInset.toFixed(1)}px`); this.signalStream.style.setProperty(
'--cockpit-signal-right',
`${desktopInset.toFixed(1)}px`,
);
this.signalStream.style.removeProperty('--cockpit-signal-top'); this.signalStream.style.removeProperty('--cockpit-signal-top');
this.signalStream.style.removeProperty('--cockpit-signal-max-height'); this.signalStream.style.removeProperty('--cockpit-signal-max-height');
const signalBounds = this.signalStream.getBoundingClientRect(); const signalBounds = this.signalStream.getBoundingClientRect();
const utilityBounds = utilityControls?.getBoundingClientRect(); const utilityBounds = utilityControls?.getBoundingClientRect();
if (utilityBounds) { if (utilityBounds) {
const expandedControl = utilityControls?.querySelector('.cockpit-utility-control.is-expanded'); const expandedControl = utilityControls?.querySelector(
'.cockpit-utility-control.is-expanded',
);
const collapsedControl = utilityControls?.querySelector( const collapsedControl = utilityControls?.querySelector(
'.cockpit-utility-control:not(.is-expanded)', '.cockpit-utility-control:not(.is-expanded)',
); );
const collapsedLauncher = collapsedControl?.querySelector('.cockpit-utility-launcher'); const collapsedLauncher = collapsedControl?.querySelector(
'.cockpit-utility-launcher',
);
const expandedHeight = expandedControl const expandedHeight = expandedControl
? Math.max(expandedControl.scrollHeight, expandedControl.getBoundingClientRect().height) ? Math.max(
expandedControl.scrollHeight,
expandedControl.getBoundingClientRect().height,
)
: 0; : 0;
const collapsedHeight = collapsedLauncher const collapsedHeight = collapsedLauncher
? Math.max(COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX, collapsedLauncher.scrollHeight) ? Math.max(
COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX,
collapsedLauncher.scrollHeight,
)
: 0; : 0;
// Cockpit owns this anchor outright. The strip used to inherit the left // Cockpit owns this anchor outright. The strip used to inherit the left
// accordion's committed top, which is solved against left-lane obstacles // accordion's committed top, which is solved against left-lane obstacles
@@ -118,7 +157,9 @@ export function syncSignalLayout() {
// the Minimal variant drops it with `display:none`, but HUD Off hides the // the Minimal variant drops it with `display:none`, but HUD Off hides the
// whole Intel HUD with `visibility`/`opacity`, which keeps its rect. // whole Intel HUD with `visibility`/`opacity`, which keeps its rect.
const recReadout = document.querySelector('#intel-hud .hud-top-right'); const recReadout = document.querySelector('#intel-hud .hud-top-right');
const recBounds = isRenderedOnScreen(recReadout) ? recReadout.getBoundingClientRect() : null; const recBounds = isRenderedOnScreen(recReadout)
? recReadout.getBoundingClientRect()
: null;
const utilityAnchor = resolveCockpitUtilityAnchor({ const utilityAnchor = resolveCockpitUtilityAnchor({
recBottom: recBounds ? recBounds.bottom : 0, recBottom: recBounds ? recBounds.bottom : 0,
signalTop: signalBounds.top, signalTop: signalBounds.top,
@@ -131,22 +172,41 @@ export function syncSignalLayout() {
minTopRatio: COCKPIT_UTILITY_MIN_TOP_RATIO, minTopRatio: COCKPIT_UTILITY_MIN_TOP_RATIO,
}); });
const availableHeight = utilityAnchor.maxHeight; const availableHeight = utilityAnchor.maxHeight;
this.hud?.style.setProperty('--cockpit-utility-top', `${utilityAnchor.top.toFixed(1)}px`); this.hud?.style.setProperty(
this.hud?.style.setProperty('--cockpit-utility-max-height', `${availableHeight.toFixed(2)}px`); '--cockpit-utility-top',
const utilityLayout = expandedControl && collapsedControl `${utilityAnchor.top.toFixed(1)}px`,
? resolveCockpitUtilityLayout({ availableHeight, expandedHeight, collapsedHeight }) );
this.hud?.style.setProperty(
'--cockpit-utility-max-height',
`${availableHeight.toFixed(2)}px`,
);
const utilityLayout =
expandedControl && collapsedControl
? resolveCockpitUtilityLayout({
availableHeight,
expandedHeight,
collapsedHeight,
})
: { primaryOnly: false, expandedMaxHeight: availableHeight }; : { primaryOnly: false, expandedMaxHeight: availableHeight };
utilityControls?.classList.toggle('layout-primary-only', utilityLayout.primaryOnly); utilityControls?.classList.toggle(
'layout-primary-only',
utilityLayout.primaryOnly,
);
this.hud?.style.setProperty( this.hud?.style.setProperty(
'--cockpit-utility-expanded-max-height', '--cockpit-utility-expanded-max-height',
`${utilityLayout.expandedMaxHeight.toFixed(2)}px`, `${utilityLayout.expandedMaxHeight.toFixed(2)}px`,
); );
utilityControls?.querySelectorAll('.cockpit-utility-control').forEach((control) => { utilityControls
const hiddenSibling = utilityLayout.primaryOnly && control === collapsedControl; ?.querySelectorAll('.cockpit-utility-control')
.forEach((control) => {
const hiddenSibling =
utilityLayout.primaryOnly && control === collapsedControl;
control.setAttribute('aria-hidden', String(hiddenSibling)); control.setAttribute('aria-hidden', String(hiddenSibling));
if (hiddenSibling && control.contains(document.activeElement)) { if (hiddenSibling && control.contains(document.activeElement)) {
expandedControl?.querySelector('.cockpit-utility-glyph')?.focus({ preventScroll: true }); expandedControl
?.querySelector('.cockpit-utility-glyph')
?.focus({ preventScroll: true });
} }
}); });
} }
} }
+39 -20
View File
@@ -31,9 +31,11 @@ export const COCKPIT_GROUND_WAIT_TIMEOUT_MS = 5000;
export const COCKPIT_BRIEF_ROTATE_MS = 9000; 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; export const COCKPIT_REGIONAL_REFRESH_MS = 5 * 60_000;
@@ -64,7 +66,11 @@ export function isRenderedOnScreen(element) {
if (!element) return false; if (!element) return false;
for (let node = element; node instanceof Element; node = node.parentElement) { for (let node = element; node instanceof Element; node = node.parentElement) {
const style = getComputedStyle(node); 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; return false;
} }
} }
@@ -88,43 +94,54 @@ export function formatCockpitWindDirection(value) {
return `${labels[Math.round(normalized / 45) % labels.length]} · ${Math.round(normalized)}°`; return `${labels[Math.round(normalized / 45) % labels.length]} · ${Math.round(normalized)}°`;
} }
export function setCockpitRollingValue(element, text, numericValue, { export function setCockpitRollingValue(
circularRange = null, element,
immediate = false, text,
} = {}) { numericValue,
{ circularRange = null, immediate = false } = {},
) {
if (!element) return; if (!element) return;
const nextText = String(text); const nextText = String(text);
const previousText = element.dataset.rollingText; const previousText = element.dataset.rollingText;
const previousValue = Number(element.dataset.rollingValue); const previousValue = Number(element.dataset.rollingValue);
const nowMs = performance.now(); const nowMs = performance.now();
const lastRollMs = Number(element.dataset.rollingAt); const lastRollMs = Number(element.dataset.rollingAt);
if (!immediate if (
&& previousText !== undefined !immediate &&
&& previousText !== nextText previousText !== undefined &&
&& Number.isFinite(lastRollMs) previousText !== nextText &&
&& nowMs - lastRollMs < 220) { Number.isFinite(lastRollMs) &&
nowMs - lastRollMs < 220
) {
return; return;
} }
element.dataset.rollingText = nextText; element.dataset.rollingText = nextText;
element.dataset.rollingAt = String(nowMs); 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; else delete element.dataset.rollingValue;
element.setAttribute('aria-label', nextText); element.setAttribute('aria-label', nextText);
if (immediate || previousText === undefined || previousText === nextText) { if (immediate || previousText === undefined || previousText === nextText) {
if (previousText !== nextText || !element.querySelector('.cockpit-roll-token')) { if (
element.replaceChildren(...Array.from(nextText, (character) => { previousText !== nextText ||
!element.querySelector('.cockpit-roll-token')
) {
element.replaceChildren(
...Array.from(nextText, (character) => {
const token = document.createElement('span'); const token = document.createElement('span');
token.className = 'cockpit-roll-token'; token.className = 'cockpit-roll-token';
token.setAttribute('aria-hidden', 'true'); token.setAttribute('aria-hidden', 'true');
token.textContent = character; token.textContent = character;
return token; return token;
})); }),
);
} }
return; return;
} }
let delta = Number.isFinite(numericValue) && Number.isFinite(previousValue) let delta =
Number.isFinite(numericValue) && Number.isFinite(previousValue)
? numericValue - previousValue ? numericValue - previousValue
: 0; : 0;
if (Number.isFinite(circularRange) && circularRange > 0) { if (Number.isFinite(circularRange) && circularRange > 0) {
@@ -145,9 +162,11 @@ export function setCockpitRollingValue(element, text, numericValue, {
token.className = 'cockpit-roll-token'; token.className = 'cockpit-roll-token';
token.setAttribute('aria-hidden', 'true'); token.setAttribute('aria-hidden', 'true');
if (previousCharacter === nextCharacter if (
|| !/\d/.test(previousCharacter) previousCharacter === nextCharacter ||
|| !/\d/.test(nextCharacter)) { !/\d/.test(previousCharacter) ||
!/\d/.test(nextCharacter)
) {
token.textContent = nextCharacter === ' ' ? '\u00a0' : nextCharacter; token.textContent = nextCharacter === ' ' ? '\u00a0' : nextCharacter;
fragment.append(token); fragment.append(token);
continue; continue;
+39 -15
View File
@@ -1,10 +1,11 @@
/** Reconcile actionable signal rows without disturbing native keyboard focus. */ /** Reconcile actionable signal rows without disturbing native keyboard focus. */
export function renderCockpitSignals() { export function renderCockpitSignals() {
if (!this.signalList) return; if (!this.signalList) return;
const existing = [...this.signalList.children]; const existing = [...this.signalList.children];
const focusedEntry = existing.find((entry) => entry.contains(document.activeElement)); const focusedEntry = existing.find((entry) =>
entry.contains(document.activeElement),
);
const entriesByKey = new Map(); const entriesByKey = new Map();
for (const entry of existing) { for (const entry of existing) {
const key = entry.dataset.signalKey; const key = entry.dataset.signalKey;
@@ -18,7 +19,10 @@ export function renderCockpitSignals() {
// Identity includes the action target: a reused status key must never // Identity includes the action target: a reused status key must never
// silently turn a focused flight button into a different selection. // silently turn a focused flight button into a different selection.
const key = JSON.stringify([ const key = JSON.stringify([
item.key, Boolean(item.target), item.target?.layerId || '', String(item.target?.id ?? ''), item.key,
Boolean(item.target),
item.target?.layerId || '',
String(item.target?.id ?? ''),
]); ]);
let entry = entriesByKey.get(key)?.shift(); let entry = entriesByKey.get(key)?.shift();
if (!entry) { if (!entry) {
@@ -36,7 +40,8 @@ export function renderCockpitSignals() {
rule.className = 'cockpit-signal-target-rule'; rule.className = 'cockpit-signal-target-rule';
rule.setAttribute('aria-hidden', 'true'); rule.setAttribute('aria-hidden', 'true');
const chevron = document.createElement('span'); const chevron = document.createElement('span');
chevron.className = 'material-symbols-outlined cockpit-signal-target-chevron'; chevron.className =
'material-symbols-outlined cockpit-signal-target-chevron';
chevron.setAttribute('aria-hidden', 'true'); chevron.setAttribute('aria-hidden', 'true');
chevron.textContent = 'chevron_right'; chevron.textContent = 'chevron_right';
heading.append(label, rule, chevron); heading.append(label, rule, chevron);
@@ -53,7 +58,8 @@ export function renderCockpitSignals() {
heading.dataset.signalLayer = item.target.layerId; heading.dataset.signalLayer = item.target.layerId;
heading.dataset.signalId = item.target.id; heading.dataset.signalId = item.target.id;
const label = `Select flight ${item.title}`; const label = `Select flight ${item.title}`;
if (heading.getAttribute('aria-label') !== label) heading.setAttribute('aria-label', label); if (heading.getAttribute('aria-label') !== label)
heading.setAttribute('aria-label', label);
setText(heading.children[0], item.title); setText(heading.children[0], item.title);
} else { } else {
setText(heading, item.title); setText(heading, item.title);
@@ -65,7 +71,9 @@ export function renderCockpitSignals() {
if (focusedEntry && !retainedFocus) { if (focusedEntry && !retainedFocus) {
// A departed contact cannot remain selectable. Continue from the stable // A departed contact cannot remain selectable. Continue from the stable
// briefing footer instead of dropping keyboard traversal to the page top. // briefing footer instead of dropping keyboard traversal to the page top.
(this.briefTabs?.[this.briefPageIndex] || this.signalToggle)?.focus({ preventScroll: true }); (this.briefTabs?.[this.briefPageIndex] || this.signalToggle)?.focus({
preventScroll: true,
});
} }
for (const entry of existing) if (!entries.includes(entry)) entry.remove(); for (const entry of existing) if (!entries.includes(entry)) entry.remove();
@@ -76,28 +84,38 @@ export function renderCockpitSignals() {
let anchor = retainedFocus; let anchor = retainedFocus;
for (let index = focusIndex - 1; index >= 0; index -= 1) { for (let index = focusIndex - 1; index >= 0; index -= 1) {
const entry = entries[index]; const entry = entries[index];
if (entry.nextElementSibling !== anchor) this.signalList.insertBefore(entry, anchor); if (entry.nextElementSibling !== anchor)
this.signalList.insertBefore(entry, anchor);
anchor = entry; anchor = entry;
} }
} }
let anchor = retainedFocus ? retainedFocus.nextElementSibling : this.signalList.firstElementChild; let anchor = retainedFocus
? retainedFocus.nextElementSibling
: this.signalList.firstElementChild;
for (let index = focusIndex + 1; index < entries.length; index += 1) { for (let index = focusIndex + 1; index < entries.length; index += 1) {
const entry = entries[index]; const entry = entries[index];
if (entry === anchor) anchor = anchor.nextElementSibling; if (entry === anchor) anchor = anchor.nextElementSibling;
else this.signalList.insertBefore(entry, anchor); else this.signalList.insertBefore(entry, anchor);
} }
this.scheduleContextLayout(); this.scheduleContextLayout();
} }
export function pushCockpitSignal(key, tone, title, detail, target = null) { export function pushCockpitSignal(key, tone, title, detail, target = null) {
if (!this.signalList || !title || !detail) return; if (!this.signalList || !title || !detail) return;
const signature = `${title}|${detail}|${target?.layerId || ''}|${target?.id || ''}`; const signature = `${title}|${detail}|${target?.layerId || ''}|${target?.id || ''}`;
if (this.signalSignatures.get(key) === signature) return; if (this.signalSignatures.get(key) === signature) return;
this.signalSignatures.set(key, signature); this.signalSignatures.set(key, signature);
this.signalItems.unshift({ key, tone, title, detail, target, timestamp: Date.now() }); this.signalItems.unshift({
key,
tone,
title,
detail,
target,
timestamp: Date.now(),
});
this.signalItems = this.signalItems.slice(0, 5); this.signalItems = this.signalItems.slice(0, 5);
this.renderCockpitSignals(); this.renderCockpitSignals();
} }
export function updateCockpitSignals(snapshot, unknownCount) { export function updateCockpitSignals(snapshot, unknownCount) {
const previous = new Map(this.signalItems.map((item) => [item.key, item])); const previous = new Map(this.signalItems.map((item) => [item.key, item]));
@@ -140,7 +158,8 @@ export function updateCockpitSignals(snapshot, unknownCount) {
contacts.sort((a, b) => a.distanceM - b.distanceM); contacts.sort((a, b) => a.distanceM - b.distanceM);
const nextItems = contacts.slice(0, 5).map((item) => ({ const nextItems = contacts.slice(0, 5).map((item) => ({
...item, ...item,
timestamp: previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(), timestamp:
previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(),
})); }));
if (unknownCount) { if (unknownCount) {
const sources = snapshot.cohorts const sources = snapshot.cohorts
@@ -153,17 +172,22 @@ export function updateCockpitSignals(snapshot, unknownCount) {
title: `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN`, title: `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN`,
detail: sources || 'SOURCE STATUS UNAVAILABLE', detail: sources || 'SOURCE STATUS UNAVAILABLE',
target: null, target: null,
timestamp: previous.get('input-status')?.timestamp || snapshot.evaluatedAt || Date.now(), timestamp:
previous.get('input-status')?.timestamp ||
snapshot.evaluatedAt ||
Date.now(),
}); });
} }
this.signalItems = nextItems; this.signalItems = nextItems;
this.signalSignatures.clear(); this.signalSignatures.clear();
this.renderCockpitSignals(); this.renderCockpitSignals();
} }
export function handleSignalClick(event) { export function handleSignalClick(event) {
if (this.destroyed) return; if (this.destroyed) return;
const target = event.target.closest('button[data-signal-layer][data-signal-id]'); const target = event.target.closest(
'button[data-signal-layer][data-signal-id]',
);
if (!target) return; if (!target) return;
event.preventDefault(); event.preventDefault();
this.services.militaryAwarenessLayer.focusTarget?.( this.services.militaryAwarenessLayer.focusTarget?.(
+53 -25
View File
@@ -1,5 +1,8 @@
/** Tracking identity, entry, exit and contact navigation for the Cockpit controller. */ /** 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() { export function readAircraftInfo() {
// In cockpit mode the controller takes the entity off `viewer.trackedEntity` // In cockpit mode the controller takes the entity off `viewer.trackedEntity`
@@ -10,19 +13,24 @@ export function readAircraftInfo() {
military: this.services.militaryFlightsLayer.getTrackedInfo?.() || null, military: this.services.militaryFlightsLayer.getTrackedInfo?.() || null,
trackedId: trackedEntity?.gevTrackedId || '', trackedId: trackedEntity?.gevTrackedId || '',
}); });
} }
export function dispatchCockpitModeChanged(active, info = null) { export function dispatchCockpitModeChanged(active, info = null) {
const subjectId = active const subjectId = active
? String(info?.icao24 || '').trim().toLowerCase() || null ? String(info?.icao24 || '')
.trim()
.toLowerCase() || null
: null; : null;
const layerId = active && ['flights', 'military'].includes(info?.layerId) const layerId =
active && ['flights', 'military'].includes(info?.layerId)
? info.layerId ? info.layerId
: null; : null;
window.dispatchEvent(new CustomEvent('gev:cockpit-mode-changed', { window.dispatchEvent(
new CustomEvent('gev:cockpit-mode-changed', {
detail: { active: active === true, subjectId, layerId }, detail: { active: active === true, subjectId, layerId },
})); }),
} );
}
export function toggleTrackedTr3b() { export function toggleTrackedTr3b() {
if (this.destroyed) return false; if (this.destroyed) return false;
@@ -30,12 +38,15 @@ export function toggleTrackedTr3b() {
const icao24 = String(info?.icao24 || '').trim(); const icao24 = String(info?.icao24 || '').trim();
if (!icao24) return false; if (!icao24) return false;
this.services.toggleTr3b(icao24); this.services.toggleTr3b(icao24);
const layer = info.layerId === 'military' ? this.services.militaryFlightsLayer : this.services.flightsLayer; const layer =
info.layerId === 'military'
? this.services.militaryFlightsLayer
: this.services.flightsLayer;
layer.refreshTr3b?.(icao24); layer.refreshTr3b?.(icao24);
this._tr3bSignature = null; // force the chip to repaint on the next sync this._tr3bSignature = null; // force the chip to repaint on the next sync
this.syncTr3bToggle(info); this.syncTr3bToggle(info);
return true; return true;
} }
export function syncTr3bToggle(info) { export function syncTr3bToggle(info) {
if (!this.tr3bToggle) return; if (!this.tr3bToggle) return;
@@ -46,8 +57,10 @@ export function syncTr3bToggle(info) {
this._tr3bSignature = signature; this._tr3bSignature = signature;
this.tr3bToggle.hidden = !icao24; this.tr3bToggle.hidden = !icao24;
this.tr3bToggle.setAttribute('aria-pressed', converted ? 'true' : 'false'); this.tr3bToggle.setAttribute('aria-pressed', converted ? 'true' : 'false');
this.tr3bToggle.title = converted ? 'Restore real aircraft' : 'Reclassify as TR-3B'; this.tr3bToggle.title = converted
} ? 'Restore real aircraft'
: 'Reclassify as TR-3B';
}
export function syncEntry() { export function syncEntry() {
if (this.destroyed) return false; if (this.destroyed) return false;
@@ -64,26 +77,35 @@ export function syncEntry() {
if (this.entry) this.entry.hidden = !available; if (this.entry) this.entry.hidden = !available;
if (this.mapViewButton) this.mapViewButton.hidden = true; if (this.mapViewButton) this.mapViewButton.hidden = true;
if (this.resetGlobeButton) this.resetGlobeButton.hidden = true; if (this.resetGlobeButton) this.resetGlobeButton.hidden = true;
} }
export function navigateContext(direction, options = {}) { export function navigateContext(direction, options = {}) {
if (this.destroyed) return false; if (this.destroyed) return false;
const method = direction < 0 ? 'navigatePrevious' : 'navigateNext'; const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';
const wasActive = this.active; const wasActive = this.active;
if (wasActive) this.contextNavigationDeadlineMs = performance.now() + 1500; if (wasActive) this.contextNavigationDeadlineMs = performance.now() + 1500;
const navigationOptions = wasActive ? { ...options, aircraftOnly: true } : options; const navigationOptions = wasActive
const changed = Boolean(this.services.militaryAwarenessLayer?.[method]?.(navigationOptions)); ? { ...options, aircraftOnly: true }
: options;
const changed = Boolean(
this.services.militaryAwarenessLayer?.[method]?.(navigationOptions),
);
if (!changed) { if (!changed) {
this.contextNavigationDeadlineMs = 0; this.contextNavigationDeadlineMs = 0;
return false; return false;
} }
if (wasActive) this._adoptTrackedEntity(performance.now()); if (wasActive) this._adoptTrackedEntity(performance.now());
return true; return true;
} }
export function _adoptTrackedEntity(nowMs, suppliedInfo = null) { export function _adoptTrackedEntity(nowMs, suppliedInfo = null) {
const nextEntity = this.viewer.trackedEntity; const nextEntity = this.viewer.trackedEntity;
if (!this.active || !nextEntity?.position || nextEntity === this.trackedEntity) return false; if (
!this.active ||
!nextEntity?.position ||
nextEntity === this.trackedEntity
)
return false;
const info = suppliedInfo || this.readAircraftInfo(); const info = suppliedInfo || this.readAircraftInfo();
if (!info) return false; if (!info) return false;
if (this.trackedEntity && this.viewer.entities.contains(this.trackedEntity)) { if (this.trackedEntity && this.viewer.entities.contains(this.trackedEntity)) {
@@ -102,7 +124,7 @@ export function _adoptTrackedEntity(nowMs, suppliedInfo = null) {
this.contextNavigationDeadlineMs = 0; this.contextNavigationDeadlineMs = 0;
this.dispatchCockpitModeChanged(true, info); this.dispatchCockpitModeChanged(true, info);
return true; return true;
} }
export function enter() { export function enter() {
if (this.destroyed) return false; if (this.destroyed) return false;
@@ -111,7 +133,8 @@ export function enter() {
const info = this.readAircraftInfo(); const info = this.readAircraftInfo();
const entity = this.viewer.trackedEntity; const entity = this.viewer.trackedEntity;
if (!info || !entity?.position) return false; if (!info || !entity?.position) return false;
this.entryFocusOrigin = document.activeElement instanceof HTMLElement this.entryFocusOrigin =
document.activeElement instanceof HTMLElement
? document.activeElement ? document.activeElement
: null; : null;
// Retire deferred navigation before cancelFlight can run its callbacks. // Retire deferred navigation before cancelFlight can run its callbacks.
@@ -159,8 +182,11 @@ export function enter() {
this.signalSignatures.clear(); this.signalSignatures.clear();
this.showBriefPage(0); this.showBriefPage(0);
this.startBriefRotation(); this.startBriefRotation();
const trackLabel = info.callsign || info.registration || info.icao24 || 'AIRCRAFT'; const trackLabel =
const trackHeading = String(Math.round(normalizeHeading(info.track ?? 0))).padStart(3, '0'); info.callsign || info.registration || info.icao24 || 'AIRCRAFT';
const trackHeading = String(
Math.round(normalizeHeading(info.track ?? 0)),
).padStart(3, '0');
this.pushCockpitSignal( this.pushCockpitSignal(
'track', 'track',
'track', 'track',
@@ -174,7 +200,7 @@ export function enter() {
this.onEntered?.(); this.onEntered?.();
this.dispatchCockpitModeChanged(true, info); this.dispatchCockpitModeChanged(true, info);
return true; return true;
} }
export function exit({ restoreTracking = true } = {}) { export function exit({ restoreTracking = true } = {}) {
if (!this.active) return false; if (!this.active) return false;
@@ -207,7 +233,8 @@ export function exit({ restoreTracking = true } = {}) {
if (this.signalStream) this.signalStream.hidden = true; if (this.signalStream) this.signalStream.hidden = true;
this.hud?.classList.remove('signals-active'); this.hud?.classList.remove('signals-active');
this.viewer.scene.screenSpaceCameraController.enableInputs = true; this.viewer.scene.screenSpaceCameraController.enableInputs = true;
if (entity && this.viewer.entities.contains(entity)) entity.show = this.trackedEntityWasShown; if (entity && this.viewer.entities.contains(entity))
entity.show = this.trackedEntityWasShown;
this.trackedEntityWasShown = true; this.trackedEntityWasShown = true;
this.dispatchCockpitModeChanged(false); this.dispatchCockpitModeChanged(false);
if (restoreTracking && entity && this.viewer.entities.contains(entity)) { if (restoreTracking && entity && this.viewer.entities.contains(entity)) {
@@ -215,12 +242,13 @@ export function exit({ restoreTracking = true } = {}) {
this.restoreTrackingFrame(entity); this.restoreTrackingFrame(entity);
} }
this.syncEntry(); this.syncEntry();
const restoreTarget = this.entryFocusOrigin === this.entry const restoreTarget =
this.entryFocusOrigin === this.entry
? this.entry ? this.entry
: (this.entry || this.entryFocusOrigin); : this.entry || this.entryFocusOrigin;
this.entryFocusOrigin = null; this.entryFocusOrigin = null;
if (restoreTarget?.isConnected && !restoreTarget.hidden) { if (restoreTarget?.isConnected && !restoreTarget.hidden) {
restoreTarget.focus({ preventScroll: true }); restoreTarget.focus({ preventScroll: true });
} }
return true; return true;
} }