Files
gods-eye-view/src/ui/cockpitController.js
T
Sameh Khamis ffe14e50f5 feat(ui): render the Cockpit Contact card from a store
The Cockpit controller no longer writes the Contact card's scope,
navigation state, cohort counts, nearest contact, bearing, uncertainty,
update time or weather toggle: it keeps them as plain state, starting
from cockpitContextState.js, and publishes a copy to the gev-cockpit
store, and a new CockpitContext component renders it. A lost contact
still holds its last readout. The weather toggle flips from that state,
not the button's attribute. The card's visibility, collapse and layout
stay with the controller. The controller's publishBrief option becomes
a publish object, one entry per Cockpit view.
2026-10-31 14:08:19 -07:00

403 lines
14 KiB
JavaScript

/** 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 { update } from './cockpitCamera.js';
import {
updateHud,
updateRoute,
syncWeatherToggle,
setVisionMode,
cycleVisionMode,
clearPredictiveRoute,
} from './cockpitInstruments.js';
import { publishContextView, updateContext } from './cockpitContext.js';
import { createContextState } from './cockpitContextState.js';
import { createBriefState } from './cockpitBriefPages.js';
import {
publishBriefView,
showBriefPage,
setBriefAutoRotate,
startBriefRotation,
stopBriefRotation,
updateLocalPosition,
maybeRefreshRegionalBrief,
renderRegionalBriefStatus,
renderRegionalBrief,
} from './cockpitBriefing.js';
import {
handleSignalClick,
renderCockpitSignals,
pushCockpitSignal,
updateCockpitSignals,
} from './cockpitSignals.js';
import {
scheduleContextLayout,
setContextCollapsed,
setSignalCollapsed,
syncContextLayout,
syncSignalLayout,
} from './cockpitLayout.js';
import { onKeyDown } from './cockpitInput.js';
import * as Cesium from 'cesium';
export class CockpitViewController {
constructor(
viewer,
{
services,
onVisionChange = null,
getInitialVisionMode = null,
onCameraTakeover = null,
isEntryAllowed = null,
onEntered = null,
onExited = null,
restoreTrackingFrame = null,
publish = {},
} = {},
) {
this.viewer = viewer;
this.services = services;
this.destroyed = false;
this.disposed = false;
this.active = false;
this.trackedEntity = null;
this.trackedEntityWasShown = true;
this.heading = null;
this.lastFrameMs = 0;
this.lastCameraUpdateMs = 0;
this.lastHudUpdateMs = 0;
this.lastContextUpdateMs = 0;
this.lastGroundProbeMs = 0;
this.contextNavigationDeadlineMs = 0;
this.surfaceWaitStartedMs = 0;
this.surfaceAcquiring = false;
this.surfaceFallback = false;
this.lastCompassSignature = '';
this.entry = document.getElementById('cockpit-entry');
this.tr3bToggle = document.getElementById('tr3b-toggle');
this._tr3bSignature = null;
this.mapViewButton = document.getElementById('map-view-switch');
this.resetGlobeButton = document.getElementById('cockpit-reset-globe');
this.hud = document.getElementById('cockpit-hud');
this.entryFocusOrigin = null;
this.callsign = document.getElementById('cockpit-callsign');
this.speed = document.getElementById('cockpit-speed-value');
this.speedRim = document.getElementById('cockpit-speed-rim');
this.speedRimValue = document.getElementById('cockpit-speed-rim-value');
this.speedRimTicks = Array.from(
document.querySelectorAll('[data-speed-rim-tick]'),
);
this.altitude = document.getElementById('cockpit-altitude-value');
this.altitudeRim = document.getElementById('cockpit-altitude-rim');
this.altitudeRimValue = document.getElementById(
'cockpit-altitude-rim-value',
);
this.altitudeRimTicks = Array.from(
document.querySelectorAll('[data-altitude-rim-tick]'),
);
this.headingValue = document.getElementById('cockpit-heading-value');
this.compassTape = document.getElementById('cockpit-compass-tape');
this.clock = document.getElementById('cockpit-clock');
this.position = document.getElementById('cockpit-position');
this.aircraftMeta = document.getElementById('cockpit-aircraft-meta');
this.route = document.getElementById('cockpit-route');
this.routeFrom = document.getElementById('cockpit-route-from');
this.routeTo = document.getElementById('cockpit-route-to');
this.routeStatus = document.getElementById('cockpit-route-status');
this.routeDirection = document.getElementById('cockpit-route-direction');
this.routeDirectionLabel = document.getElementById(
'cockpit-route-direction-label',
);
this.visionPrevious = document.getElementById('cockpit-vision-previous');
this.visionCurrent = document.getElementById('cockpit-vision-current');
this.visionCurrentLabel = document.getElementById(
'cockpit-vision-current-label',
);
this.visionNext = document.getElementById('cockpit-vision-next');
this.visionMode = 'optical';
this.onVisionChange = onVisionChange;
this.getInitialVisionMode =
typeof getInitialVisionMode === 'function'
? getInitialVisionMode
: () => 'optical';
this.onCameraTakeover = onCameraTakeover;
this.onEntered = onEntered;
this.onExited = onExited;
this.restoreTrackingFrame =
typeof restoreTrackingFrame === 'function'
? restoreTrackingFrame
: () => false;
this.isEntryAllowed =
typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true;
this.context = document.getElementById('cockpit-context');
this.contextPrevious = document.getElementById('cockpit-context-previous');
this.contextNext = document.getElementById('cockpit-context-next');
this.contextToggle = document.getElementById('cockpit-context-toggle');
this.weatherToggle = document.getElementById('cockpit-weather-toggle');
this.contextCollapsed = false;
this.signalStream = document.getElementById('cockpit-signal-stream');
this.signalList = document.getElementById('cockpit-signal-list');
this.signalToggle = document.getElementById('cockpit-signal-toggle');
this.briefPrevious = document.getElementById('cockpit-brief-previous');
this.briefNext = document.getElementById('cockpit-brief-next');
this.briefAutoToggle = document.getElementById('cockpit-brief-auto');
this.briefTabs = Array.from(
document.querySelectorAll('[data-cockpit-brief-index]'),
);
this.signalCollapsed = false;
this.signalUserCollapsed = false;
this.signalItems = [];
this.signalSignatures = new Map();
this.briefPageIndex = 0;
this.briefAutoRotateEnabled = false;
// Cockpit's views render what these receive.
this.publishBrief = publish.brief ?? (() => {});
this.publishContext = publish.context ?? (() => {});
this.contextView = createContextState();
this.brief = createBriefState();
this.briefTimer = null;
this.lastAircraftInfo = null;
this.regionalBrief = null;
this.regionalBriefAnchor = null;
this.regionalBriefFetchedAt = 0;
this.regionalBriefAbort = null;
this.regionalBriefRequestToken = 0;
this.regionalBriefSubjectId = null;
this.contextLayoutFrame = null;
this.contextLayoutStamp = null;
this.scratchTarget = new Cesium.Cartesian3();
this.cockpitAnchor = new Cesium.Cartesian3();
this.cockpitAnchorValid = false;
this.scratchCamera = new Cesium.Cartesian3();
this.scratchAdvance = new Cesium.Cartesian3();
this.scratchCorrection = new Cesium.Cartesian3();
this.scratchForward = new Cesium.Cartesian3();
this.scratchHorizontal = new Cesium.Cartesian3();
this.scratchUp = new Cesium.Cartesian3();
this.scratchLocal = new Cesium.Cartesian3();
this.scratchEnu = new Cesium.Matrix4();
this.scratchAnchorCartographic = new Cesium.Cartographic();
this.scratchCameraCartographic = new Cesium.Cartographic();
this.scratchTargetCartographic = new Cesium.Cartographic();
this._listenerRemovers = [];
// Camera mutations belong before scene update/culling. Changing the camera
// from preRender makes 3D Tiles discover a new view after traversal and can
// create a self-sustaining refinement loop under a moving cockpit camera.
this._listenerRemovers.push(
viewer.scene.preUpdate.addEventListener(() => this.update()),
viewer.trackedEntityChanged.addEventListener(() => {
if (this.active) this._adoptTrackedEntity(performance.now());
else this.syncEntry();
}),
);
this._listen(this.entry, 'click', () => this.enter());
this._listen(this.tr3bToggle, 'click', () => this.toggleTrackedTr3b());
this._listen(this.mapViewButton, 'click', () => this.exit());
this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1));
this._listen(this.visionCurrent, 'click', () => this.cycleVisionMode(1));
this._listen(this.visionNext, 'click', () => this.cycleVisionMode(1));
this._listen(this.contextPrevious, 'click', () =>
this.navigateContext(-1, { origin: 'user' }),
);
this._listen(this.contextNext, 'click', () =>
this.navigateContext(1, { origin: 'user' }),
);
this._listen(this.contextToggle, 'click', () =>
this.setContextCollapsed(!this.contextCollapsed),
);
this._listen(this.weatherToggle, 'click', () => {
const enabled = !this.contextView.weather;
this.syncWeatherToggle(enabled);
window.dispatchEvent(
new CustomEvent('gev:cockpit-weather-toggle', {
detail: { enabled },
}),
);
});
this._listen(window, 'gev:cockpit-weather-state', (event) => {
this.syncWeatherToggle(event?.detail?.enabled !== false);
});
this._listen(this.signalToggle, 'click', () =>
this.setSignalCollapsed(!this.signalCollapsed, { user: true }),
);
this._listen(this.signalList, 'click', (event) =>
this.handleSignalClick(event),
);
this._listen(this.briefPrevious, 'click', () =>
this.showBriefPage(this.briefPageIndex - 1, { manual: true }),
);
this._listen(this.briefNext, 'click', () =>
this.showBriefPage(this.briefPageIndex + 1, { manual: true }),
);
this._listen(this.briefAutoToggle, 'click', () => {
this.setBriefAutoRotate(!this.briefAutoRotateEnabled);
});
this.briefTabs.forEach((button) =>
this._listen(button, 'click', () => {
this.showBriefPage(Number(button.dataset.cockpitBriefIndex), {
manual: true,
});
}),
);
this._listen(document, 'visibilitychange', () => {
if (document.hidden) this.stopBriefRotation();
else if (this.briefAutoRotateEnabled) this.startBriefRotation();
});
this._listen(window, 'resize', () => this.scheduleContextLayout());
this._listen(document, 'keydown', (event) => this.onKeyDown(event), true);
}
_listen(target, type, handler, options) {
if (!target?.addEventListener) return;
target.addEventListener(type, handler, options);
this._listenerRemovers.push(() =>
target.removeEventListener(type, handler, options),
);
}
readAircraftInfo() {
return readAircraftInfo.call(this);
}
dispatchCockpitModeChanged(active, info) {
return dispatchCockpitModeChanged.call(this, active, info);
}
toggleTrackedTr3b() {
return toggleTrackedTr3b.call(this);
}
syncTr3bToggle(info) {
return syncTr3bToggle.call(this, info);
}
syncEntry() {
return syncEntry.call(this);
}
navigateContext(direction, options) {
return navigateContext.call(this, direction, options);
}
_adoptTrackedEntity(nowMs, suppliedInfo) {
return _adoptTrackedEntity.call(this, nowMs, suppliedInfo);
}
enter() {
return enter.call(this);
}
exit(options) {
return exit.call(this, options);
}
update() {
return update.call(this);
}
updateHud(info, nowMs, forceContext) {
return updateHud.call(this, info, nowMs, forceContext);
}
updateRoute(info) {
return updateRoute.call(this, info);
}
syncWeatherToggle(enabled) {
return syncWeatherToggle.call(this, enabled);
}
setVisionMode(mode, options) {
return setVisionMode.call(this, mode, options);
}
cycleVisionMode(direction) {
return cycleVisionMode.call(this, direction);
}
clearPredictiveRoute() {
return clearPredictiveRoute.call(this);
}
publishContextView() {
return publishContextView.call(this);
}
updateContext(info, heading) {
return updateContext.call(this, info, heading);
}
publishBriefView() {
return publishBriefView.call(this);
}
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);
}
/** Revoke new work before the shell awaits layer restoration. */
stop() {
if (this.destroyed) return;
this.destroyed = true;
this.regionalBriefAbort?.abort();
this.regionalBriefAbort = null;
this.regionalBriefRequestToken += 1;
this.stopBriefRotation();
if (this.contextLayoutFrame !== null)
cancelAnimationFrame(this.contextLayoutFrame);
this.contextLayoutFrame = null;
for (const removeListener of this._listenerRemovers.splice(0))
removeListener?.();
}
dispose() {
if (this.disposed) return;
this.disposed = true;
this.stop();
this.exit({ restoreTracking: false });
}
}