mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
style: format Cockpit components and pure policies
This commit is contained in:
1 parent
8075c70e6e
commit
e04aefe863
18 files changed
+2227
-1407
No files matched your search
@@ -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"
|
||||||
]
|
]
|
||||||
@@ -413,8 +413,6 @@
|
|||||||
"src/cockpitVisionPolicy.js",
|
"src/cockpitVisionPolicy.js",
|
||||||
"src/ui/panelRailGeometry.js"
|
"src/ui/panelRailGeometry.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": ["cesium"]
|
||||||
"cesium"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+76
-36
@@ -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,
|
||||||
? subjectLabel.trim() || '—'
|
radiusM,
|
||||||
: Number.isFinite(subjectLabel) ? String(subjectLabel) : '—';
|
installationCoverage = null,
|
||||||
const radiusKm = Number.isFinite(radiusM) && radiusM >= 0
|
) {
|
||||||
? String(Math.round(radiusM / 1000))
|
const normalizedLabel =
|
||||||
: '—';
|
typeof subjectLabel === 'string'
|
||||||
const coverage = typeof installationCoverage === 'string'
|
? subjectLabel.trim() || '—'
|
||||||
? installationCoverage.trim()
|
: Number.isFinite(subjectLabel)
|
||||||
: '';
|
? String(subjectLabel)
|
||||||
|
: '—';
|
||||||
|
const radiusKm =
|
||||||
|
Number.isFinite(radiusM) && radiusM >= 0
|
||||||
|
? String(Math.round(radiusM / 1000))
|
||||||
|
: '—';
|
||||||
|
const coverage =
|
||||||
|
typeof installationCoverage === 'string' ? installationCoverage.trim() : '';
|
||||||
const base = `${normalizedLabel} · ${radiusKm} KM AIR/SEA WINDOW`;
|
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
|
||||||
|
|||||||
@@ -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({
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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',
|
||||||
|
|||||||
+216
-153
@@ -1,150 +1,199 @@
|
|||||||
/** 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;
|
||||||
this.briefPageIndex = ((Number(index) % count) + count) % count;
|
this.briefPageIndex = ((Number(index) % count) + count) % count;
|
||||||
const page = COCKPIT_BRIEF_PAGES[this.briefPageIndex];
|
const page = COCKPIT_BRIEF_PAGES[this.briefPageIndex];
|
||||||
this.briefPages.forEach((element) => {
|
this.briefPages.forEach((element) => {
|
||||||
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 =
|
||||||
button.setAttribute('aria-current', current ? 'true' : 'false');
|
Number(button.dataset.cockpitBriefIndex) === this.briefPageIndex;
|
||||||
});
|
button.setAttribute('aria-current', current ? 'true' : 'false');
|
||||||
if (this.briefKicker) {
|
});
|
||||||
const indicator = this.briefKicker.querySelector('i');
|
if (this.briefKicker) {
|
||||||
this.briefKicker.replaceChildren(...[indicator, document.createTextNode(` ${page.kicker}`)].filter(Boolean));
|
const indicator = this.briefKicker.querySelector('i');
|
||||||
}
|
this.briefKicker.replaceChildren(
|
||||||
if (this.briefSubtitle) this.briefSubtitle.textContent = page.subtitle;
|
...[indicator, document.createTextNode(` ${page.kicker}`)].filter(
|
||||||
if (this.briefPosition) this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`;
|
Boolean,
|
||||||
if (this.briefSource) this.briefSource.textContent = page.source;
|
),
|
||||||
if (this.signalStream) this.signalStream.dataset.briefPage = page.id;
|
);
|
||||||
if (manual && this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true });
|
|
||||||
this.scheduleContextLayout();
|
|
||||||
}
|
}
|
||||||
|
if (this.briefSubtitle) this.briefSubtitle.textContent = page.subtitle;
|
||||||
|
if (this.briefPosition)
|
||||||
|
this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`;
|
||||||
|
if (this.briefSource) this.briefSource.textContent = page.source;
|
||||||
|
if (this.signalStream) this.signalStream.dataset.briefPage = page.id;
|
||||||
|
if (manual && this.briefAutoRotateEnabled)
|
||||||
|
this.startBriefRotation({ reset: true });
|
||||||
|
this.scheduleContextLayout();
|
||||||
|
}
|
||||||
|
|
||||||
export function setBriefAutoRotate(enabled) {
|
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(
|
||||||
const label = this.briefAutoRotateEnabled ? 'CYCLE ON' : 'CYCLE OFF';
|
'aria-pressed',
|
||||||
this.briefAutoToggle.textContent = label;
|
String(this.briefAutoRotateEnabled),
|
||||||
const help = this.briefAutoRotateEnabled
|
);
|
||||||
? COCKPIT_BRIEF_CYCLE_ON_HELP
|
const label = this.briefAutoRotateEnabled ? 'CYCLE ON' : 'CYCLE OFF';
|
||||||
: COCKPIT_BRIEF_CYCLE_OFF_HELP;
|
this.briefAutoToggle.textContent = label;
|
||||||
this.briefAutoToggle.setAttribute('aria-label', label);
|
const help = this.briefAutoRotateEnabled
|
||||||
this.briefAutoToggle.title = help;
|
? COCKPIT_BRIEF_CYCLE_ON_HELP
|
||||||
}
|
: COCKPIT_BRIEF_CYCLE_OFF_HELP;
|
||||||
if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true });
|
this.briefAutoToggle.setAttribute('aria-label', label);
|
||||||
else this.stopBriefRotation();
|
this.briefAutoToggle.title = help;
|
||||||
}
|
}
|
||||||
|
if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true });
|
||||||
|
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 ||
|
||||||
this.briefTimer = window.setTimeout(() => {
|
document.hidden
|
||||||
this.briefTimer = null;
|
)
|
||||||
const hasPointer = this.signalStream?.matches(':hover') === true;
|
return;
|
||||||
const hasFocus = this.signalStream?.contains(document.activeElement) === true;
|
this.briefTimer = window.setTimeout(() => {
|
||||||
const isInteracting = hasPointer || hasFocus;
|
this.briefTimer = null;
|
||||||
if (!isInteracting) {
|
const hasPointer = this.signalStream?.matches(':hover') === true;
|
||||||
this.showBriefPage(this.briefPageIndex + 1);
|
const hasFocus =
|
||||||
}
|
this.signalStream?.contains(document.activeElement) === true;
|
||||||
this.startBriefRotation();
|
const isInteracting = hasPointer || hasFocus;
|
||||||
}, COCKPIT_BRIEF_ROTATE_MS);
|
if (!isInteracting) {
|
||||||
}
|
this.showBriefPage(this.briefPageIndex + 1);
|
||||||
|
}
|
||||||
|
this.startBriefRotation();
|
||||||
|
}, 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;
|
||||||
if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) {
|
if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) {
|
||||||
this.localCoordinates.textContent = 'POSITION UNAVAILABLE';
|
this.localCoordinates.textContent = 'POSITION UNAVAILABLE';
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`;
|
|
||||||
const lon = `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`;
|
|
||||||
this.localCoordinates.textContent = `${lat} · ${lon}`;
|
|
||||||
}
|
}
|
||||||
|
const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`;
|
||||||
|
const lon = `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`;
|
||||||
|
this.localCoordinates.textContent = `${lat} · ${lon}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function maybeRefreshRegionalBrief(info) {
|
export function maybeRefreshRegionalBrief(info) {
|
||||||
if (!this.active || !Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) return;
|
if (
|
||||||
const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.registration || info.callsign || 'unknown'}`;
|
!this.active ||
|
||||||
if (subjectId !== this.regionalBriefSubjectId) {
|
!Number.isFinite(info.latitude) ||
|
||||||
this.regionalBriefAbort?.abort();
|
!Number.isFinite(info.longitude)
|
||||||
this.regionalBriefAbort = null;
|
)
|
||||||
this.regionalBriefRequestToken += 1;
|
return;
|
||||||
this.regionalBriefSubjectId = subjectId;
|
const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.registration || info.callsign || 'unknown'}`;
|
||||||
this.regionalBrief = null;
|
if (subjectId !== this.regionalBriefSubjectId) {
|
||||||
this.regionalBriefAnchor = null;
|
this.regionalBriefAbort?.abort();
|
||||||
this.regionalBriefFetchedAt = 0;
|
this.regionalBriefAbort = null;
|
||||||
}
|
this.regionalBriefRequestToken += 1;
|
||||||
const point = { latitude: info.latitude, longitude: info.longitude };
|
this.regionalBriefSubjectId = subjectId;
|
||||||
const ageMs = Date.now() - this.regionalBriefFetchedAt;
|
this.regionalBrief = null;
|
||||||
const distanceM = this.services.regionalDistanceM(this.regionalBriefAnchor, point);
|
this.regionalBriefAnchor = null;
|
||||||
if (this.regionalBriefAbort || (ageMs < COCKPIT_REGIONAL_REFRESH_MS
|
this.regionalBriefFetchedAt = 0;
|
||||||
&& distanceM < COCKPIT_REGIONAL_REFRESH_DISTANCE_M)) return;
|
|
||||||
|
|
||||||
this.regionalBriefAnchor = point;
|
|
||||||
this.regionalBriefFetchedAt = Date.now();
|
|
||||||
const controller = new AbortController();
|
|
||||||
const requestToken = ++this.regionalBriefRequestToken;
|
|
||||||
this.regionalBriefAbort = controller;
|
|
||||||
if (!this.regionalBrief) this.renderRegionalBriefStatus('loading', info);
|
|
||||||
this.services.fetchRegionalBrief(point.latitude, point.longitude, { signal: controller.signal })
|
|
||||||
.then((payload) => {
|
|
||||||
if (!this.active
|
|
||||||
|| requestToken !== this.regionalBriefRequestToken
|
|
||||||
|| subjectId !== this.regionalBriefSubjectId) return;
|
|
||||||
this.regionalBrief = payload;
|
|
||||||
this.renderRegionalBrief(payload, info);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (error?.name !== 'AbortError'
|
|
||||||
&& this.active
|
|
||||||
&& requestToken === this.regionalBriefRequestToken
|
|
||||||
&& subjectId === this.regionalBriefSubjectId) {
|
|
||||||
this.renderRegionalBriefStatus('unavailable', info);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (this.regionalBriefAbort === controller) this.regionalBriefAbort = null;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
const point = { latitude: info.latitude, longitude: info.longitude };
|
||||||
|
const ageMs = Date.now() - this.regionalBriefFetchedAt;
|
||||||
|
const distanceM = this.services.regionalDistanceM(
|
||||||
|
this.regionalBriefAnchor,
|
||||||
|
point,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
this.regionalBriefAbort ||
|
||||||
|
(ageMs < COCKPIT_REGIONAL_REFRESH_MS &&
|
||||||
|
distanceM < COCKPIT_REGIONAL_REFRESH_DISTANCE_M)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.regionalBriefAnchor = point;
|
||||||
|
this.regionalBriefFetchedAt = Date.now();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const requestToken = ++this.regionalBriefRequestToken;
|
||||||
|
this.regionalBriefAbort = controller;
|
||||||
|
if (!this.regionalBrief) this.renderRegionalBriefStatus('loading', info);
|
||||||
|
this.services
|
||||||
|
.fetchRegionalBrief(point.latitude, point.longitude, {
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
.then((payload) => {
|
||||||
|
if (
|
||||||
|
!this.active ||
|
||||||
|
requestToken !== this.regionalBriefRequestToken ||
|
||||||
|
subjectId !== this.regionalBriefSubjectId
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
this.regionalBrief = payload;
|
||||||
|
this.renderRegionalBrief(payload, info);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (
|
||||||
|
error?.name !== 'AbortError' &&
|
||||||
|
this.active &&
|
||||||
|
requestToken === this.regionalBriefRequestToken &&
|
||||||
|
subjectId === this.regionalBriefSubjectId
|
||||||
|
) {
|
||||||
|
this.renderRegionalBriefStatus('unavailable', info);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (this.regionalBriefAbort === controller)
|
||||||
|
this.regionalBriefAbort = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function renderRegionalBriefStatus(status, info) {
|
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 (this.localPlace && status === 'loading') this.localPlace.textContent = 'RESOLVING REGION';
|
|
||||||
if (this.localPlace && status === 'unavailable') this.localPlace.textContent = 'REGION UNAVAILABLE';
|
|
||||||
this.updateLocalPosition(info);
|
|
||||||
}
|
}
|
||||||
|
if (status === 'unavailable') this.newsList?.replaceChildren();
|
||||||
|
if (this.localPlace && status === 'loading')
|
||||||
|
this.localPlace.textContent = 'RESOLVING REGION';
|
||||||
|
if (this.localPlace && status === 'unavailable')
|
||||||
|
this.localPlace.textContent = 'REGION UNAVAILABLE';
|
||||||
|
this.updateLocalPosition(info);
|
||||||
|
}
|
||||||
|
|
||||||
export function renderRegionalBrief(payload, info) {
|
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';
|
|
||||||
if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase();
|
|
||||||
this.updateLocalPosition(info);
|
|
||||||
const weather = payload?.weather;
|
|
||||||
if (this.localTemperature) {
|
|
||||||
this.localTemperature.textContent = Number.isFinite(weather?.temperatureC)
|
|
||||||
? `${Math.round(weather.temperatureC)}°C` : '—';
|
|
||||||
}
|
|
||||||
if (this.localWind) {
|
|
||||||
this.localWind.textContent = Number.isFinite(weather?.windKph)
|
|
||||||
? `${Math.round(weather.windKph)} KM/H` : '—';
|
|
||||||
}
|
|
||||||
if (this.localWindDirection) {
|
|
||||||
this.localWindDirection.textContent = formatCockpitWindDirection(weather?.windDirectionDeg);
|
|
||||||
}
|
|
||||||
if (this.localCondition) this.localCondition.textContent = this.services.weatherCodeLabel(weather?.weatherCode);
|
|
||||||
if (this.localCloud) {
|
|
||||||
this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct)
|
|
||||||
? `CLOUD ${Math.round(weather.cloudCoverPct)}%` : 'CLOUD UNKNOWN';
|
|
||||||
}
|
|
||||||
if (this.localPrecipitation) {
|
|
||||||
this.localPrecipitation.textContent = Number.isFinite(weather?.precipitationMm)
|
|
||||||
? weather.precipitationMm.toFixed(1) : '—';
|
|
||||||
}
|
|
||||||
if (this.signalStream) this.signalStream.dataset.regionalStatus = payload?.status || 'partial';
|
|
||||||
if (this.briefPageIndex === 1 && this.briefSource) {
|
|
||||||
this.briefSource.textContent = `${String(payload?.newsSource || 'REGIONAL NEWS').toUpperCase()} · LOCATION QUERY`;
|
|
||||||
}
|
|
||||||
this.scheduleContextLayout();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const placeLabel =
|
||||||
|
payload?.place?.label || payload?.place?.country || 'REGION UNAVAILABLE';
|
||||||
|
if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase();
|
||||||
|
this.updateLocalPosition(info);
|
||||||
|
const weather = payload?.weather;
|
||||||
|
if (this.localTemperature) {
|
||||||
|
this.localTemperature.textContent = Number.isFinite(weather?.temperatureC)
|
||||||
|
? `${Math.round(weather.temperatureC)}°C`
|
||||||
|
: '—';
|
||||||
|
}
|
||||||
|
if (this.localWind) {
|
||||||
|
this.localWind.textContent = Number.isFinite(weather?.windKph)
|
||||||
|
? `${Math.round(weather.windKph)} KM/H`
|
||||||
|
: '—';
|
||||||
|
}
|
||||||
|
if (this.localWindDirection) {
|
||||||
|
this.localWindDirection.textContent = formatCockpitWindDirection(
|
||||||
|
weather?.windDirectionDeg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.localCondition)
|
||||||
|
this.localCondition.textContent = this.services.weatherCodeLabel(
|
||||||
|
weather?.weatherCode,
|
||||||
|
);
|
||||||
|
if (this.localCloud) {
|
||||||
|
this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct)
|
||||||
|
? `CLOUD ${Math.round(weather.cloudCoverPct)}%`
|
||||||
|
: 'CLOUD UNKNOWN';
|
||||||
|
}
|
||||||
|
if (this.localPrecipitation) {
|
||||||
|
this.localPrecipitation.textContent = Number.isFinite(
|
||||||
|
weather?.precipitationMm,
|
||||||
|
)
|
||||||
|
? weather.precipitationMm.toFixed(1)
|
||||||
|
: '—';
|
||||||
|
}
|
||||||
|
if (this.signalStream)
|
||||||
|
this.signalStream.dataset.regionalStatus = payload?.status || 'partial';
|
||||||
|
if (this.briefPageIndex === 1 && this.briefSource) {
|
||||||
|
this.briefSource.textContent = `${String(payload?.newsSource || 'REGIONAL NEWS').toUpperCase()} · LOCATION QUERY`;
|
||||||
|
}
|
||||||
|
this.scheduleContextLayout();
|
||||||
|
}
|
||||||
+337
-202
@@ -1,225 +1,360 @@
|
|||||||
/** 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;
|
||||||
if (!this.active) {
|
if (!this.active) {
|
||||||
// Entry availability changes on a human timescale (tracking start/stop,
|
// Entry availability changes on a human timescale (tracking start/stop,
|
||||||
// info arriving after a poll) — polling it every rendered frame ran
|
// info arriving after a poll) — polling it every rendered frame ran
|
||||||
// readAircraftInfo() + DOM pokes at display rate in plain map mode.
|
// readAircraftInfo() + DOM pokes at display rate in plain map mode.
|
||||||
// 250 ms keeps the chip imperceptibly fresh; trackedEntityChanged still
|
// 250 ms keeps the chip imperceptibly fresh; trackedEntityChanged still
|
||||||
// fires syncEntry immediately on the events that matter. (perf item 9)
|
// fires syncEntry immediately on the events that matter. (perf item 9)
|
||||||
const nowMs = performance.now();
|
|
||||||
if (nowMs - (this._lastEntrySyncMs || 0) >= 250) {
|
|
||||||
this._lastEntrySyncMs = nowMs;
|
|
||||||
this.syncEntry();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nowMs = performance.now();
|
const nowMs = performance.now();
|
||||||
const info = this.readAircraftInfo();
|
if (nowMs - (this._lastEntrySyncMs || 0) >= 250) {
|
||||||
|
this._lastEntrySyncMs = nowMs;
|
||||||
// Adopt a newly selected track before the camera cadence gate. A context
|
this.syncEntry();
|
||||||
// NEXT/PREV selection can otherwise spend one frame driving the old
|
|
||||||
// aircraft with the new aircraft's metadata.
|
|
||||||
this._adoptTrackedEntity(nowMs, info);
|
|
||||||
if (!info || !this.trackedEntity || !this.viewer.entities.contains(this.trackedEntity)) {
|
|
||||||
if (nowMs < this.contextNavigationDeadlineMs) return;
|
|
||||||
this.exit({ restoreTracking: false });
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!cockpitUiUpdateDue(nowMs, this.lastCameraUpdateMs, COCKPIT_CAMERA_UPDATE_MS)) return;
|
return;
|
||||||
this.lastCameraUpdateMs = nowMs;
|
}
|
||||||
|
|
||||||
const target = this.trackedEntity.position.getValue(this.viewer.clock.currentTime, this.scratchTarget);
|
const nowMs = performance.now();
|
||||||
if (!target) return;
|
const info = this.readAircraftInfo();
|
||||||
const dtSec = Math.min(0.1, Math.max(0, (nowMs - this.lastFrameMs) / 1000));
|
|
||||||
this.lastFrameMs = nowMs;
|
|
||||||
if (Number.isFinite(info.track)) {
|
|
||||||
this.heading = slewHeading(
|
|
||||||
this.heading ?? info.track, info.track, COCKPIT_HEADING_SLEW_DPS * dtSec,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.cockpitAnchorValid) {
|
// Adopt a newly selected track before the camera cadence gate. A context
|
||||||
Cesium.Cartesian3.clone(target, this.cockpitAnchor);
|
// NEXT/PREV selection can otherwise spend one frame driving the old
|
||||||
this.cockpitAnchorValid = true;
|
// aircraft with the new aircraft's metadata.
|
||||||
}
|
this._adoptTrackedEntity(nowMs, info);
|
||||||
|
if (
|
||||||
|
!info ||
|
||||||
|
!this.trackedEntity ||
|
||||||
|
!this.viewer.entities.contains(this.trackedEntity)
|
||||||
|
) {
|
||||||
|
if (nowMs < this.contextNavigationDeadlineMs) return;
|
||||||
|
this.exit({ restoreTracking: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!cockpitUiUpdateDue(
|
||||||
|
nowMs,
|
||||||
|
this.lastCameraUpdateMs,
|
||||||
|
COCKPIT_CAMERA_UPDATE_MS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
this.lastCameraUpdateMs = nowMs;
|
||||||
|
|
||||||
// First-person motion cannot use the delayed feed correction as a raw
|
const target = this.trackedEntity.position.getValue(
|
||||||
// camera destination: a harmless icon re-anchor becomes a whole-world
|
this.viewer.clock.currentTime,
|
||||||
// surge/reversal in cockpit view. Advance the camera anchor inertially
|
this.scratchTarget,
|
||||||
// from the reported course/speed and converge on the authoritative layer
|
);
|
||||||
// display position at a bounded rate. This preserves the layer's required
|
if (!target) return;
|
||||||
// 15/30-second interpolation and per-frame cache without exposing its
|
const dtSec = Math.min(0.1, Math.max(0, (nowMs - this.lastFrameMs) / 1000));
|
||||||
// sample-boundary corrections to the camera.
|
this.lastFrameMs = nowMs;
|
||||||
const headingRad = Cesium.Math.toRadians(this.heading ?? 0);
|
if (Number.isFinite(info.track)) {
|
||||||
const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG);
|
this.heading = slewHeading(
|
||||||
const speedMps = Number.isFinite(info.velocityMps) ? Math.max(0, info.velocityMps) : 0;
|
this.heading ?? info.track,
|
||||||
|
info.track,
|
||||||
if (info.stale) {
|
COCKPIT_HEADING_SLEW_DPS * dtSec,
|
||||||
// A feed backoff has no authoritative velocity epoch to advance from.
|
|
||||||
// Hold the cockpit on the exact layer-rendered position so the camera
|
|
||||||
// cannot coast away while the icon correctly remains fixed.
|
|
||||||
Cesium.Cartesian3.clone(target, this.cockpitAnchor);
|
|
||||||
} else {
|
|
||||||
Cesium.Transforms.eastNorthUpToFixedFrame(
|
|
||||||
this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchEnu,
|
|
||||||
);
|
|
||||||
this.scratchLocal.x = Math.sin(headingRad);
|
|
||||||
this.scratchLocal.y = Math.cos(headingRad);
|
|
||||||
this.scratchLocal.z = 0;
|
|
||||||
Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchHorizontal);
|
|
||||||
Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal);
|
|
||||||
Cesium.Cartesian3.multiplyByScalar(
|
|
||||||
this.scratchHorizontal, speedMps * dtSec, this.scratchAdvance,
|
|
||||||
);
|
|
||||||
Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchAdvance, this.cockpitAnchor);
|
|
||||||
Cesium.Cartesian3.subtract(target, this.cockpitAnchor, this.scratchCorrection);
|
|
||||||
const correctionDistanceM = Cesium.Cartesian3.magnitude(this.scratchCorrection);
|
|
||||||
const correctionStepM = cockpitAnchorCorrectionStep(correctionDistanceM, speedMps, dtSec);
|
|
||||||
if (correctionStepM > 0 && correctionDistanceM > 0) {
|
|
||||||
Cesium.Cartesian3.multiplyByScalar(
|
|
||||||
this.scratchCorrection, correctionStepM / correctionDistanceM, this.scratchCorrection,
|
|
||||||
);
|
|
||||||
Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchCorrection, this.cockpitAnchor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The inertial anchor is independent of the layer's render-floor clamp and
|
|
||||||
// can otherwise coast into a photoreal mesh while a landing contact is
|
|
||||||
// between fixes. Clamp it against the same mesh-first shared floor used by
|
|
||||||
// aircraft rendering. For a slow contact whose floor cell is still cold,
|
|
||||||
// its already-clamped render position is a conservative temporary floor.
|
|
||||||
const anchorCartographic = Cesium.Cartographic.fromCartesian(
|
|
||||||
this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchAnchorCartographic,
|
|
||||||
);
|
);
|
||||||
const targetCartographic = Cesium.Cartographic.fromCartesian(
|
}
|
||||||
target, Cesium.Ellipsoid.WGS84, this.scratchTargetCartographic,
|
|
||||||
);
|
|
||||||
let cockpitFloorM = this.services.cachedGroundFloor(info.latitude, info.longitude);
|
|
||||||
if (info.onGround === true) {
|
|
||||||
const groundPoint = [{ lat: info.latitude, lon: info.longitude }];
|
|
||||||
this.services.warmGroundFloor(groundPoint);
|
|
||||||
const meshFloorM = this.services.cachedMeshFloor(info.latitude, info.longitude);
|
|
||||||
if (this.services.meshFloorPreferred() && !Number.isFinite(meshFloorM)) {
|
|
||||||
if (cockpitUiUpdateDue(nowMs, this.lastGroundProbeMs, COCKPIT_GROUND_PROBE_MS)) {
|
|
||||||
this.lastGroundProbeMs = nowMs;
|
|
||||||
const viewerCartographic = this.viewer.camera.positionCartographic;
|
|
||||||
this.services.sampleMeshFloorCells(this.viewer.scene, groundPoint, {
|
|
||||||
excludeObjects: [this.trackedEntity],
|
|
||||||
viewerLat: Cesium.Math.toDegrees(viewerCartographic.latitude),
|
|
||||||
viewerLon: Cesium.Math.toDegrees(viewerCartographic.longitude),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
cockpitFloorM = this.services.cachedMeshFloor(info.latitude, info.longitude);
|
|
||||||
if (!Number.isFinite(cockpitFloorM)) {
|
|
||||||
if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs;
|
|
||||||
if (!cockpitSurfaceWaitExpired(nowMs, this.surfaceWaitStartedMs, COCKPIT_GROUND_WAIT_TIMEOUT_MS)) {
|
|
||||||
// Keep the already-safe map camera in place while the photoreal
|
|
||||||
// surface under a parked aircraft is acquired. The bounded wait
|
|
||||||
// prevents a permanently cold mesh cell from freezing cockpit.
|
|
||||||
this.surfaceAcquiring = true;
|
|
||||||
this.surfaceFallback = false;
|
|
||||||
if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) {
|
|
||||||
this.lastHudUpdateMs = nowMs;
|
|
||||||
this.updateHud(info, nowMs);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.surfaceAcquiring = false;
|
|
||||||
this.surfaceFallback = true;
|
|
||||||
} else {
|
|
||||||
this.surfaceWaitStartedMs = 0;
|
|
||||||
this.surfaceAcquiring = false;
|
|
||||||
this.surfaceFallback = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.surfaceWaitStartedMs = 0;
|
|
||||||
this.surfaceAcquiring = false;
|
|
||||||
this.surfaceFallback = false;
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(cockpitFloorM)
|
|
||||||
&& speedMps < 90
|
|
||||||
&& Number.isFinite(targetCartographic?.height)) {
|
|
||||||
cockpitFloorM = targetCartographic.height - this.services.GROUND_FLOOR_LIFT_M;
|
|
||||||
}
|
|
||||||
if (anchorCartographic && Number.isFinite(cockpitFloorM)) {
|
|
||||||
const minimumAnchorHeightM = cockpitGroundSafeHeight(
|
|
||||||
anchorCartographic.height,
|
|
||||||
cockpitFloorM,
|
|
||||||
COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M,
|
|
||||||
);
|
|
||||||
if (minimumAnchorHeightM !== anchorCartographic.height) {
|
|
||||||
anchorCartographic.height = minimumAnchorHeightM;
|
|
||||||
Cesium.Ellipsoid.WGS84.cartographicToCartesian(anchorCartographic, this.cockpitAnchor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rebuild the local frame at the stabilized anchor after advancing it.
|
if (!this.cockpitAnchorValid) {
|
||||||
|
Cesium.Cartesian3.clone(target, this.cockpitAnchor);
|
||||||
|
this.cockpitAnchorValid = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First-person motion cannot use the delayed feed correction as a raw
|
||||||
|
// camera destination: a harmless icon re-anchor becomes a whole-world
|
||||||
|
// surge/reversal in cockpit view. Advance the camera anchor inertially
|
||||||
|
// from the reported course/speed and converge on the authoritative layer
|
||||||
|
// display position at a bounded rate. This preserves the layer's required
|
||||||
|
// 15/30-second interpolation and per-frame cache without exposing its
|
||||||
|
// sample-boundary corrections to the camera.
|
||||||
|
const headingRad = Cesium.Math.toRadians(this.heading ?? 0);
|
||||||
|
const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG);
|
||||||
|
const speedMps = Number.isFinite(info.velocityMps)
|
||||||
|
? Math.max(0, info.velocityMps)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (info.stale) {
|
||||||
|
// A feed backoff has no authoritative velocity epoch to advance from.
|
||||||
|
// Hold the cockpit on the exact layer-rendered position so the camera
|
||||||
|
// cannot coast away while the icon correctly remains fixed.
|
||||||
|
Cesium.Cartesian3.clone(target, this.cockpitAnchor);
|
||||||
|
} else {
|
||||||
Cesium.Transforms.eastNorthUpToFixedFrame(
|
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.y = Math.cos(headingRad) * Math.cos(pitchRad);
|
|
||||||
this.scratchLocal.z = Math.sin(pitchRad);
|
|
||||||
Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchForward);
|
|
||||||
Cesium.Cartesian3.normalize(this.scratchForward, this.scratchForward);
|
|
||||||
|
|
||||||
this.scratchLocal.x = -Math.sin(headingRad) * Math.sin(pitchRad);
|
|
||||||
this.scratchLocal.y = -Math.cos(headingRad) * Math.sin(pitchRad);
|
|
||||||
this.scratchLocal.z = Math.cos(pitchRad);
|
|
||||||
Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchUp);
|
|
||||||
Cesium.Cartesian3.normalize(this.scratchUp, this.scratchUp);
|
|
||||||
|
|
||||||
Cesium.Cartesian3.multiplyByScalar(
|
Cesium.Cartesian3.multiplyByScalar(
|
||||||
this.scratchHorizontal, COCKPIT_FORWARD_OFFSET_M, this.scratchCamera,
|
this.scratchHorizontal,
|
||||||
|
speedMps * dtSec,
|
||||||
|
this.scratchAdvance,
|
||||||
);
|
);
|
||||||
Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchCamera, this.scratchCamera);
|
Cesium.Cartesian3.add(
|
||||||
Cesium.Matrix4.getTranslation(this.scratchEnu, this.scratchTarget);
|
this.cockpitAnchor,
|
||||||
Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget);
|
this.scratchAdvance,
|
||||||
Cesium.Cartesian3.multiplyByScalar(this.scratchTarget, COCKPIT_UP_OFFSET_M, this.scratchTarget);
|
this.cockpitAnchor,
|
||||||
Cesium.Cartesian3.add(this.scratchCamera, this.scratchTarget, this.scratchCamera);
|
|
||||||
|
|
||||||
// Recheck at the final forward-offset camera coordinate because a taxiing
|
|
||||||
// aircraft can cross into an adjacent coarse floor cell between updates.
|
|
||||||
const cameraCartographic = Cesium.Cartographic.fromCartesian(
|
|
||||||
this.scratchCamera, Cesium.Ellipsoid.WGS84, this.scratchCameraCartographic,
|
|
||||||
);
|
);
|
||||||
if (cameraCartographic) {
|
Cesium.Cartesian3.subtract(
|
||||||
const cameraLat = Cesium.Math.toDegrees(cameraCartographic.latitude);
|
target,
|
||||||
const cameraLon = Cesium.Math.toDegrees(cameraCartographic.longitude);
|
this.cockpitAnchor,
|
||||||
const cameraFloorM = this.services.cachedGroundFloor(cameraLat, cameraLon);
|
this.scratchCorrection,
|
||||||
if (Number.isFinite(cameraFloorM)) {
|
);
|
||||||
cockpitFloorM = Math.max(cockpitFloorM ?? Number.NEGATIVE_INFINITY, cameraFloorM);
|
const correctionDistanceM = Cesium.Cartesian3.magnitude(
|
||||||
}
|
this.scratchCorrection,
|
||||||
const safeHeightM = cockpitGroundSafeHeight(
|
);
|
||||||
cameraCartographic.height,
|
const correctionStepM = cockpitAnchorCorrectionStep(
|
||||||
cockpitFloorM,
|
correctionDistanceM,
|
||||||
COCKPIT_MIN_GROUND_CLEARANCE_M,
|
speedMps,
|
||||||
|
dtSec,
|
||||||
|
);
|
||||||
|
if (correctionStepM > 0 && correctionDistanceM > 0) {
|
||||||
|
Cesium.Cartesian3.multiplyByScalar(
|
||||||
|
this.scratchCorrection,
|
||||||
|
correctionStepM / correctionDistanceM,
|
||||||
|
this.scratchCorrection,
|
||||||
|
);
|
||||||
|
Cesium.Cartesian3.add(
|
||||||
|
this.cockpitAnchor,
|
||||||
|
this.scratchCorrection,
|
||||||
|
this.cockpitAnchor,
|
||||||
);
|
);
|
||||||
if (safeHeightM !== cameraCartographic.height) {
|
|
||||||
cameraCartographic.height = safeHeightM;
|
|
||||||
Cesium.Ellipsoid.WGS84.cartographicToCartesian(cameraCartographic, this.scratchCamera);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.viewer.camera.setView({
|
|
||||||
destination: this.scratchCamera,
|
|
||||||
orientation: { direction: this.scratchForward, up: this.scratchUp },
|
|
||||||
});
|
|
||||||
if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) {
|
|
||||||
this.lastHudUpdateMs = nowMs;
|
|
||||||
this.updateHud(info, nowMs);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The inertial anchor is independent of the layer's render-floor clamp and
|
||||||
|
// can otherwise coast into a photoreal mesh while a landing contact is
|
||||||
|
// between fixes. Clamp it against the same mesh-first shared floor used by
|
||||||
|
// aircraft rendering. For a slow contact whose floor cell is still cold,
|
||||||
|
// its already-clamped render position is a conservative temporary floor.
|
||||||
|
const anchorCartographic = Cesium.Cartographic.fromCartesian(
|
||||||
|
this.cockpitAnchor,
|
||||||
|
Cesium.Ellipsoid.WGS84,
|
||||||
|
this.scratchAnchorCartographic,
|
||||||
|
);
|
||||||
|
const targetCartographic = Cesium.Cartographic.fromCartesian(
|
||||||
|
target,
|
||||||
|
Cesium.Ellipsoid.WGS84,
|
||||||
|
this.scratchTargetCartographic,
|
||||||
|
);
|
||||||
|
let cockpitFloorM = this.services.cachedGroundFloor(
|
||||||
|
info.latitude,
|
||||||
|
info.longitude,
|
||||||
|
);
|
||||||
|
if (info.onGround === true) {
|
||||||
|
const groundPoint = [{ lat: info.latitude, lon: info.longitude }];
|
||||||
|
this.services.warmGroundFloor(groundPoint);
|
||||||
|
const meshFloorM = this.services.cachedMeshFloor(
|
||||||
|
info.latitude,
|
||||||
|
info.longitude,
|
||||||
|
);
|
||||||
|
if (this.services.meshFloorPreferred() && !Number.isFinite(meshFloorM)) {
|
||||||
|
if (
|
||||||
|
cockpitUiUpdateDue(
|
||||||
|
nowMs,
|
||||||
|
this.lastGroundProbeMs,
|
||||||
|
COCKPIT_GROUND_PROBE_MS,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.lastGroundProbeMs = nowMs;
|
||||||
|
const viewerCartographic = this.viewer.camera.positionCartographic;
|
||||||
|
this.services.sampleMeshFloorCells(this.viewer.scene, groundPoint, {
|
||||||
|
excludeObjects: [this.trackedEntity],
|
||||||
|
viewerLat: Cesium.Math.toDegrees(viewerCartographic.latitude),
|
||||||
|
viewerLon: Cesium.Math.toDegrees(viewerCartographic.longitude),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cockpitFloorM = this.services.cachedMeshFloor(
|
||||||
|
info.latitude,
|
||||||
|
info.longitude,
|
||||||
|
);
|
||||||
|
if (!Number.isFinite(cockpitFloorM)) {
|
||||||
|
if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs;
|
||||||
|
if (
|
||||||
|
!cockpitSurfaceWaitExpired(
|
||||||
|
nowMs,
|
||||||
|
this.surfaceWaitStartedMs,
|
||||||
|
COCKPIT_GROUND_WAIT_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// Keep the already-safe map camera in place while the photoreal
|
||||||
|
// surface under a parked aircraft is acquired. The bounded wait
|
||||||
|
// prevents a permanently cold mesh cell from freezing cockpit.
|
||||||
|
this.surfaceAcquiring = true;
|
||||||
|
this.surfaceFallback = false;
|
||||||
|
if (
|
||||||
|
cockpitUiUpdateDue(
|
||||||
|
nowMs,
|
||||||
|
this.lastHudUpdateMs,
|
||||||
|
COCKPIT_HUD_UPDATE_MS,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.lastHudUpdateMs = nowMs;
|
||||||
|
this.updateHud(info, nowMs);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.surfaceAcquiring = false;
|
||||||
|
this.surfaceFallback = true;
|
||||||
|
} else {
|
||||||
|
this.surfaceWaitStartedMs = 0;
|
||||||
|
this.surfaceAcquiring = false;
|
||||||
|
this.surfaceFallback = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.surfaceWaitStartedMs = 0;
|
||||||
|
this.surfaceAcquiring = false;
|
||||||
|
this.surfaceFallback = false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isFinite(cockpitFloorM) &&
|
||||||
|
speedMps < 90 &&
|
||||||
|
Number.isFinite(targetCartographic?.height)
|
||||||
|
) {
|
||||||
|
cockpitFloorM =
|
||||||
|
targetCartographic.height - this.services.GROUND_FLOOR_LIFT_M;
|
||||||
|
}
|
||||||
|
if (anchorCartographic && Number.isFinite(cockpitFloorM)) {
|
||||||
|
const minimumAnchorHeightM = cockpitGroundSafeHeight(
|
||||||
|
anchorCartographic.height,
|
||||||
|
cockpitFloorM,
|
||||||
|
COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M,
|
||||||
|
);
|
||||||
|
if (minimumAnchorHeightM !== anchorCartographic.height) {
|
||||||
|
anchorCartographic.height = minimumAnchorHeightM;
|
||||||
|
Cesium.Ellipsoid.WGS84.cartographicToCartesian(
|
||||||
|
anchorCartographic,
|
||||||
|
this.cockpitAnchor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the local frame at the stabilized anchor after advancing it.
|
||||||
|
Cesium.Transforms.eastNorthUpToFixedFrame(
|
||||||
|
this.cockpitAnchor,
|
||||||
|
Cesium.Ellipsoid.WGS84,
|
||||||
|
this.scratchEnu,
|
||||||
|
);
|
||||||
|
this.scratchLocal.x = Math.sin(headingRad);
|
||||||
|
this.scratchLocal.y = Math.cos(headingRad);
|
||||||
|
this.scratchLocal.z = 0;
|
||||||
|
Cesium.Matrix4.multiplyByPointAsVector(
|
||||||
|
this.scratchEnu,
|
||||||
|
this.scratchLocal,
|
||||||
|
this.scratchHorizontal,
|
||||||
|
);
|
||||||
|
Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal);
|
||||||
|
|
||||||
|
this.scratchLocal.x = Math.sin(headingRad) * Math.cos(pitchRad);
|
||||||
|
this.scratchLocal.y = Math.cos(headingRad) * Math.cos(pitchRad);
|
||||||
|
this.scratchLocal.z = Math.sin(pitchRad);
|
||||||
|
Cesium.Matrix4.multiplyByPointAsVector(
|
||||||
|
this.scratchEnu,
|
||||||
|
this.scratchLocal,
|
||||||
|
this.scratchForward,
|
||||||
|
);
|
||||||
|
Cesium.Cartesian3.normalize(this.scratchForward, this.scratchForward);
|
||||||
|
|
||||||
|
this.scratchLocal.x = -Math.sin(headingRad) * Math.sin(pitchRad);
|
||||||
|
this.scratchLocal.y = -Math.cos(headingRad) * Math.sin(pitchRad);
|
||||||
|
this.scratchLocal.z = Math.cos(pitchRad);
|
||||||
|
Cesium.Matrix4.multiplyByPointAsVector(
|
||||||
|
this.scratchEnu,
|
||||||
|
this.scratchLocal,
|
||||||
|
this.scratchUp,
|
||||||
|
);
|
||||||
|
Cesium.Cartesian3.normalize(this.scratchUp, this.scratchUp);
|
||||||
|
|
||||||
|
Cesium.Cartesian3.multiplyByScalar(
|
||||||
|
this.scratchHorizontal,
|
||||||
|
COCKPIT_FORWARD_OFFSET_M,
|
||||||
|
this.scratchCamera,
|
||||||
|
);
|
||||||
|
Cesium.Cartesian3.add(
|
||||||
|
this.cockpitAnchor,
|
||||||
|
this.scratchCamera,
|
||||||
|
this.scratchCamera,
|
||||||
|
);
|
||||||
|
Cesium.Matrix4.getTranslation(this.scratchEnu, this.scratchTarget);
|
||||||
|
Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget);
|
||||||
|
Cesium.Cartesian3.multiplyByScalar(
|
||||||
|
this.scratchTarget,
|
||||||
|
COCKPIT_UP_OFFSET_M,
|
||||||
|
this.scratchTarget,
|
||||||
|
);
|
||||||
|
Cesium.Cartesian3.add(
|
||||||
|
this.scratchCamera,
|
||||||
|
this.scratchTarget,
|
||||||
|
this.scratchCamera,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recheck at the final forward-offset camera coordinate because a taxiing
|
||||||
|
// aircraft can cross into an adjacent coarse floor cell between updates.
|
||||||
|
const cameraCartographic = Cesium.Cartographic.fromCartesian(
|
||||||
|
this.scratchCamera,
|
||||||
|
Cesium.Ellipsoid.WGS84,
|
||||||
|
this.scratchCameraCartographic,
|
||||||
|
);
|
||||||
|
if (cameraCartographic) {
|
||||||
|
const cameraLat = Cesium.Math.toDegrees(cameraCartographic.latitude);
|
||||||
|
const cameraLon = Cesium.Math.toDegrees(cameraCartographic.longitude);
|
||||||
|
const cameraFloorM = this.services.cachedGroundFloor(cameraLat, cameraLon);
|
||||||
|
if (Number.isFinite(cameraFloorM)) {
|
||||||
|
cockpitFloorM = Math.max(
|
||||||
|
cockpitFloorM ?? Number.NEGATIVE_INFINITY,
|
||||||
|
cameraFloorM,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const safeHeightM = cockpitGroundSafeHeight(
|
||||||
|
cameraCartographic.height,
|
||||||
|
cockpitFloorM,
|
||||||
|
COCKPIT_MIN_GROUND_CLEARANCE_M,
|
||||||
|
);
|
||||||
|
if (safeHeightM !== cameraCartographic.height) {
|
||||||
|
cameraCartographic.height = safeHeightM;
|
||||||
|
Cesium.Ellipsoid.WGS84.cartographicToCartesian(
|
||||||
|
cameraCartographic,
|
||||||
|
this.scratchCamera,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.viewer.camera.setView({
|
||||||
|
destination: this.scratchCamera,
|
||||||
|
orientation: { direction: this.scratchForward, up: this.scratchUp },
|
||||||
|
});
|
||||||
|
if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) {
|
||||||
|
this.lastHudUpdateMs = nowMs;
|
||||||
|
this.updateHud(info, nowMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
+152
-129
@@ -1,134 +1,157 @@
|
|||||||
/** 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 =
|
||||||
const trackedId = info.icao24 || info.id;
|
this.services.militaryAwarenessLayer.getContextSnapshot?.() || null;
|
||||||
const readout = resolveCockpitContextReadout({ snapshot, info });
|
const trackedId = info.icao24 || info.id;
|
||||||
if (!readout.visible) {
|
const readout = resolveCockpitContextReadout({ snapshot, info });
|
||||||
this.context.hidden = true;
|
if (!readout.visible) {
|
||||||
this.hud?.classList.remove('context-active');
|
this.context.hidden = true;
|
||||||
this.contextLayoutStamp = null;
|
this.hud?.classList.remove('context-active');
|
||||||
this.pushCockpitSignal(
|
this.contextLayoutStamp = null;
|
||||||
'context-status',
|
this.pushCockpitSignal(
|
||||||
'info',
|
'context-status',
|
||||||
'CONTEXT STANDBY',
|
'info',
|
||||||
'ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS',
|
'CONTEXT STANDBY',
|
||||||
);
|
'ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS',
|
||||||
return;
|
);
|
||||||
}
|
return;
|
||||||
|
|
||||||
this.context.hidden = false;
|
|
||||||
this.hud?.classList.add('context-active');
|
|
||||||
if (this.contextSubject) {
|
|
||||||
const installationCoverage = snapshot.cohorts
|
|
||||||
.find((cohort) => cohort.id === 'military-installations')?.coverage;
|
|
||||||
this.contextSubject.textContent = formatCockpitContextScope(
|
|
||||||
snapshot.subject.label || trackedId,
|
|
||||||
snapshot.radiusM,
|
|
||||||
installationCoverage,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Navigation stays wired in every state — the operator must always be able
|
|
||||||
// to step off the current contact from the panel that hosts the controls.
|
|
||||||
if (this.contextPrevious) this.contextPrevious.disabled = !snapshot.navigation?.canPrevious;
|
|
||||||
if (this.contextNext) this.contextNext.disabled = !snapshot.navigation?.canNext;
|
|
||||||
|
|
||||||
if (readout.contactLost) {
|
|
||||||
// The subject left its source. Every number below is measured against a
|
|
||||||
// position that stopped updating, so hold the last rendered readout and
|
|
||||||
// say so instead of re-deriving stale geometry as if it were live.
|
|
||||||
const enteringLost = this.context.dataset.state !== 'lost';
|
|
||||||
this.context.dataset.state = 'lost';
|
|
||||||
if (this.contextUncertainty) {
|
|
||||||
this.contextUncertainty.textContent = 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR';
|
|
||||||
}
|
|
||||||
// The cue changes the footer's height; re-run layout once on the way in
|
|
||||||
// rather than every frame the contact stays lost.
|
|
||||||
if (enteringLost) this.scheduleContextLayout();
|
|
||||||
this.pushCockpitSignal(
|
|
||||||
'context-status',
|
|
||||||
'warning',
|
|
||||||
`CONTACT LOST · ${snapshot.subject.label || snapshot.subject.id || 'SUBJECT'}`,
|
|
||||||
'SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let unknownCount = 0;
|
|
||||||
const nearest = [];
|
|
||||||
for (const cohort of snapshot.cohorts) {
|
|
||||||
const element = this.contextCohorts.get(cohort.id);
|
|
||||||
const value = element?.querySelector('strong');
|
|
||||||
if (value) value.textContent = cohort.count === null ? '?' : String(cohort.count);
|
|
||||||
element?.classList.toggle('unknown', cohort.relationship === 'UNKNOWN');
|
|
||||||
if (cohort.count === null) unknownCount += 1;
|
|
||||||
for (const item of cohort.nearest) nearest.push({ ...item, cohort });
|
|
||||||
}
|
|
||||||
nearest.sort((a, b) => (a.distanceM ?? Infinity) - (b.distanceM ?? Infinity));
|
|
||||||
const closest = nearest[0] || null;
|
|
||||||
const closestLabel = this.services.formatAwarenessLabel(closest);
|
|
||||||
if (this.contextNearestLabel) {
|
|
||||||
this.contextNearestLabel.textContent = closest
|
|
||||||
? `${closest.cohort.label.toUpperCase()} · ${closestLabel}` : 'NO AVAILABLE EXAMPLE';
|
|
||||||
this.contextNearestLabel.setAttribute(
|
|
||||||
'aria-label',
|
|
||||||
closest && closestLabel === '—'
|
|
||||||
? `${closest.cohort.label}, Unavailable`
|
|
||||||
: this.contextNearestLabel.textContent,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (this.contextDistance) {
|
|
||||||
const distanceM = closest?.distanceM;
|
|
||||||
this.contextDistance.textContent = Number.isFinite(distanceM)
|
|
||||||
? `${distanceM < 10000 ? (distanceM / 1000).toFixed(1) : Math.round(distanceM / 1000)} KM` : '—';
|
|
||||||
this.contextDistance.setAttribute(
|
|
||||||
'aria-label',
|
|
||||||
Number.isFinite(distanceM) ? this.contextDistance.textContent : 'Unavailable',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The arrow and BRG are nose-relative to the tracked aircraft. When the
|
|
||||||
// subject is a vessel, an installation, or another aircraft, the rest of
|
|
||||||
// this row is measured from that subject — so the aircraft-frame half is
|
|
||||||
// dashed rather than presented alongside subject-frame distances as if the
|
|
||||||
// two shared an origin.
|
|
||||||
let relative = null;
|
|
||||||
if (readout.aircraftRelative
|
|
||||||
&& closest?.position && Number.isFinite(info.latitude) && Number.isFinite(info.longitude)) {
|
|
||||||
const cartographic = Cesium.Cartographic.fromCartesian(closest.position);
|
|
||||||
const bearing = cartographic ? bearingBetweenCoordinates(
|
|
||||||
info.latitude,
|
|
||||||
info.longitude,
|
|
||||||
Cesium.Math.toDegrees(cartographic.latitude),
|
|
||||||
Cesium.Math.toDegrees(cartographic.longitude),
|
|
||||||
) : null;
|
|
||||||
relative = relativeBearing(bearing, heading);
|
|
||||||
}
|
|
||||||
if (this.contextDirection) {
|
|
||||||
this.contextDirection.style.transform = `rotate(${relative ?? 0}deg)`;
|
|
||||||
this.contextDirection.classList.toggle('unknown', relative === null);
|
|
||||||
}
|
|
||||||
if (this.contextBearing) {
|
|
||||||
if (relative === null) this.contextBearing.textContent = 'BRG —';
|
|
||||||
else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD';
|
|
||||||
else this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`;
|
|
||||||
}
|
|
||||||
if (this.contextUncertainty) {
|
|
||||||
this.contextUncertainty.textContent = unknownCount
|
|
||||||
? `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN · NOT AN ALL-CLEAR`
|
|
||||||
: 'AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR';
|
|
||||||
}
|
|
||||||
if (this.contextUpdated) {
|
|
||||||
this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt)
|
|
||||||
? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z' : '--:--:--Z';
|
|
||||||
}
|
|
||||||
this.context.dataset.state = unknownCount ? 'uncertain' : 'current';
|
|
||||||
this.updateCockpitSignals(snapshot, unknownCount);
|
|
||||||
if (this.contextLayoutStamp !== snapshot.evaluatedAt) {
|
|
||||||
this.contextLayoutStamp = snapshot.evaluatedAt;
|
|
||||||
this.scheduleContextLayout();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.context.hidden = false;
|
||||||
|
this.hud?.classList.add('context-active');
|
||||||
|
if (this.contextSubject) {
|
||||||
|
const installationCoverage = snapshot.cohorts.find(
|
||||||
|
(cohort) => cohort.id === 'military-installations',
|
||||||
|
)?.coverage;
|
||||||
|
this.contextSubject.textContent = formatCockpitContextScope(
|
||||||
|
snapshot.subject.label || trackedId,
|
||||||
|
snapshot.radiusM,
|
||||||
|
installationCoverage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Navigation stays wired in every state — the operator must always be able
|
||||||
|
// to step off the current contact from the panel that hosts the controls.
|
||||||
|
if (this.contextPrevious)
|
||||||
|
this.contextPrevious.disabled = !snapshot.navigation?.canPrevious;
|
||||||
|
if (this.contextNext)
|
||||||
|
this.contextNext.disabled = !snapshot.navigation?.canNext;
|
||||||
|
|
||||||
|
if (readout.contactLost) {
|
||||||
|
// The subject left its source. Every number below is measured against a
|
||||||
|
// position that stopped updating, so hold the last rendered readout and
|
||||||
|
// say so instead of re-deriving stale geometry as if it were live.
|
||||||
|
const enteringLost = this.context.dataset.state !== 'lost';
|
||||||
|
this.context.dataset.state = 'lost';
|
||||||
|
if (this.contextUncertainty) {
|
||||||
|
this.contextUncertainty.textContent =
|
||||||
|
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR';
|
||||||
|
}
|
||||||
|
// The cue changes the footer's height; re-run layout once on the way in
|
||||||
|
// rather than every frame the contact stays lost.
|
||||||
|
if (enteringLost) this.scheduleContextLayout();
|
||||||
|
this.pushCockpitSignal(
|
||||||
|
'context-status',
|
||||||
|
'warning',
|
||||||
|
`CONTACT LOST · ${snapshot.subject.label || snapshot.subject.id || 'SUBJECT'}`,
|
||||||
|
'SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let unknownCount = 0;
|
||||||
|
const nearest = [];
|
||||||
|
for (const cohort of snapshot.cohorts) {
|
||||||
|
const element = this.contextCohorts.get(cohort.id);
|
||||||
|
const value = element?.querySelector('strong');
|
||||||
|
if (value)
|
||||||
|
value.textContent = cohort.count === null ? '?' : String(cohort.count);
|
||||||
|
element?.classList.toggle('unknown', cohort.relationship === 'UNKNOWN');
|
||||||
|
if (cohort.count === null) unknownCount += 1;
|
||||||
|
for (const item of cohort.nearest) nearest.push({ ...item, cohort });
|
||||||
|
}
|
||||||
|
nearest.sort((a, b) => (a.distanceM ?? Infinity) - (b.distanceM ?? Infinity));
|
||||||
|
const closest = nearest[0] || null;
|
||||||
|
const closestLabel = this.services.formatAwarenessLabel(closest);
|
||||||
|
if (this.contextNearestLabel) {
|
||||||
|
this.contextNearestLabel.textContent = closest
|
||||||
|
? `${closest.cohort.label.toUpperCase()} · ${closestLabel}`
|
||||||
|
: 'NO AVAILABLE EXAMPLE';
|
||||||
|
this.contextNearestLabel.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
closest && closestLabel === '—'
|
||||||
|
? `${closest.cohort.label}, Unavailable`
|
||||||
|
: this.contextNearestLabel.textContent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.contextDistance) {
|
||||||
|
const distanceM = closest?.distanceM;
|
||||||
|
this.contextDistance.textContent = Number.isFinite(distanceM)
|
||||||
|
? `${distanceM < 10000 ? (distanceM / 1000).toFixed(1) : Math.round(distanceM / 1000)} KM`
|
||||||
|
: '—';
|
||||||
|
this.contextDistance.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
Number.isFinite(distanceM)
|
||||||
|
? this.contextDistance.textContent
|
||||||
|
: 'Unavailable',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The arrow and BRG are nose-relative to the tracked aircraft. When the
|
||||||
|
// subject is a vessel, an installation, or another aircraft, the rest of
|
||||||
|
// this row is measured from that subject — so the aircraft-frame half is
|
||||||
|
// dashed rather than presented alongside subject-frame distances as if the
|
||||||
|
// two shared an origin.
|
||||||
|
let relative = null;
|
||||||
|
if (
|
||||||
|
readout.aircraftRelative &&
|
||||||
|
closest?.position &&
|
||||||
|
Number.isFinite(info.latitude) &&
|
||||||
|
Number.isFinite(info.longitude)
|
||||||
|
) {
|
||||||
|
const cartographic = Cesium.Cartographic.fromCartesian(closest.position);
|
||||||
|
const bearing = cartographic
|
||||||
|
? bearingBetweenCoordinates(
|
||||||
|
info.latitude,
|
||||||
|
info.longitude,
|
||||||
|
Cesium.Math.toDegrees(cartographic.latitude),
|
||||||
|
Cesium.Math.toDegrees(cartographic.longitude),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
relative = relativeBearing(bearing, heading);
|
||||||
|
}
|
||||||
|
if (this.contextDirection) {
|
||||||
|
this.contextDirection.style.transform = `rotate(${relative ?? 0}deg)`;
|
||||||
|
this.contextDirection.classList.toggle('unknown', relative === null);
|
||||||
|
}
|
||||||
|
if (this.contextBearing) {
|
||||||
|
if (relative === null) this.contextBearing.textContent = 'BRG —';
|
||||||
|
else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD';
|
||||||
|
else
|
||||||
|
this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`;
|
||||||
|
}
|
||||||
|
if (this.contextUncertainty) {
|
||||||
|
this.contextUncertainty.textContent = unknownCount
|
||||||
|
? `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN · NOT AN ALL-CLEAR`
|
||||||
|
: 'AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR';
|
||||||
|
}
|
||||||
|
if (this.contextUpdated) {
|
||||||
|
this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt)
|
||||||
|
? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z'
|
||||||
|
: '--:--:--Z';
|
||||||
|
}
|
||||||
|
this.context.dataset.state = unknownCount ? 'uncertain' : 'current';
|
||||||
|
this.updateCockpitSignals(snapshot, unknownCount);
|
||||||
|
if (this.contextLayoutStamp !== snapshot.evaluatedAt) {
|
||||||
|
this.contextLayoutStamp = snapshot.evaluatedAt;
|
||||||
|
this.scheduleContextLayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
+259
-94
@@ -1,25 +1,65 @@
|
|||||||
/** 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(
|
||||||
services,
|
viewer,
|
||||||
onVisionChange = null,
|
{
|
||||||
onCameraTakeover = null,
|
services,
|
||||||
isEntryAllowed = null,
|
onVisionChange = null,
|
||||||
onEntered = null,
|
onCameraTakeover = null,
|
||||||
onExited = null,
|
isEntryAllowed = null,
|
||||||
getInheritedVisionLabel = null,
|
onEntered = null,
|
||||||
restoreTrackingFrame = null,
|
onExited = null,
|
||||||
} = {}) {
|
getInheritedVisionLabel = 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 =
|
||||||
? getInheritedVisionLabel
|
typeof getInheritedVisionLabel === 'function'
|
||||||
: () => 'NORMAL';
|
? getInheritedVisionLabel
|
||||||
this.restoreTrackingFrame = typeof restoreTrackingFrame === 'function'
|
: () => 'NORMAL';
|
||||||
? restoreTrackingFrame
|
this.restoreTrackingFrame =
|
||||||
: () => false;
|
typeof restoreTrackingFrame === 'function'
|
||||||
this.isEntryAllowed = typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true;
|
? restoreTrackingFrame
|
||||||
|
: () => false;
|
||||||
|
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(
|
||||||
detail: { enabled },
|
new CustomEvent('gev:cockpit-weather-toggle', {
|
||||||
}));
|
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?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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',
|
||||||
flightsLayer: { getTrackedInfo: () => civilian },
|
callsign: 'CIVIL',
|
||||||
militaryFlightsLayer: { getTrackedInfo: () => military },
|
};
|
||||||
} });
|
const military = {
|
||||||
|
icao24: 'def456',
|
||||||
|
layerId: 'military',
|
||||||
|
callsign: 'MILITARY',
|
||||||
|
};
|
||||||
|
const owner = new CockpitViewController(env.viewer, {
|
||||||
|
services: {
|
||||||
|
flightsLayer: { getTrackedInfo: () => civilian },
|
||||||
|
militaryFlightsLayer: { getTrackedInfo: () => military },
|
||||||
|
},
|
||||||
|
});
|
||||||
env.viewer.trackedEntity = { gevTrackedId: 'military:def456' };
|
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, {
|
||||||
regionalDistanceM: () => Infinity,
|
services: {
|
||||||
fetchRegionalBrief: (_lat, _lon, options) => {
|
regionalDistanceM: () => Infinity,
|
||||||
signal = options.signal;
|
fetchRegionalBrief: (_lat, _lon, options) => {
|
||||||
return new Promise(resolve => { finish = resolve; });
|
signal = options.signal;
|
||||||
|
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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -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(
|
||||||
if (!this.active) this.standardScrollTop = standardPanel.scrollTop;
|
'scroll',
|
||||||
}, options);
|
() => {
|
||||||
cockpitPanel?.addEventListener('scroll', () => {
|
if (!this.active) this.standardScrollTop = standardPanel.scrollTop;
|
||||||
if (this.active) this.cockpitScrollTop = cockpitPanel.scrollTop;
|
},
|
||||||
}, options);
|
options,
|
||||||
window.addEventListener('gev:cockpit-mode-changed', event => {
|
);
|
||||||
this.setActive(event?.detail?.active === true);
|
cockpitPanel?.addEventListener(
|
||||||
}, { signal: this.listeners.signal });
|
'scroll',
|
||||||
|
() => {
|
||||||
|
if (this.active) this.cockpitScrollTop = cockpitPanel.scrollTop;
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
window.addEventListener(
|
||||||
|
'gev:cockpit-mode-changed',
|
||||||
|
(event) => {
|
||||||
|
this.setActive(event?.detail?.active === true);
|
||||||
|
},
|
||||||
|
{ signal: this.listeners.signal },
|
||||||
|
);
|
||||||
this.setActive(document.body.classList.contains('cockpit-mode'));
|
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 });
|
||||||
|
|||||||
@@ -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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
focus() {
|
||||||
|
focusCalls += 1;
|
||||||
|
document.activeElement = this;
|
||||||
}
|
}
|
||||||
contains(node) { return this === node || this.children.some(child => child.contains(node)); }
|
|
||||||
focus() { focusCalls += 1; document.activeElement = this; }
|
|
||||||
}
|
}
|
||||||
const standard = new Node('standard'); standard.scrollTop = 71;
|
const standard = new Node('standard');
|
||||||
const cockpit = new Node('cockpit'); cockpit.scrollTop = 19;
|
standard.scrollTop = 71;
|
||||||
const group = new Node('group'); const sibling = new Node('sibling');
|
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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
+58
-42
@@ -1,54 +1,70 @@
|
|||||||
/** 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;
|
||||||
if (event.key === 'Escape' && this.active) {
|
if (event.key === 'Escape' && this.active) {
|
||||||
// The credit lightbox owns Escape while its Close control or links hold
|
// The credit lightbox owns Escape while its Close control or links hold
|
||||||
// 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
|
||||||
if (this.context?.contains(event.target) && !this.contextCollapsed) {
|
.getElementById('context-radio-dock')
|
||||||
event.preventDefault();
|
?.classList.contains('disclosure-open')
|
||||||
event.stopImmediatePropagation();
|
)
|
||||||
this.setContextCollapsed(true);
|
return;
|
||||||
if (event.target === this.contextToggle || this.contextToggle?.contains?.(event.target)) {
|
if (
|
||||||
this.contextToggle?.blur?.();
|
document.querySelector('#cockpit-utility-controls [aria-expanded="true"]')
|
||||||
} else {
|
)
|
||||||
this.contextToggle?.focus({ preventScroll: true });
|
return;
|
||||||
}
|
if (this.context?.contains(event.target) && !this.contextCollapsed) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.signalStream?.contains(event.target) && !this.signalCollapsed) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
this.setSignalCollapsed(true, { user: true });
|
|
||||||
if (event.target === this.signalToggle || this.signalToggle?.contains?.(event.target)) {
|
|
||||||
this.signalToggle?.blur?.();
|
|
||||||
} else {
|
|
||||||
this.signalToggle?.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
this.exit();
|
this.setContextCollapsed(true);
|
||||||
|
if (
|
||||||
|
event.target === this.contextToggle ||
|
||||||
|
this.contextToggle?.contains?.(event.target)
|
||||||
|
) {
|
||||||
|
this.contextToggle?.blur?.();
|
||||||
|
} else {
|
||||||
|
this.contextToggle?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.target?.closest?.('input, textarea, select, [contenteditable]')) return;
|
if (this.signalStream?.contains(event.target) && !this.signalCollapsed) {
|
||||||
const key = event.key?.toLowerCase();
|
|
||||||
if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
|
||||||
if (!this.active) {
|
|
||||||
const cockpitAttempt = !!(this.readAircraftInfo() && this.viewer.trackedEntity?.position);
|
|
||||||
if (!cockpitAttempt) return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
if (!this.active && !this.isEntryAllowed()) return;
|
this.setSignalCollapsed(true, { user: true });
|
||||||
const changed = this.active ? this.exit() : this.enter();
|
if (
|
||||||
|
event.target === this.signalToggle ||
|
||||||
|
this.signalToggle?.contains?.(event.target)
|
||||||
|
) {
|
||||||
|
this.signalToggle?.blur?.();
|
||||||
|
} else {
|
||||||
|
this.signalToggle?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
this.exit();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (event.target?.closest?.('input, textarea, select, [contenteditable]'))
|
||||||
|
return;
|
||||||
|
const key = event.key?.toLowerCase();
|
||||||
|
if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
||||||
|
if (!this.active) {
|
||||||
|
const cockpitAttempt = !!(
|
||||||
|
this.readAircraftInfo() && this.viewer.trackedEntity?.position
|
||||||
|
);
|
||||||
|
if (!cockpitAttempt) return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
if (!this.active && !this.isEntryAllowed()) return;
|
||||||
|
const changed = this.active ? this.exit() : this.enter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
+242
-164
@@ -1,182 +1,260 @@
|
|||||||
/** 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(
|
||||||
this.lastAircraftInfo = info;
|
info,
|
||||||
const heading = normalizeHeading(this.heading ?? info.track ?? 0);
|
nowMs = performance.now(),
|
||||||
if (this.callsign) {
|
forceContext = false,
|
||||||
this.callsign.textContent = info.callsign || info.registration || info.icao24 || 'AIRCRAFT';
|
) {
|
||||||
}
|
this.lastAircraftInfo = info;
|
||||||
const speedKt = Number.isFinite(info.velocityMps) ? info.velocityMps * 1.94384 : null;
|
const heading = normalizeHeading(this.heading ?? info.track ?? 0);
|
||||||
|
if (this.callsign) {
|
||||||
|
this.callsign.textContent =
|
||||||
|
info.callsign || info.registration || info.icao24 || 'AIRCRAFT';
|
||||||
|
}
|
||||||
|
const speedKt = Number.isFinite(info.velocityMps)
|
||||||
|
? info.velocityMps * 1.94384
|
||||||
|
: null;
|
||||||
|
setCockpitRollingValue(this.speed, formatSpeedRulerTick(speedKt), speedKt, {
|
||||||
|
immediate: forceContext,
|
||||||
|
});
|
||||||
|
if (this.speedRim)
|
||||||
|
this.speedRim.classList.toggle('unavailable', speedKt === null);
|
||||||
|
if (this.speedRimValue)
|
||||||
|
this.speedRimValue.textContent = formatSpeedRulerTick(speedKt);
|
||||||
|
const speedTicks = speedRulerTicks(speedKt, this.speedRimTicks.length);
|
||||||
|
this.speedRimTicks.forEach((element, index) => {
|
||||||
|
const tick = speedTicks[index];
|
||||||
|
element.hidden = !tick;
|
||||||
|
if (!tick) return;
|
||||||
|
element.style.setProperty('--slot', tick.slot.toFixed(4));
|
||||||
|
element.style.setProperty('--depth', tick.depth.toFixed(4));
|
||||||
|
element.style.setProperty(
|
||||||
|
'--curve',
|
||||||
|
altitudeRulerCurveInset(tick.slot).toFixed(5),
|
||||||
|
);
|
||||||
|
element.classList.toggle('major', tick.major);
|
||||||
|
const label = element.querySelector('b');
|
||||||
|
if (label) label.textContent = formatSpeedRulerTick(tick.valueKt);
|
||||||
|
});
|
||||||
|
const altitudeFt = cockpitAltitudeDisplayFt(info.altitudeM, info.onGround);
|
||||||
|
if (this.altitude) {
|
||||||
|
const displayedAltitudeFt = Number.isFinite(altitudeFt)
|
||||||
|
? Math.round(altitudeFt)
|
||||||
|
: null;
|
||||||
setCockpitRollingValue(
|
setCockpitRollingValue(
|
||||||
this.speed,
|
this.altitude,
|
||||||
formatSpeedRulerTick(speedKt),
|
displayedAltitudeFt !== null
|
||||||
speedKt,
|
? displayedAltitudeFt.toLocaleString('en-US')
|
||||||
|
: '-----',
|
||||||
|
displayedAltitudeFt,
|
||||||
{ immediate: forceContext },
|
{ immediate: forceContext },
|
||||||
);
|
);
|
||||||
if (this.speedRim) this.speedRim.classList.toggle('unavailable', speedKt === null);
|
|
||||||
if (this.speedRimValue) this.speedRimValue.textContent = formatSpeedRulerTick(speedKt);
|
|
||||||
const speedTicks = speedRulerTicks(speedKt, this.speedRimTicks.length);
|
|
||||||
this.speedRimTicks.forEach((element, index) => {
|
|
||||||
const tick = speedTicks[index];
|
|
||||||
element.hidden = !tick;
|
|
||||||
if (!tick) return;
|
|
||||||
element.style.setProperty('--slot', tick.slot.toFixed(4));
|
|
||||||
element.style.setProperty('--depth', tick.depth.toFixed(4));
|
|
||||||
element.style.setProperty('--curve', altitudeRulerCurveInset(tick.slot).toFixed(5));
|
|
||||||
element.classList.toggle('major', tick.major);
|
|
||||||
const label = element.querySelector('b');
|
|
||||||
if (label) label.textContent = formatSpeedRulerTick(tick.valueKt);
|
|
||||||
});
|
|
||||||
const altitudeFt = cockpitAltitudeDisplayFt(info.altitudeM, info.onGround);
|
|
||||||
if (this.altitude) {
|
|
||||||
const displayedAltitudeFt = Number.isFinite(altitudeFt)
|
|
||||||
? Math.round(altitudeFt)
|
|
||||||
: null;
|
|
||||||
setCockpitRollingValue(
|
|
||||||
this.altitude,
|
|
||||||
displayedAltitudeFt !== null
|
|
||||||
? displayedAltitudeFt.toLocaleString('en-US')
|
|
||||||
: '-----',
|
|
||||||
displayedAltitudeFt,
|
|
||||||
{ immediate: forceContext },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (this.altitudeRim) this.altitudeRim.classList.toggle('unavailable', altitudeFt === null);
|
|
||||||
if (this.altitudeRimValue) {
|
|
||||||
this.altitudeRimValue.textContent = formatAltitudeRulerTick(altitudeFt);
|
|
||||||
}
|
|
||||||
const altitudeTicks = altitudeRulerTicks(altitudeFt, this.altitudeRimTicks.length);
|
|
||||||
this.altitudeRimTicks.forEach((element, index) => {
|
|
||||||
const tick = altitudeTicks[index];
|
|
||||||
element.hidden = !tick;
|
|
||||||
if (!tick) return;
|
|
||||||
element.style.setProperty('--slot', tick.slot.toFixed(4));
|
|
||||||
element.style.setProperty('--depth', tick.depth.toFixed(4));
|
|
||||||
element.style.setProperty('--curve', altitudeRulerCurveInset(tick.slot).toFixed(5));
|
|
||||||
element.classList.toggle('major', tick.major);
|
|
||||||
const label = element.querySelector('b');
|
|
||||||
if (label) label.textContent = formatAltitudeRulerTick(tick.valueFt);
|
|
||||||
});
|
|
||||||
setCockpitRollingValue(
|
|
||||||
this.headingValue,
|
|
||||||
String(Math.round(heading) % 360).padStart(3, '0'),
|
|
||||||
heading,
|
|
||||||
{ circularRange: 360, immediate: forceContext },
|
|
||||||
);
|
|
||||||
if (this.compassTape) {
|
|
||||||
const divisions = compassDivisions(heading);
|
|
||||||
const signature = divisions.join(',');
|
|
||||||
if (signature !== this.lastCompassSignature) {
|
|
||||||
this.lastCompassSignature = signature;
|
|
||||||
this.compassTape.innerHTML = divisions
|
|
||||||
.map((division, index) => {
|
|
||||||
const slot = index - 3;
|
|
||||||
return `<span class="${slot === 0 ? 'active' : ''}" style="--slot:${slot};--depth:${Math.abs(slot)}">${formatCompassDivision(division)}</span>`;
|
|
||||||
})
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.clock) this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z';
|
|
||||||
if (this.position) {
|
|
||||||
const lat = Number.isFinite(info.latitude)
|
|
||||||
? `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}` : '--';
|
|
||||||
const lon = Number.isFinite(info.longitude)
|
|
||||||
? `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}` : '--';
|
|
||||||
this.position.textContent = `${lat} · ${lon}`;
|
|
||||||
}
|
|
||||||
if (this.aircraftMeta) {
|
|
||||||
const feedState = this.surfaceAcquiring
|
|
||||||
? 'ACQUIRING SURFACE'
|
|
||||||
: (this.surfaceFallback ? 'SURFACE FALLBACK' : (info.stale ? 'STALE FEED' : 'LIVE TRACK'));
|
|
||||||
this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`;
|
|
||||||
}
|
|
||||||
this.updateRoute(info);
|
|
||||||
if (forceContext
|
|
||||||
|| cockpitUiUpdateDue(nowMs, this.lastContextUpdateMs, COCKPIT_CONTEXT_UPDATE_MS)) {
|
|
||||||
this.lastContextUpdateMs = nowMs;
|
|
||||||
this.updateLocalPosition(info);
|
|
||||||
this.maybeRefreshRegionalBrief(info);
|
|
||||||
this.updateContext(info, heading);
|
|
||||||
}
|
|
||||||
if (this.hud) this.hud.dataset.layer = info.layerId || 'flights';
|
|
||||||
}
|
}
|
||||||
|
if (this.altitudeRim)
|
||||||
|
this.altitudeRim.classList.toggle('unavailable', altitudeFt === null);
|
||||||
|
if (this.altitudeRimValue) {
|
||||||
|
this.altitudeRimValue.textContent = formatAltitudeRulerTick(altitudeFt);
|
||||||
|
}
|
||||||
|
const altitudeTicks = altitudeRulerTicks(
|
||||||
|
altitudeFt,
|
||||||
|
this.altitudeRimTicks.length,
|
||||||
|
);
|
||||||
|
this.altitudeRimTicks.forEach((element, index) => {
|
||||||
|
const tick = altitudeTicks[index];
|
||||||
|
element.hidden = !tick;
|
||||||
|
if (!tick) return;
|
||||||
|
element.style.setProperty('--slot', tick.slot.toFixed(4));
|
||||||
|
element.style.setProperty('--depth', tick.depth.toFixed(4));
|
||||||
|
element.style.setProperty(
|
||||||
|
'--curve',
|
||||||
|
altitudeRulerCurveInset(tick.slot).toFixed(5),
|
||||||
|
);
|
||||||
|
element.classList.toggle('major', tick.major);
|
||||||
|
const label = element.querySelector('b');
|
||||||
|
if (label) label.textContent = formatAltitudeRulerTick(tick.valueFt);
|
||||||
|
});
|
||||||
|
setCockpitRollingValue(
|
||||||
|
this.headingValue,
|
||||||
|
String(Math.round(heading) % 360).padStart(3, '0'),
|
||||||
|
heading,
|
||||||
|
{ circularRange: 360, immediate: forceContext },
|
||||||
|
);
|
||||||
|
if (this.compassTape) {
|
||||||
|
const divisions = compassDivisions(heading);
|
||||||
|
const signature = divisions.join(',');
|
||||||
|
if (signature !== this.lastCompassSignature) {
|
||||||
|
this.lastCompassSignature = signature;
|
||||||
|
this.compassTape.innerHTML = divisions
|
||||||
|
.map((division, index) => {
|
||||||
|
const slot = index - 3;
|
||||||
|
return `<span class="${slot === 0 ? 'active' : ''}" style="--slot:${slot};--depth:${Math.abs(slot)}">${formatCompassDivision(division)}</span>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.clock)
|
||||||
|
this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z';
|
||||||
|
if (this.position) {
|
||||||
|
const lat = Number.isFinite(info.latitude)
|
||||||
|
? `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`
|
||||||
|
: '--';
|
||||||
|
const lon = Number.isFinite(info.longitude)
|
||||||
|
? `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`
|
||||||
|
: '--';
|
||||||
|
this.position.textContent = `${lat} · ${lon}`;
|
||||||
|
}
|
||||||
|
if (this.aircraftMeta) {
|
||||||
|
const feedState = this.surfaceAcquiring
|
||||||
|
? 'ACQUIRING SURFACE'
|
||||||
|
: this.surfaceFallback
|
||||||
|
? 'SURFACE FALLBACK'
|
||||||
|
: info.stale
|
||||||
|
? 'STALE FEED'
|
||||||
|
: 'LIVE TRACK';
|
||||||
|
this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`;
|
||||||
|
}
|
||||||
|
this.updateRoute(info);
|
||||||
|
if (
|
||||||
|
forceContext ||
|
||||||
|
cockpitUiUpdateDue(
|
||||||
|
nowMs,
|
||||||
|
this.lastContextUpdateMs,
|
||||||
|
COCKPIT_CONTEXT_UPDATE_MS,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.lastContextUpdateMs = nowMs;
|
||||||
|
this.updateLocalPosition(info);
|
||||||
|
this.maybeRefreshRegionalBrief(info);
|
||||||
|
this.updateContext(info, heading);
|
||||||
|
}
|
||||||
|
if (this.hud) this.hud.dataset.layer = info.layerId || 'flights';
|
||||||
|
}
|
||||||
|
|
||||||
export function updateRoute(info) {
|
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);
|
||||||
if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin);
|
const routeLabel = (airport) =>
|
||||||
if (this.routeTo) this.routeTo.textContent = routeLabel(destination);
|
[airport?.code, airport?.name].filter(Boolean).join(' · ') || 'UNKNOWN';
|
||||||
if (this.routeStatus) {
|
if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin);
|
||||||
this.routeStatus.textContent = validDestination
|
if (this.routeTo) this.routeTo.textContent = routeLabel(destination);
|
||||||
? 'ARROW · ESTIMATED DIRECTION'
|
if (this.routeStatus) {
|
||||||
: 'ROUTE DATA UNAVAILABLE';
|
this.routeStatus.textContent = validDestination
|
||||||
}
|
? 'ARROW · ESTIMATED DIRECTION'
|
||||||
if (this.route) this.route.hidden = !origin && !destination;
|
: 'ROUTE DATA UNAVAILABLE';
|
||||||
if (!validDestination || !Number.isFinite(info?.longitude) || !Number.isFinite(info?.latitude)) {
|
|
||||||
this.clearPredictiveRoute();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const destinationBearing = bearingBetweenCoordinates(
|
|
||||||
info.latitude,
|
|
||||||
info.longitude,
|
|
||||||
destination.lat,
|
|
||||||
destination.lon,
|
|
||||||
);
|
|
||||||
const relative = relativeBearing(destinationBearing, this.heading ?? info.track ?? 0);
|
|
||||||
if (!Number.isFinite(destinationBearing) || !Number.isFinite(relative)) {
|
|
||||||
this.clearPredictiveRoute();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.routeDirection) {
|
|
||||||
const displayedRelative = Math.max(-120, Math.min(120, relative));
|
|
||||||
this.routeDirection.hidden = false;
|
|
||||||
this.routeDirection.style.setProperty('--route-angle', `${displayedRelative.toFixed(2)}deg`);
|
|
||||||
}
|
|
||||||
if (this.routeDirectionLabel) {
|
|
||||||
this.routeDirectionLabel.textContent = `DEST ${String(Math.round(destinationBearing)).padStart(3, '0')}°`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (this.route) this.route.hidden = !origin && !destination;
|
||||||
|
if (
|
||||||
|
!validDestination ||
|
||||||
|
!Number.isFinite(info?.longitude) ||
|
||||||
|
!Number.isFinite(info?.latitude)
|
||||||
|
) {
|
||||||
|
this.clearPredictiveRoute();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const destinationBearing = bearingBetweenCoordinates(
|
||||||
|
info.latitude,
|
||||||
|
info.longitude,
|
||||||
|
destination.lat,
|
||||||
|
destination.lon,
|
||||||
|
);
|
||||||
|
const relative = relativeBearing(
|
||||||
|
destinationBearing,
|
||||||
|
this.heading ?? info.track ?? 0,
|
||||||
|
);
|
||||||
|
if (!Number.isFinite(destinationBearing) || !Number.isFinite(relative)) {
|
||||||
|
this.clearPredictiveRoute();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.routeDirection) {
|
||||||
|
const displayedRelative = Math.max(-120, Math.min(120, relative));
|
||||||
|
this.routeDirection.hidden = false;
|
||||||
|
this.routeDirection.style.setProperty(
|
||||||
|
'--route-angle',
|
||||||
|
`${displayedRelative.toFixed(2)}deg`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.routeDirectionLabel) {
|
||||||
|
this.routeDirectionLabel.textContent = `DEST ${String(Math.round(destinationBearing)).padStart(3, '0')}°`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function syncWeatherToggle(enabled) {
|
export function syncWeatherToggle(enabled) {
|
||||||
if (!this.weatherToggle) return;
|
if (!this.weatherToggle) return;
|
||||||
const active = !!enabled;
|
const active = !!enabled;
|
||||||
this.weatherToggle.setAttribute('aria-pressed', String(active));
|
this.weatherToggle.setAttribute('aria-pressed', String(active));
|
||||||
this.weatherToggle.setAttribute(
|
this.weatherToggle.setAttribute(
|
||||||
'aria-label',
|
'aria-label',
|
||||||
`${active ? 'Disable' : 'Enable'} cockpit weather effects`,
|
`${active ? 'Disable' : 'Enable'} cockpit weather effects`,
|
||||||
);
|
);
|
||||||
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();
|
||||||
if (this.visionCurrent) {
|
const labels = {
|
||||||
this.visionCurrent.dataset.cockpitVision = next;
|
optical: inherited,
|
||||||
this.visionCurrent.setAttribute('aria-label', `Current cockpit vision style: ${names[next]}. Activate for next style.`);
|
crt: 'CRT',
|
||||||
this.visionCurrent.title = `Current style: ${names[next]} — click for next`;
|
nvg: 'NVG',
|
||||||
}
|
thermal: 'FLIR',
|
||||||
if (this.visionCurrentLabel) this.visionCurrentLabel.textContent = labels[next];
|
noir: 'NOIR',
|
||||||
this.onVisionChange?.(next, this.active, { revealParameters });
|
};
|
||||||
|
const names = {
|
||||||
|
optical: inherited,
|
||||||
|
crt: 'CRT',
|
||||||
|
nvg: 'Night vision',
|
||||||
|
thermal: 'Thermal',
|
||||||
|
noir: 'Noir',
|
||||||
|
};
|
||||||
|
if (this.visionCurrent) {
|
||||||
|
this.visionCurrent.dataset.cockpitVision = next;
|
||||||
|
this.visionCurrent.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
`Current cockpit vision style: ${names[next]}. Activate for next style.`,
|
||||||
|
);
|
||||||
|
this.visionCurrent.title = `Current style: ${names[next]} — click for next`;
|
||||||
}
|
}
|
||||||
|
if (this.visionCurrentLabel)
|
||||||
|
this.visionCurrentLabel.textContent = labels[next];
|
||||||
|
this.onVisionChange?.(next, this.active, { revealParameters });
|
||||||
|
}
|
||||||
|
|
||||||
export function cycleVisionMode(direction = 1) {
|
export function cycleVisionMode(direction = 1) {
|
||||||
const modes = COCKPIT_VISION_MODES;
|
const modes = COCKPIT_VISION_MODES;
|
||||||
const currentIndex = Math.max(0, modes.indexOf(this.visionMode));
|
const currentIndex = Math.max(0, modes.indexOf(this.visionMode));
|
||||||
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;
|
||||||
}
|
}
|
||||||
+184
-124
@@ -1,152 +1,212 @@
|
|||||||
/** 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)
|
||||||
this.contextLayoutFrame = requestAnimationFrame(() => {
|
return;
|
||||||
this.contextLayoutFrame = null;
|
this.contextLayoutFrame = requestAnimationFrame(() => {
|
||||||
this.syncContextLayout();
|
this.contextLayoutFrame = null;
|
||||||
this.syncSignalLayout();
|
this.syncContextLayout();
|
||||||
});
|
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)
|
||||||
if (this.contextToggle) {
|
this.context.dataset.collapsed = String(this.contextCollapsed);
|
||||||
const expanded = !this.contextCollapsed;
|
if (this.contextToggle) {
|
||||||
this.contextToggle.setAttribute('aria-expanded', String(expanded));
|
const expanded = !this.contextCollapsed;
|
||||||
this.contextToggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} Contact panel`);
|
this.contextToggle.setAttribute('aria-expanded', String(expanded));
|
||||||
this.contextToggle.title = `${expanded ? 'Collapse' : 'Expand'} contact panel`;
|
this.contextToggle.setAttribute(
|
||||||
const icon = this.contextToggle.querySelector('.material-symbols-outlined');
|
'aria-label',
|
||||||
if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right';
|
`${expanded ? 'Collapse' : 'Expand'} Contact panel`,
|
||||||
}
|
);
|
||||||
if (this.active && wasCollapsed && !this.contextCollapsed) {
|
this.contextToggle.title = `${expanded ? 'Collapse' : 'Expand'} contact panel`;
|
||||||
window.dispatchEvent(new CustomEvent('gev:cockpit-context-expanded'));
|
const icon = this.contextToggle.querySelector('.material-symbols-outlined');
|
||||||
}
|
if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right';
|
||||||
this.scheduleContextLayout();
|
|
||||||
}
|
}
|
||||||
|
if (this.active && wasCollapsed && !this.contextCollapsed) {
|
||||||
|
window.dispatchEvent(new CustomEvent('gev:cockpit-context-expanded'));
|
||||||
|
}
|
||||||
|
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)
|
||||||
if (this.signalToggle) {
|
this.signalStream.dataset.collapsed = String(this.signalCollapsed);
|
||||||
const expanded = !this.signalCollapsed;
|
if (this.signalToggle) {
|
||||||
this.signalToggle.setAttribute('aria-expanded', String(expanded));
|
const expanded = !this.signalCollapsed;
|
||||||
this.signalToggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} cockpit briefing panel`);
|
this.signalToggle.setAttribute('aria-expanded', String(expanded));
|
||||||
this.signalToggle.title = `${expanded ? 'Collapse' : 'Expand'} briefing panel`;
|
this.signalToggle.setAttribute(
|
||||||
const icon = this.signalToggle.querySelector('.material-symbols-outlined');
|
'aria-label',
|
||||||
if (icon) icon.textContent = expanded ? 'right_panel_close' : 'right_panel_open';
|
`${expanded ? 'Collapse' : 'Expand'} cockpit briefing panel`,
|
||||||
}
|
);
|
||||||
if (this.signalCollapsed) this.stopBriefRotation();
|
this.signalToggle.title = `${expanded ? 'Collapse' : 'Expand'} briefing panel`;
|
||||||
else this.startBriefRotation({ reset: true });
|
const icon = this.signalToggle.querySelector('.material-symbols-outlined');
|
||||||
if (this.active && wasCollapsed && !this.signalCollapsed) {
|
if (icon)
|
||||||
window.dispatchEvent(new CustomEvent('gev:cockpit-signal-expanded'));
|
icon.textContent = expanded ? 'right_panel_close' : 'right_panel_open';
|
||||||
}
|
|
||||||
this.scheduleContextLayout();
|
|
||||||
}
|
}
|
||||||
|
if (this.signalCollapsed) this.stopBriefRotation();
|
||||||
|
else this.startBriefRotation({ reset: true });
|
||||||
|
if (this.active && wasCollapsed && !this.signalCollapsed) {
|
||||||
|
window.dispatchEvent(new CustomEvent('gev:cockpit-signal-expanded'));
|
||||||
|
}
|
||||||
|
this.scheduleContextLayout();
|
||||||
|
}
|
||||||
|
|
||||||
export function syncContextLayout() {
|
export function syncContextLayout() {
|
||||||
if (!this.context || this.context.hidden) return;
|
if (!this.context || this.context.hidden) return;
|
||||||
if (window.matchMedia('(max-width: 760px)').matches) {
|
if (window.matchMedia('(max-width: 760px)').matches) {
|
||||||
this.context.dataset.layoutMode = 'compact-bottom';
|
this.context.dataset.layoutMode = 'compact-bottom';
|
||||||
this.context.style.removeProperty('--cockpit-context-left');
|
this.context.style.removeProperty('--cockpit-context-left');
|
||||||
this.context.style.removeProperty('--cockpit-context-top');
|
|
||||||
this.context.style.removeProperty('--cockpit-context-max-height');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04));
|
|
||||||
this.context.dataset.layoutMode = 'bottom-left';
|
|
||||||
this.context.style.setProperty('--cockpit-context-left', `${desktopInset.toFixed(1)}px`);
|
|
||||||
this.context.style.removeProperty('--cockpit-context-top');
|
this.context.style.removeProperty('--cockpit-context-top');
|
||||||
this.context.style.removeProperty('--cockpit-context-max-height');
|
this.context.style.removeProperty('--cockpit-context-max-height');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04));
|
||||||
|
this.context.dataset.layoutMode = 'bottom-left';
|
||||||
|
this.context.style.setProperty(
|
||||||
|
'--cockpit-context-left',
|
||||||
|
`${desktopInset.toFixed(1)}px`,
|
||||||
|
);
|
||||||
|
this.context.style.removeProperty('--cockpit-context-top');
|
||||||
|
this.context.style.removeProperty('--cockpit-context-max-height');
|
||||||
|
}
|
||||||
|
|
||||||
export function syncSignalLayout() {
|
export function syncSignalLayout() {
|
||||||
if (!this.signalStream || this.signalStream.hidden) return;
|
if (!this.signalStream || this.signalStream.hidden) return;
|
||||||
const utilityControls = document.getElementById('cockpit-utility-controls');
|
const utilityControls = document.getElementById('cockpit-utility-controls');
|
||||||
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));
|
||||||
});
|
});
|
||||||
this.hud?.style.removeProperty('--cockpit-utility-top');
|
this.hud?.style.removeProperty('--cockpit-utility-top');
|
||||||
this.hud?.style.removeProperty('--cockpit-utility-max-height');
|
this.hud?.style.removeProperty('--cockpit-utility-max-height');
|
||||||
this.hud?.style.removeProperty('--cockpit-utility-expanded-max-height');
|
this.hud?.style.removeProperty('--cockpit-utility-expanded-max-height');
|
||||||
this.signalStream.style.removeProperty('--cockpit-signal-right');
|
this.signalStream.style.removeProperty('--cockpit-signal-right');
|
||||||
this.signalStream.style.removeProperty('--cockpit-signal-top');
|
|
||||||
this.signalStream.style.removeProperty('--cockpit-signal-max-height');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04));
|
|
||||||
this.signalStream.dataset.layoutMode = 'bottom-right';
|
|
||||||
this.signalStream.style.setProperty('--cockpit-signal-right', `${desktopInset.toFixed(1)}px`);
|
|
||||||
this.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();
|
return;
|
||||||
const utilityBounds = utilityControls?.getBoundingClientRect();
|
}
|
||||||
if (utilityBounds) {
|
|
||||||
const expandedControl = utilityControls?.querySelector('.cockpit-utility-control.is-expanded');
|
const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04));
|
||||||
const collapsedControl = utilityControls?.querySelector(
|
this.signalStream.dataset.layoutMode = 'bottom-right';
|
||||||
'.cockpit-utility-control:not(.is-expanded)',
|
this.signalStream.style.setProperty(
|
||||||
);
|
'--cockpit-signal-right',
|
||||||
const collapsedLauncher = collapsedControl?.querySelector('.cockpit-utility-launcher');
|
`${desktopInset.toFixed(1)}px`,
|
||||||
const expandedHeight = expandedControl
|
);
|
||||||
? Math.max(expandedControl.scrollHeight, expandedControl.getBoundingClientRect().height)
|
this.signalStream.style.removeProperty('--cockpit-signal-top');
|
||||||
: 0;
|
this.signalStream.style.removeProperty('--cockpit-signal-max-height');
|
||||||
const collapsedHeight = collapsedLauncher
|
const signalBounds = this.signalStream.getBoundingClientRect();
|
||||||
? Math.max(COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX, collapsedLauncher.scrollHeight)
|
const utilityBounds = utilityControls?.getBoundingClientRect();
|
||||||
: 0;
|
if (utilityBounds) {
|
||||||
// Cockpit owns this anchor outright. The strip used to inherit the left
|
const expandedControl = utilityControls?.querySelector(
|
||||||
// accordion's committed top, which is solved against left-lane obstacles
|
'.cockpit-utility-control.is-expanded',
|
||||||
// and dropped the strip straight through the briefing card below it.
|
);
|
||||||
// The readout only anchors the strip while it is genuinely on screen:
|
const collapsedControl = utilityControls?.querySelector(
|
||||||
// the Minimal variant drops it with `display:none`, but HUD Off hides the
|
'.cockpit-utility-control:not(.is-expanded)',
|
||||||
// whole Intel HUD with `visibility`/`opacity`, which keeps its rect.
|
);
|
||||||
const recReadout = document.querySelector('#intel-hud .hud-top-right');
|
const collapsedLauncher = collapsedControl?.querySelector(
|
||||||
const recBounds = isRenderedOnScreen(recReadout) ? recReadout.getBoundingClientRect() : null;
|
'.cockpit-utility-launcher',
|
||||||
const utilityAnchor = resolveCockpitUtilityAnchor({
|
);
|
||||||
recBottom: recBounds ? recBounds.bottom : 0,
|
const expandedHeight = expandedControl
|
||||||
signalTop: signalBounds.top,
|
? Math.max(
|
||||||
stripHeight: utilityBounds.height,
|
expandedControl.scrollHeight,
|
||||||
viewportHeight: window.innerHeight,
|
expandedControl.getBoundingClientRect().height,
|
||||||
collapsedHeight,
|
)
|
||||||
recGap: COCKPIT_UTILITY_REC_GAP_PX,
|
: 0;
|
||||||
signalGap: COCKPIT_UTILITY_SIGNAL_GAP_PX,
|
const collapsedHeight = collapsedLauncher
|
||||||
minTopFloor: COCKPIT_UTILITY_MIN_TOP_PX,
|
? Math.max(
|
||||||
minTopRatio: COCKPIT_UTILITY_MIN_TOP_RATIO,
|
COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX,
|
||||||
});
|
collapsedLauncher.scrollHeight,
|
||||||
const availableHeight = utilityAnchor.maxHeight;
|
)
|
||||||
this.hud?.style.setProperty('--cockpit-utility-top', `${utilityAnchor.top.toFixed(1)}px`);
|
: 0;
|
||||||
this.hud?.style.setProperty('--cockpit-utility-max-height', `${availableHeight.toFixed(2)}px`);
|
// Cockpit owns this anchor outright. The strip used to inherit the left
|
||||||
const utilityLayout = expandedControl && collapsedControl
|
// accordion's committed top, which is solved against left-lane obstacles
|
||||||
? resolveCockpitUtilityLayout({ availableHeight, expandedHeight, collapsedHeight })
|
// and dropped the strip straight through the briefing card below it.
|
||||||
|
// The readout only anchors the strip while it is genuinely on screen:
|
||||||
|
// the Minimal variant drops it with `display:none`, but HUD Off hides the
|
||||||
|
// whole Intel HUD with `visibility`/`opacity`, which keeps its rect.
|
||||||
|
const recReadout = document.querySelector('#intel-hud .hud-top-right');
|
||||||
|
const recBounds = isRenderedOnScreen(recReadout)
|
||||||
|
? recReadout.getBoundingClientRect()
|
||||||
|
: null;
|
||||||
|
const utilityAnchor = resolveCockpitUtilityAnchor({
|
||||||
|
recBottom: recBounds ? recBounds.bottom : 0,
|
||||||
|
signalTop: signalBounds.top,
|
||||||
|
stripHeight: utilityBounds.height,
|
||||||
|
viewportHeight: window.innerHeight,
|
||||||
|
collapsedHeight,
|
||||||
|
recGap: COCKPIT_UTILITY_REC_GAP_PX,
|
||||||
|
signalGap: COCKPIT_UTILITY_SIGNAL_GAP_PX,
|
||||||
|
minTopFloor: COCKPIT_UTILITY_MIN_TOP_PX,
|
||||||
|
minTopRatio: COCKPIT_UTILITY_MIN_TOP_RATIO,
|
||||||
|
});
|
||||||
|
const availableHeight = utilityAnchor.maxHeight;
|
||||||
|
this.hud?.style.setProperty(
|
||||||
|
'--cockpit-utility-top',
|
||||||
|
`${utilityAnchor.top.toFixed(1)}px`,
|
||||||
|
);
|
||||||
|
this.hud?.style.setProperty(
|
||||||
|
'--cockpit-utility-max-height',
|
||||||
|
`${availableHeight.toFixed(2)}px`,
|
||||||
|
);
|
||||||
|
const utilityLayout =
|
||||||
|
expandedControl && collapsedControl
|
||||||
|
? resolveCockpitUtilityLayout({
|
||||||
|
availableHeight,
|
||||||
|
expandedHeight,
|
||||||
|
collapsedHeight,
|
||||||
|
})
|
||||||
: { primaryOnly: false, expandedMaxHeight: availableHeight };
|
: { primaryOnly: false, expandedMaxHeight: availableHeight };
|
||||||
utilityControls?.classList.toggle('layout-primary-only', utilityLayout.primaryOnly);
|
utilityControls?.classList.toggle(
|
||||||
this.hud?.style.setProperty(
|
'layout-primary-only',
|
||||||
'--cockpit-utility-expanded-max-height',
|
utilityLayout.primaryOnly,
|
||||||
`${utilityLayout.expandedMaxHeight.toFixed(2)}px`,
|
);
|
||||||
);
|
this.hud?.style.setProperty(
|
||||||
utilityControls?.querySelectorAll('.cockpit-utility-control').forEach((control) => {
|
'--cockpit-utility-expanded-max-height',
|
||||||
const hiddenSibling = utilityLayout.primaryOnly && control === collapsedControl;
|
`${utilityLayout.expandedMaxHeight.toFixed(2)}px`,
|
||||||
|
);
|
||||||
|
utilityControls
|
||||||
|
?.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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
@@ -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,45 +94,56 @@ 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 ||
|
||||||
const token = document.createElement('span');
|
!element.querySelector('.cockpit-roll-token')
|
||||||
token.className = 'cockpit-roll-token';
|
) {
|
||||||
token.setAttribute('aria-hidden', 'true');
|
element.replaceChildren(
|
||||||
token.textContent = character;
|
...Array.from(nextText, (character) => {
|
||||||
return token;
|
const token = document.createElement('span');
|
||||||
}));
|
token.className = 'cockpit-roll-token';
|
||||||
|
token.setAttribute('aria-hidden', 'true');
|
||||||
|
token.textContent = character;
|
||||||
|
return token;
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let delta = Number.isFinite(numericValue) && Number.isFinite(previousValue)
|
let delta =
|
||||||
? numericValue - previousValue
|
Number.isFinite(numericValue) && Number.isFinite(previousValue)
|
||||||
: 0;
|
? numericValue - previousValue
|
||||||
|
: 0;
|
||||||
if (Number.isFinite(circularRange) && circularRange > 0) {
|
if (Number.isFinite(circularRange) && circularRange > 0) {
|
||||||
const halfRange = circularRange / 2;
|
const halfRange = circularRange / 2;
|
||||||
if (delta > halfRange) delta -= circularRange;
|
if (delta > halfRange) delta -= circularRange;
|
||||||
@@ -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;
|
||||||
|
|||||||
+185
-161
@@ -1,174 +1,198 @@
|
|||||||
/** 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) =>
|
||||||
const entriesByKey = new Map();
|
entry.contains(document.activeElement),
|
||||||
for (const entry of existing) {
|
);
|
||||||
const key = entry.dataset.signalKey;
|
const entriesByKey = new Map();
|
||||||
if (!entriesByKey.has(key)) entriesByKey.set(key, []);
|
for (const entry of existing) {
|
||||||
entriesByKey.get(key).push(entry);
|
const key = entry.dataset.signalKey;
|
||||||
}
|
if (!entriesByKey.has(key)) entriesByKey.set(key, []);
|
||||||
const setText = (element, value) => {
|
entriesByKey.get(key).push(entry);
|
||||||
if (element.textContent !== value) element.textContent = value;
|
|
||||||
};
|
|
||||||
const entries = this.signalItems.map((item) => {
|
|
||||||
// Identity includes the action target: a reused status key must never
|
|
||||||
// silently turn a focused flight button into a different selection.
|
|
||||||
const key = JSON.stringify([
|
|
||||||
item.key, Boolean(item.target), item.target?.layerId || '', String(item.target?.id ?? ''),
|
|
||||||
]);
|
|
||||||
let entry = entriesByKey.get(key)?.shift();
|
|
||||||
if (!entry) {
|
|
||||||
entry = document.createElement('li');
|
|
||||||
entry.dataset.signalKey = key;
|
|
||||||
const time = document.createElement('time');
|
|
||||||
const body = document.createElement('div');
|
|
||||||
const heading = document.createElement(item.target ? 'button' : 'strong');
|
|
||||||
if (item.target) {
|
|
||||||
heading.type = 'button';
|
|
||||||
heading.className = 'cockpit-signal-target';
|
|
||||||
const label = document.createElement('span');
|
|
||||||
label.className = 'cockpit-signal-target-label';
|
|
||||||
const rule = document.createElement('span');
|
|
||||||
rule.className = 'cockpit-signal-target-rule';
|
|
||||||
rule.setAttribute('aria-hidden', 'true');
|
|
||||||
const chevron = document.createElement('span');
|
|
||||||
chevron.className = 'material-symbols-outlined cockpit-signal-target-chevron';
|
|
||||||
chevron.setAttribute('aria-hidden', 'true');
|
|
||||||
chevron.textContent = 'chevron_right';
|
|
||||||
heading.append(label, rule, chevron);
|
|
||||||
}
|
|
||||||
body.append(heading, document.createElement('span'));
|
|
||||||
entry.append(time, body);
|
|
||||||
}
|
|
||||||
const [time, body] = entry.children;
|
|
||||||
const [heading, copy] = body.children;
|
|
||||||
const className = item.target ? `${item.tone} actionable` : item.tone;
|
|
||||||
if (entry.className !== className) entry.className = className;
|
|
||||||
setText(time, new Date(item.timestamp).toISOString().slice(11, 19) + 'Z');
|
|
||||||
if (item.target) {
|
|
||||||
heading.dataset.signalLayer = item.target.layerId;
|
|
||||||
heading.dataset.signalId = item.target.id;
|
|
||||||
const label = `Select flight ${item.title}`;
|
|
||||||
if (heading.getAttribute('aria-label') !== label) heading.setAttribute('aria-label', label);
|
|
||||||
setText(heading.children[0], item.title);
|
|
||||||
} else {
|
|
||||||
setText(heading, item.title);
|
|
||||||
}
|
|
||||||
setText(copy, item.detail);
|
|
||||||
return entry;
|
|
||||||
});
|
|
||||||
const retainedFocus = entries.includes(focusedEntry) ? focusedEntry : null;
|
|
||||||
if (focusedEntry && !retainedFocus) {
|
|
||||||
// A departed contact cannot remain selectable. Continue from the stable
|
|
||||||
// briefing footer instead of dropping keyboard traversal to the page top.
|
|
||||||
(this.briefTabs?.[this.briefPageIndex] || this.signalToggle)?.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
for (const entry of existing) if (!entries.includes(entry)) entry.remove();
|
|
||||||
|
|
||||||
// Ordinary insertBefore moves disconnect an element and lose its focus.
|
|
||||||
// Reorder the other rows around the focused row, which stays connected.
|
|
||||||
const focusIndex = retainedFocus ? entries.indexOf(retainedFocus) : -1;
|
|
||||||
if (retainedFocus) {
|
|
||||||
let anchor = retainedFocus;
|
|
||||||
for (let index = focusIndex - 1; index >= 0; index -= 1) {
|
|
||||||
const entry = entries[index];
|
|
||||||
if (entry.nextElementSibling !== anchor) this.signalList.insertBefore(entry, anchor);
|
|
||||||
anchor = entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let anchor = retainedFocus ? retainedFocus.nextElementSibling : this.signalList.firstElementChild;
|
|
||||||
for (let index = focusIndex + 1; index < entries.length; index += 1) {
|
|
||||||
const entry = entries[index];
|
|
||||||
if (entry === anchor) anchor = anchor.nextElementSibling;
|
|
||||||
else this.signalList.insertBefore(entry, anchor);
|
|
||||||
}
|
|
||||||
this.scheduleContextLayout();
|
|
||||||
}
|
}
|
||||||
|
const setText = (element, value) => {
|
||||||
|
if (element.textContent !== value) element.textContent = value;
|
||||||
|
};
|
||||||
|
const entries = this.signalItems.map((item) => {
|
||||||
|
// Identity includes the action target: a reused status key must never
|
||||||
|
// silently turn a focused flight button into a different selection.
|
||||||
|
const key = JSON.stringify([
|
||||||
|
item.key,
|
||||||
|
Boolean(item.target),
|
||||||
|
item.target?.layerId || '',
|
||||||
|
String(item.target?.id ?? ''),
|
||||||
|
]);
|
||||||
|
let entry = entriesByKey.get(key)?.shift();
|
||||||
|
if (!entry) {
|
||||||
|
entry = document.createElement('li');
|
||||||
|
entry.dataset.signalKey = key;
|
||||||
|
const time = document.createElement('time');
|
||||||
|
const body = document.createElement('div');
|
||||||
|
const heading = document.createElement(item.target ? 'button' : 'strong');
|
||||||
|
if (item.target) {
|
||||||
|
heading.type = 'button';
|
||||||
|
heading.className = 'cockpit-signal-target';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'cockpit-signal-target-label';
|
||||||
|
const rule = document.createElement('span');
|
||||||
|
rule.className = 'cockpit-signal-target-rule';
|
||||||
|
rule.setAttribute('aria-hidden', 'true');
|
||||||
|
const chevron = document.createElement('span');
|
||||||
|
chevron.className =
|
||||||
|
'material-symbols-outlined cockpit-signal-target-chevron';
|
||||||
|
chevron.setAttribute('aria-hidden', 'true');
|
||||||
|
chevron.textContent = 'chevron_right';
|
||||||
|
heading.append(label, rule, chevron);
|
||||||
|
}
|
||||||
|
body.append(heading, document.createElement('span'));
|
||||||
|
entry.append(time, body);
|
||||||
|
}
|
||||||
|
const [time, body] = entry.children;
|
||||||
|
const [heading, copy] = body.children;
|
||||||
|
const className = item.target ? `${item.tone} actionable` : item.tone;
|
||||||
|
if (entry.className !== className) entry.className = className;
|
||||||
|
setText(time, new Date(item.timestamp).toISOString().slice(11, 19) + 'Z');
|
||||||
|
if (item.target) {
|
||||||
|
heading.dataset.signalLayer = item.target.layerId;
|
||||||
|
heading.dataset.signalId = item.target.id;
|
||||||
|
const label = `Select flight ${item.title}`;
|
||||||
|
if (heading.getAttribute('aria-label') !== label)
|
||||||
|
heading.setAttribute('aria-label', label);
|
||||||
|
setText(heading.children[0], item.title);
|
||||||
|
} else {
|
||||||
|
setText(heading, item.title);
|
||||||
|
}
|
||||||
|
setText(copy, item.detail);
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
const retainedFocus = entries.includes(focusedEntry) ? focusedEntry : null;
|
||||||
|
if (focusedEntry && !retainedFocus) {
|
||||||
|
// A departed contact cannot remain selectable. Continue from the stable
|
||||||
|
// briefing footer instead of dropping keyboard traversal to the page top.
|
||||||
|
(this.briefTabs?.[this.briefPageIndex] || this.signalToggle)?.focus({
|
||||||
|
preventScroll: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const entry of existing) if (!entries.includes(entry)) entry.remove();
|
||||||
|
|
||||||
|
// Ordinary insertBefore moves disconnect an element and lose its focus.
|
||||||
|
// Reorder the other rows around the focused row, which stays connected.
|
||||||
|
const focusIndex = retainedFocus ? entries.indexOf(retainedFocus) : -1;
|
||||||
|
if (retainedFocus) {
|
||||||
|
let anchor = retainedFocus;
|
||||||
|
for (let index = focusIndex - 1; index >= 0; index -= 1) {
|
||||||
|
const entry = entries[index];
|
||||||
|
if (entry.nextElementSibling !== anchor)
|
||||||
|
this.signalList.insertBefore(entry, anchor);
|
||||||
|
anchor = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let anchor = retainedFocus
|
||||||
|
? retainedFocus.nextElementSibling
|
||||||
|
: this.signalList.firstElementChild;
|
||||||
|
for (let index = focusIndex + 1; index < entries.length; index += 1) {
|
||||||
|
const entry = entries[index];
|
||||||
|
if (entry === anchor) anchor = anchor.nextElementSibling;
|
||||||
|
else this.signalList.insertBefore(entry, anchor);
|
||||||
|
}
|
||||||
|
this.scheduleContextLayout();
|
||||||
|
}
|
||||||
|
|
||||||
export function pushCockpitSignal(key, tone, title, detail, target = null) {
|
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({
|
||||||
this.signalItems = this.signalItems.slice(0, 5);
|
key,
|
||||||
this.renderCockpitSignals();
|
tone,
|
||||||
}
|
title,
|
||||||
|
detail,
|
||||||
|
target,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
this.signalItems = this.signalItems.slice(0, 5);
|
||||||
|
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]));
|
||||||
const contacts = [];
|
const contacts = [];
|
||||||
const subject = snapshot.subject;
|
const subject = snapshot.subject;
|
||||||
if (['flights', 'military'].includes(subject?.layerId) && subject?.id) {
|
if (['flights', 'military'].includes(subject?.layerId) && subject?.id) {
|
||||||
contacts.push({
|
contacts.push({
|
||||||
key: `flight:${subject.layerId}:${subject.id}`,
|
key: `flight:${subject.layerId}:${subject.id}`,
|
||||||
tone: 'track',
|
tone: 'track',
|
||||||
title: subject.label || subject.id,
|
title: subject.label || subject.id,
|
||||||
detail: `${subject.layerId === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · CURRENT`,
|
detail: `${subject.layerId === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · CURRENT`,
|
||||||
target: { layerId: subject.layerId, id: String(subject.id) },
|
target: { layerId: subject.layerId, id: String(subject.id) },
|
||||||
distanceM: -1,
|
distanceM: -1,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
for (const cohort of snapshot.cohorts) {
|
|
||||||
if (!['flights', 'military'].includes(cohort.id)) continue;
|
|
||||||
for (const item of cohort.nearest) {
|
|
||||||
const id = item.icao24 || item.id;
|
|
||||||
if (!id) continue;
|
|
||||||
contacts.push({
|
|
||||||
key: `flight:${cohort.id}:${id}`,
|
|
||||||
tone: cohort.id === 'military' ? 'nearby military' : 'nearby',
|
|
||||||
// `id` above is IDENTITY (the ICAO hex). Display must not reuse it:
|
|
||||||
// the row's own `id` already carries the layer's label convention
|
|
||||||
// (callsign → registration → hex), so a callsign-less enriched
|
|
||||||
// contact reads as its registration here too. Same helper the
|
|
||||||
// Context panel's nearest list uses.
|
|
||||||
title: this.services.formatAwarenessLabel(item),
|
|
||||||
detail: `${cohort.id === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · ${
|
|
||||||
Number.isFinite(item.distanceM)
|
|
||||||
? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM`
|
|
||||||
: 'DISTANCE UNKNOWN'
|
|
||||||
}`,
|
|
||||||
target: { layerId: cohort.id, id: String(id) },
|
|
||||||
distanceM: item.distanceM ?? Infinity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
contacts.sort((a, b) => a.distanceM - b.distanceM);
|
|
||||||
const nextItems = contacts.slice(0, 5).map((item) => ({
|
|
||||||
...item,
|
|
||||||
timestamp: previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(),
|
|
||||||
}));
|
|
||||||
if (unknownCount) {
|
|
||||||
const sources = snapshot.cohorts
|
|
||||||
.filter((cohort) => cohort.count === null)
|
|
||||||
.map((cohort) => cohort.source)
|
|
||||||
.join(' · ');
|
|
||||||
nextItems.splice(4, Math.max(0, nextItems.length - 4), {
|
|
||||||
key: 'input-status',
|
|
||||||
tone: 'warning',
|
|
||||||
title: `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN`,
|
|
||||||
detail: sources || 'SOURCE STATUS UNAVAILABLE',
|
|
||||||
target: null,
|
|
||||||
timestamp: previous.get('input-status')?.timestamp || snapshot.evaluatedAt || Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.signalItems = nextItems;
|
|
||||||
this.signalSignatures.clear();
|
|
||||||
this.renderCockpitSignals();
|
|
||||||
}
|
}
|
||||||
|
for (const cohort of snapshot.cohorts) {
|
||||||
|
if (!['flights', 'military'].includes(cohort.id)) continue;
|
||||||
|
for (const item of cohort.nearest) {
|
||||||
|
const id = item.icao24 || item.id;
|
||||||
|
if (!id) continue;
|
||||||
|
contacts.push({
|
||||||
|
key: `flight:${cohort.id}:${id}`,
|
||||||
|
tone: cohort.id === 'military' ? 'nearby military' : 'nearby',
|
||||||
|
// `id` above is IDENTITY (the ICAO hex). Display must not reuse it:
|
||||||
|
// the row's own `id` already carries the layer's label convention
|
||||||
|
// (callsign → registration → hex), so a callsign-less enriched
|
||||||
|
// contact reads as its registration here too. Same helper the
|
||||||
|
// Context panel's nearest list uses.
|
||||||
|
title: this.services.formatAwarenessLabel(item),
|
||||||
|
detail: `${cohort.id === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · ${
|
||||||
|
Number.isFinite(item.distanceM)
|
||||||
|
? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM`
|
||||||
|
: 'DISTANCE UNKNOWN'
|
||||||
|
}`,
|
||||||
|
target: { layerId: cohort.id, id: String(id) },
|
||||||
|
distanceM: item.distanceM ?? Infinity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contacts.sort((a, b) => a.distanceM - b.distanceM);
|
||||||
|
const nextItems = contacts.slice(0, 5).map((item) => ({
|
||||||
|
...item,
|
||||||
|
timestamp:
|
||||||
|
previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(),
|
||||||
|
}));
|
||||||
|
if (unknownCount) {
|
||||||
|
const sources = snapshot.cohorts
|
||||||
|
.filter((cohort) => cohort.count === null)
|
||||||
|
.map((cohort) => cohort.source)
|
||||||
|
.join(' · ');
|
||||||
|
nextItems.splice(4, Math.max(0, nextItems.length - 4), {
|
||||||
|
key: 'input-status',
|
||||||
|
tone: 'warning',
|
||||||
|
title: `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN`,
|
||||||
|
detail: sources || 'SOURCE STATUS UNAVAILABLE',
|
||||||
|
target: null,
|
||||||
|
timestamp:
|
||||||
|
previous.get('input-status')?.timestamp ||
|
||||||
|
snapshot.evaluatedAt ||
|
||||||
|
Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.signalItems = nextItems;
|
||||||
|
this.signalSignatures.clear();
|
||||||
|
this.renderCockpitSignals();
|
||||||
|
}
|
||||||
|
|
||||||
export function handleSignalClick(event) {
|
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(
|
||||||
if (!target) return;
|
'button[data-signal-layer][data-signal-id]',
|
||||||
event.preventDefault();
|
);
|
||||||
this.services.militaryAwarenessLayer.focusTarget?.(
|
if (!target) return;
|
||||||
target.dataset.signalLayer,
|
event.preventDefault();
|
||||||
target.dataset.signalId,
|
this.services.militaryAwarenessLayer.focusTarget?.(
|
||||||
{ origin: 'user' },
|
target.dataset.signalLayer,
|
||||||
);
|
target.dataset.signalId,
|
||||||
|
{ origin: 'user' },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
+226
-198
@@ -1,226 +1,254 @@
|
|||||||
/** 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`
|
||||||
// (see update()), so the cockpit's own handle is the tracked identity there.
|
// (see update()), so the cockpit's own handle is the tracked identity there.
|
||||||
const trackedEntity = this.viewer?.trackedEntity || this.trackedEntity;
|
const trackedEntity = this.viewer?.trackedEntity || this.trackedEntity;
|
||||||
return resolveTrackedAircraftInfo({
|
return resolveTrackedAircraftInfo({
|
||||||
civilian: this.services.flightsLayer.getTrackedInfo?.() || null,
|
civilian: this.services.flightsLayer.getTrackedInfo?.() || null,
|
||||||
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 || '')
|
||||||
: null;
|
.trim()
|
||||||
const layerId = active && ['flights', 'military'].includes(info?.layerId)
|
.toLowerCase() || null
|
||||||
|
: null;
|
||||||
|
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;
|
||||||
const info = this.readAircraftInfo();
|
const info = this.readAircraftInfo();
|
||||||
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 =
|
||||||
layer.refreshTr3b?.(icao24);
|
info.layerId === 'military'
|
||||||
this._tr3bSignature = null; // force the chip to repaint on the next sync
|
? this.services.militaryFlightsLayer
|
||||||
this.syncTr3bToggle(info);
|
: this.services.flightsLayer;
|
||||||
return true;
|
layer.refreshTr3b?.(icao24);
|
||||||
}
|
this._tr3bSignature = null; // force the chip to repaint on the next sync
|
||||||
|
this.syncTr3bToggle(info);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function syncTr3bToggle(info) {
|
export function syncTr3bToggle(info) {
|
||||||
if (!this.tr3bToggle) return;
|
if (!this.tr3bToggle) return;
|
||||||
const icao24 = String(info?.icao24 || '').trim();
|
const icao24 = String(info?.icao24 || '').trim();
|
||||||
const converted = !!icao24 && this.services.isTr3b(icao24);
|
const converted = !!icao24 && this.services.isTr3b(icao24);
|
||||||
const signature = icao24 ? `${icao24}:${converted ? 1 : 0}` : '';
|
const signature = icao24 ? `${icao24}:${converted ? 1 : 0}` : '';
|
||||||
if (this._tr3bSignature === signature) return;
|
if (this._tr3bSignature === signature) return;
|
||||||
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;
|
||||||
if (this.active) return;
|
if (this.active) return;
|
||||||
const info = this.readAircraftInfo();
|
const info = this.readAircraftInfo();
|
||||||
const trackedContact = !!(info && this.viewer.trackedEntity?.position);
|
const trackedContact = !!(info && this.viewer.trackedEntity?.position);
|
||||||
this.syncTr3bToggle(trackedContact ? info : null);
|
this.syncTr3bToggle(trackedContact ? info : null);
|
||||||
const available = !!(this.isEntryAllowed() && trackedContact);
|
const available = !!(this.isEntryAllowed() && trackedContact);
|
||||||
// Change-only DOM writes: this runs on a preUpdate cadence, and
|
// Change-only DOM writes: this runs on a preUpdate cadence, and
|
||||||
// unconditional `hidden` assignments invalidate style/layout every frame
|
// unconditional `hidden` assignments invalidate style/layout every frame
|
||||||
// even when nothing changed. (perf item 9)
|
// even when nothing changed. (perf item 9)
|
||||||
if (this._entryAvailable === available) return;
|
if (this._entryAvailable === available) return;
|
||||||
this._entryAvailable = available;
|
this._entryAvailable = available;
|
||||||
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 }
|
||||||
if (!changed) {
|
: options;
|
||||||
this.contextNavigationDeadlineMs = 0;
|
const changed = Boolean(
|
||||||
return false;
|
this.services.militaryAwarenessLayer?.[method]?.(navigationOptions),
|
||||||
}
|
);
|
||||||
if (wasActive) this._adoptTrackedEntity(performance.now());
|
if (!changed) {
|
||||||
return true;
|
this.contextNavigationDeadlineMs = 0;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
if (wasActive) this._adoptTrackedEntity(performance.now());
|
||||||
|
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 (
|
||||||
const info = suppliedInfo || this.readAircraftInfo();
|
!this.active ||
|
||||||
if (!info) return false;
|
!nextEntity?.position ||
|
||||||
if (this.trackedEntity && this.viewer.entities.contains(this.trackedEntity)) {
|
nextEntity === this.trackedEntity
|
||||||
this.trackedEntity.show = this.trackedEntityWasShown;
|
)
|
||||||
}
|
return false;
|
||||||
this.trackedEntity = nextEntity;
|
const info = suppliedInfo || this.readAircraftInfo();
|
||||||
this.trackedEntityWasShown = nextEntity.show;
|
if (!info) return false;
|
||||||
nextEntity.show = false;
|
if (this.trackedEntity && this.viewer.entities.contains(this.trackedEntity)) {
|
||||||
this.viewer.trackedEntity = undefined;
|
this.trackedEntity.show = this.trackedEntityWasShown;
|
||||||
this.cockpitAnchorValid = false;
|
|
||||||
this.heading = normalizeHeading(info.track ?? 0);
|
|
||||||
this.lastFrameMs = nowMs;
|
|
||||||
this.lastHudUpdateMs = 0;
|
|
||||||
this.lastContextUpdateMs = 0;
|
|
||||||
this.lastCameraUpdateMs = 0;
|
|
||||||
this.contextNavigationDeadlineMs = 0;
|
|
||||||
this.dispatchCockpitModeChanged(true, info);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
this.trackedEntity = nextEntity;
|
||||||
|
this.trackedEntityWasShown = nextEntity.show;
|
||||||
|
nextEntity.show = false;
|
||||||
|
this.viewer.trackedEntity = undefined;
|
||||||
|
this.cockpitAnchorValid = false;
|
||||||
|
this.heading = normalizeHeading(info.track ?? 0);
|
||||||
|
this.lastFrameMs = nowMs;
|
||||||
|
this.lastHudUpdateMs = 0;
|
||||||
|
this.lastContextUpdateMs = 0;
|
||||||
|
this.lastCameraUpdateMs = 0;
|
||||||
|
this.contextNavigationDeadlineMs = 0;
|
||||||
|
this.dispatchCockpitModeChanged(true, info);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function enter() {
|
export function enter() {
|
||||||
if (this.destroyed) return false;
|
if (this.destroyed) return false;
|
||||||
if (this.active) return false;
|
if (this.active) return false;
|
||||||
if (!this.isEntryAllowed()) return false;
|
if (!this.isEntryAllowed()) return false;
|
||||||
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.
|
||||||
this.onCameraTakeover?.();
|
this.onCameraTakeover?.();
|
||||||
this.viewer.camera.cancelFlight();
|
this.viewer.camera.cancelFlight();
|
||||||
this.trackedEntity = entity;
|
this.trackedEntity = entity;
|
||||||
this.trackedEntityWasShown = entity.show;
|
this.trackedEntityWasShown = entity.show;
|
||||||
entity.show = false;
|
entity.show = false;
|
||||||
this.heading = normalizeHeading(info.track ?? 0);
|
this.heading = normalizeHeading(info.track ?? 0);
|
||||||
this.lastFrameMs = performance.now();
|
this.lastFrameMs = performance.now();
|
||||||
this.lastCameraUpdateMs = 0;
|
this.lastCameraUpdateMs = 0;
|
||||||
this.lastHudUpdateMs = 0;
|
this.lastHudUpdateMs = 0;
|
||||||
this.lastContextUpdateMs = 0;
|
this.lastContextUpdateMs = 0;
|
||||||
this.contextNavigationDeadlineMs = 0;
|
this.contextNavigationDeadlineMs = 0;
|
||||||
this.lastGroundProbeMs = 0;
|
this.lastGroundProbeMs = 0;
|
||||||
this.surfaceWaitStartedMs = performance.now();
|
this.surfaceWaitStartedMs = performance.now();
|
||||||
this.surfaceAcquiring = false;
|
this.surfaceAcquiring = false;
|
||||||
this.surfaceFallback = false;
|
this.surfaceFallback = false;
|
||||||
this.lastCompassSignature = '';
|
this.lastCompassSignature = '';
|
||||||
this.cockpitAnchorValid = false;
|
this.cockpitAnchorValid = false;
|
||||||
this.lastCameraUpdateMs = 0;
|
this.lastCameraUpdateMs = 0;
|
||||||
this.active = true;
|
this.active = true;
|
||||||
// Cockpit animates the camera from preUpdate every frame — preUpdate only
|
// Cockpit animates the camera from preUpdate every frame — preUpdate only
|
||||||
// runs on rendered frames, so idle mode would freeze the cockpit solid.
|
// runs on rendered frames, so idle mode would freeze the cockpit solid.
|
||||||
// (perf wave 2)
|
// (perf wave 2)
|
||||||
this.services.holdContinuousRender('cockpit');
|
this.services.holdContinuousRender('cockpit');
|
||||||
this.viewer.trackedEntity = undefined;
|
this.viewer.trackedEntity = undefined;
|
||||||
this.viewer.scene.screenSpaceCameraController.enableInputs = false;
|
this.viewer.scene.screenSpaceCameraController.enableInputs = false;
|
||||||
document.body.classList.add('cockpit-mode');
|
document.body.classList.add('cockpit-mode');
|
||||||
// Activation writes entry/quick/map visibility directly, bypassing
|
// Activation writes entry/quick/map visibility directly, bypassing
|
||||||
// syncEntry's change-only cache — invalidate it so the exit-path
|
// syncEntry's change-only cache — invalidate it so the exit-path
|
||||||
// syncEntry re-applies every write (notably re-hiding mapViewButton).
|
// syncEntry re-applies every write (notably re-hiding mapViewButton).
|
||||||
this._entryAvailable = undefined;
|
this._entryAvailable = undefined;
|
||||||
if (this.entry) this.entry.hidden = true;
|
if (this.entry) this.entry.hidden = true;
|
||||||
if (this.tr3bToggle) {
|
if (this.tr3bToggle) {
|
||||||
this.tr3bToggle.hidden = true;
|
this.tr3bToggle.hidden = true;
|
||||||
this._tr3bSignature = null;
|
this._tr3bSignature = null;
|
||||||
}
|
|
||||||
if (this.mapViewButton) this.mapViewButton.hidden = false;
|
|
||||||
if (this.resetGlobeButton) this.resetGlobeButton.hidden = false;
|
|
||||||
if (this.hud) this.hud.hidden = false;
|
|
||||||
if (this.signalStream) this.signalStream.hidden = false;
|
|
||||||
this.hud?.classList.add('signals-active');
|
|
||||||
this.signalItems = [];
|
|
||||||
this.signalSignatures.clear();
|
|
||||||
this.showBriefPage(0);
|
|
||||||
this.startBriefRotation();
|
|
||||||
const trackLabel = info.callsign || info.registration || info.icao24 || 'AIRCRAFT';
|
|
||||||
const trackHeading = String(Math.round(normalizeHeading(info.track ?? 0))).padStart(3, '0');
|
|
||||||
this.pushCockpitSignal(
|
|
||||||
'track',
|
|
||||||
'track',
|
|
||||||
'TRACK ACQUIRED',
|
|
||||||
`${trackLabel} · COURSE ${trackHeading}°`,
|
|
||||||
);
|
|
||||||
this.updateHud(info, performance.now(), true);
|
|
||||||
this.setVisionMode(this.visionMode);
|
|
||||||
this.scheduleContextLayout();
|
|
||||||
this.mapViewButton?.focus({ preventScroll: true });
|
|
||||||
this.onEntered?.();
|
|
||||||
this.dispatchCockpitModeChanged(true, info);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
if (this.mapViewButton) this.mapViewButton.hidden = false;
|
||||||
|
if (this.resetGlobeButton) this.resetGlobeButton.hidden = false;
|
||||||
|
if (this.hud) this.hud.hidden = false;
|
||||||
|
if (this.signalStream) this.signalStream.hidden = false;
|
||||||
|
this.hud?.classList.add('signals-active');
|
||||||
|
this.signalItems = [];
|
||||||
|
this.signalSignatures.clear();
|
||||||
|
this.showBriefPage(0);
|
||||||
|
this.startBriefRotation();
|
||||||
|
const trackLabel =
|
||||||
|
info.callsign || info.registration || info.icao24 || 'AIRCRAFT';
|
||||||
|
const trackHeading = String(
|
||||||
|
Math.round(normalizeHeading(info.track ?? 0)),
|
||||||
|
).padStart(3, '0');
|
||||||
|
this.pushCockpitSignal(
|
||||||
|
'track',
|
||||||
|
'track',
|
||||||
|
'TRACK ACQUIRED',
|
||||||
|
`${trackLabel} · COURSE ${trackHeading}°`,
|
||||||
|
);
|
||||||
|
this.updateHud(info, performance.now(), true);
|
||||||
|
this.setVisionMode(this.visionMode);
|
||||||
|
this.scheduleContextLayout();
|
||||||
|
this.mapViewButton?.focus({ preventScroll: true });
|
||||||
|
this.onEntered?.();
|
||||||
|
this.dispatchCockpitModeChanged(true, info);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function exit({ restoreTracking = true } = {}) {
|
export function exit({ restoreTracking = true } = {}) {
|
||||||
if (!this.active) return false;
|
if (!this.active) return false;
|
||||||
const entity = this.trackedEntity;
|
const entity = this.trackedEntity;
|
||||||
this.active = false;
|
this.active = false;
|
||||||
this.services.releaseContinuousRender('cockpit');
|
this.services.releaseContinuousRender('cockpit');
|
||||||
this.trackedEntity = null;
|
this.trackedEntity = null;
|
||||||
this.heading = null;
|
this.heading = null;
|
||||||
this.cockpitAnchorValid = false;
|
this.cockpitAnchorValid = false;
|
||||||
this.surfaceWaitStartedMs = 0;
|
this.surfaceWaitStartedMs = 0;
|
||||||
this.surfaceAcquiring = false;
|
this.surfaceAcquiring = false;
|
||||||
this.surfaceFallback = false;
|
this.surfaceFallback = false;
|
||||||
this.lastHudUpdateMs = 0;
|
this.lastHudUpdateMs = 0;
|
||||||
this.lastContextUpdateMs = 0;
|
this.lastContextUpdateMs = 0;
|
||||||
this.contextNavigationDeadlineMs = 0;
|
this.contextNavigationDeadlineMs = 0;
|
||||||
this.lastCompassSignature = '';
|
this.lastCompassSignature = '';
|
||||||
this.stopBriefRotation();
|
this.stopBriefRotation();
|
||||||
this.regionalBriefAbort?.abort();
|
this.regionalBriefAbort?.abort();
|
||||||
this.regionalBriefAbort = null;
|
this.regionalBriefAbort = null;
|
||||||
this.regionalBriefRequestToken += 1;
|
this.regionalBriefRequestToken += 1;
|
||||||
this.regionalBriefSubjectId = null;
|
this.regionalBriefSubjectId = null;
|
||||||
document.body.classList.remove('cockpit-mode');
|
document.body.classList.remove('cockpit-mode');
|
||||||
this.onExited?.();
|
this.onExited?.();
|
||||||
this.hud?.style.removeProperty('--cockpit-utility-top');
|
this.hud?.style.removeProperty('--cockpit-utility-top');
|
||||||
this.hud?.style.removeProperty('--cockpit-utility-max-height');
|
this.hud?.style.removeProperty('--cockpit-utility-max-height');
|
||||||
if (this.hud) this.hud.hidden = true;
|
if (this.hud) this.hud.hidden = true;
|
||||||
if (this.route) this.route.hidden = true;
|
if (this.route) this.route.hidden = true;
|
||||||
this.clearPredictiveRoute();
|
this.clearPredictiveRoute();
|
||||||
this.setVisionMode('optical');
|
this.setVisionMode('optical');
|
||||||
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))
|
||||||
this.trackedEntityWasShown = true;
|
entity.show = this.trackedEntityWasShown;
|
||||||
this.dispatchCockpitModeChanged(false);
|
this.trackedEntityWasShown = true;
|
||||||
if (restoreTracking && entity && this.viewer.entities.contains(entity)) {
|
this.dispatchCockpitModeChanged(false);
|
||||||
this.viewer.trackedEntity = entity;
|
if (restoreTracking && entity && this.viewer.entities.contains(entity)) {
|
||||||
this.restoreTrackingFrame(entity);
|
this.viewer.trackedEntity = entity;
|
||||||
}
|
this.restoreTrackingFrame(entity);
|
||||||
this.syncEntry();
|
|
||||||
const restoreTarget = this.entryFocusOrigin === this.entry
|
|
||||||
? this.entry
|
|
||||||
: (this.entry || this.entryFocusOrigin);
|
|
||||||
this.entryFocusOrigin = null;
|
|
||||||
if (restoreTarget?.isConnected && !restoreTarget.hidden) {
|
|
||||||
restoreTarget.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
this.syncEntry();
|
||||||
|
const restoreTarget =
|
||||||
|
this.entryFocusOrigin === this.entry
|
||||||
|
? this.entry
|
||||||
|
: this.entry || this.entryFocusOrigin;
|
||||||
|
this.entryFocusOrigin = null;
|
||||||
|
if (restoreTarget?.isConnected && !restoreTarget.hidden) {
|
||||||
|
restoreTarget.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user