Files
gods-eye-view/src/ui/applicationShell.js
T
Sameh Khamis b22e138c55 feat(world-overlay): render the visible map target list from a store
The world overlay presents its focusable mirror of painted cards through
subscribeWorldOverlayActions() and answers activateWorldOverlayAction();
the application shell publishes the list to a store and the scene chrome
renders the buttons and the status, announcing a repeated focus again.
The module stays free of Vue for the portable transit entry, and its
canvases remain painting surfaces.
2026-10-31 21:52:32 -07:00

1857 lines
71 KiB
JavaScript

import { nextTick } from 'vue';
import { resolveImageryHost } from '../layers/weather/imageryHost.js';
import { ShellFacade } from './shellFacade.js';
import { AircraftDisplay } from './aircraftDisplay.js';
import { LayerBindings } from './layerBindings.js';
import { PanelChrome } from './panelChrome.js';
import { initMobileShell } from './mobileShell.js';
import { VisualSettings } from './visualSettings.js';
import { NavigationController } from './navigationController.js';
import { ShareRestoration } from './shareRestoration.js';
import { DisplayBindings } from './displayBindings.js';
import { createStateChannel } from '../app/stateChannel.js';
import { UiLifetime } from './uiLifetime.js';
import { RecordingControls } from './recordingControls.js';
import { readShellElements } from './shellElements.js';
import { CockpitCoordinator } from './cockpitCoordinator.js';
import { ContextControls } from './context.js';
import { CctvControls } from './cctv.js';
import { StreetLevelControls } from './streetLevelControls.js';
import { RadioControls } from './radio.js';
import { LocalSdrControls } from './localSdrControls.js';
import { LocationNavigation } from './locationNavigation.js';
import { bindClearLayersControl } from './layers.js';
import { bindCameraOrientationControls } from './cameraOrientationControls.js';
import { createMapSourceControls } from './mapSource.js';
import { STYLES } from './effects.js';
import { isHudLayout } from '../hudLayouts.js';
import {
getCyberSonarControlState,
setCyberSonarControls,
} from './cyberSonarControls.js';
import * as Cesium from 'cesium';
import { aircraftTrackingTarget } from '../cockpitTracking.js';
import { ShellFeedback } from './shellFeedback.js';
import {
applyShareFollowOffset,
readShareFollowSubject,
} from './shareFollow.js';
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
import {
describeUnsavedWork,
getLocale,
msg,
persistLocaleAndReload,
registerUnsavedWork,
t,
} from '../i18n/index.js';
import { applyLocaleChoice } from './localePicker.js';
import { PanelNavigation } from './panelNavigation.js';
import { applicationPinia, createUiRuntime } from './vueRuntime.js';
import { useContextStore } from './stores/context.js';
import { useMissionReplayStore } from './stores/missionReplay.js';
import { useCelestialRingStore } from './stores/celestialRing.js';
import { useWorldOverlayActionsStore } from './stores/worldOverlayActions.js';
import { useDisplayStore } from './stores/display.js';
import { useLocaleStore } from './stores/locale.js';
import { useLocationStore } from './stores/location.js';
import { useCockpitStore } from './stores/cockpit.js';
/**
* Central UI orchestrator for the God's Eye View application.
*
* Responsibilities:
* - Visual controls and presets backed by the VisualEffects controller.
* - Bloom and sharpen post-processing toggle/intensity control.
* - Collapsible rail panels with their collapsed state kept in localStorage.
* - CCTV panel: camera selection, coverage toggle, projection, calibration
* sliders, auto-hop, and summary typewriter effect.
* - Location bar with city/POI preset pills, QWERTY key navigation,
* geocoding search, and inter-city world-jump transitions.
* - Orbit controller integration for POI fly-around.
* - Recording mode with safe-frame overlay and HUD mode switching.
* - Share link encoding/decoding (delegates to ShareLinkManager).
* - Detection overlay mode cycling and density tuning.
* - Toast notification system.
* - Intel HUD lifecycle and variant switching.
*/
export class StyleManager extends ShellFacade {
/**
* @param {Cesium.Viewer} viewer - The CesiumJS viewer instance.
* @param {object} [options]
* @param {boolean} [options.localePicker=true] - false removes the language
* picker, for a host that does not initialize Core's locale.
*/
constructor(
viewer,
{
mapStackController = null,
placeSearch,
services,
requestServices,
initialShareCode = null,
localePicker = true,
} = {},
) {
super();
this._localePickerEnabled = localePicker;
const {
IntelHUD,
ShareLinkManager,
CelestialRing,
initTrackedReadout,
initWorldOverlay,
subscribeWorldOverlayActions,
activateWorldOverlayAction,
initDetection,
setDetectionStyle,
trafficLayer,
flightsLayer,
militaryFlightsLayer,
satellitesLayer,
cctvLayer,
bikeshareLayer,
transitLayer,
aisLiveVesselsLayer,
militaryAwarenessLayer,
localAdsbLayer,
} = services;
this.services = services;
this._lifetime = new UiLifetime();
this._recording = new RecordingControls({
syncShareState: () => this._syncShareState(),
syncHudControls: () => this._visualSettings._syncHudControls(),
});
Object.assign(this, readShellElements());
this._panelChrome = new PanelChrome({
elements: {
_leftPanelStack: this._leftPanelStack,
_locationSearch: this._locationSearch,
_ppToggles: this._ppToggles,
_rightPanelStack: this._rightPanelStack,
},
operations: {
_syncCctvPanelViewport: (...args) =>
this._syncCctvPanelViewport(...args),
_showToast: (...args) => this._showToast(...args),
},
readHud: () => this.hud,
readCockpit: () => this.cockpitView,
readShareLinks: () => this.shareLinkManager,
readInitialShare: () => this._initialShareState,
readScrollRestoreOwner: () => this._displayPortalScrollRestoreOwner,
readDisplayScrollTop: () => this._standardDisplayScrollTop,
readSharedCollapsed: (panelId) =>
this._mobileShell?.sharedCollapsed(panelId),
});
// Vue views share this runtime and its stores; dispose unmounts them.
this._ui = createUiRuntime({ pinia: applicationPinia() });
this._feedback = new ShellFeedback({
readLayers: () => this._dataManager?.getAll?.() || [],
});
this.viewer = viewer;
this.mapStackController = mapStackController;
this.placeSearch = placeSearch;
this._navigation = new NavigationController({
viewer,
tracking: {
flightsLayer,
militaryFlightsLayer,
satellitesLayer,
aisLiveVesselsLayer,
militaryAwarenessLayer,
rocketLaunchesLayer: services.rocketLaunchesLayer,
},
searchInput: this._locationSearch,
interruptCameraMotion: services.interruptCameraMotion,
interruptGlobeRotation: services.interruptGlobeRotation,
isCockpitActive: () => !!this.cockpitView?.active,
clearLocation: () => this.clearSearchedLocation(),
cancelShareSelection: () => this._shareRestoration.cancelSelection(),
getDataManager: () => this._dataManager,
stopOrbit: () => this._stopOrbit(),
cancelOrientation: () => this._cameraOrientationControls?.cancel(),
showToast: (text) => this._showToast(text),
});
this._shareRestoration = new ShareRestoration({
viewer,
navigation: this._navigation,
syncShareState: () => this._syncShareState(),
syncModels3d: (state) => this._syncModels3dFromLayerState(state),
showStatus: (message, options) =>
this._showGlobalStatusNotice(message, options),
feedback: this._feedback,
updateFeedback: () => this._updateGlobalLoadingFeedback(),
applyFollowOffset: (entity, offset) =>
applyShareFollowOffset(viewer, entity, offset),
});
this._visualSettings = new VisualSettings({
viewer,
mapStackController,
services: {
setScopeTerminusOverride: services.setScopeTerminusOverride,
clampScopeTerminusPct: services.clampScopeTerminusPct,
getDetectionMode: services.getDetectionMode,
getDetectionTuning: services.getDetectionTuning,
getKeyholeFadeTuning: services.getKeyholeFadeTuning,
getScopeMaskFeather: services.getScopeMaskFeather,
getScopeTerminusOverride: services.getScopeTerminusOverride,
governorRequestRender: services.governorRequestRender,
holdContinuousRender: services.holdContinuousRender,
isCelestialRingStyleSupported: services.isCelestialRingStyleSupported,
isScopeMaskEnabled: services.isScopeMaskEnabled,
readDetectionDiagnostics: services.readDetectionDiagnostics,
releaseContinuousRender: services.releaseContinuousRender,
setDetectionModeByLabel: services.setDetectionModeByLabel,
setDetectionStyle: services.setDetectionStyle,
setDetectionTuning: services.setDetectionTuning,
setKeyholeFadeTuning: services.setKeyholeFadeTuning,
setScopeMaskEnabled: services.setScopeMaskEnabled,
setScopeMaskFeather: services.setScopeMaskFeather,
},
elements: {
_ppToggles: this._ppToggles,
_sliderContainer: this._sliderContainer,
_sliderPanel: this._sliderPanel,
},
operations: {
_restorePanelState: (...args) => this._restorePanelState(...args),
_layoutRightPanels: (...args) => this._layoutRightPanels(...args),
_scheduleAdaptivePanelLayout: (...args) =>
this._scheduleAdaptivePanelLayout(...args),
_scheduleRightPanelLayout: (...args) =>
this._scheduleRightPanelLayout(...args),
_setCockpitDisclosure: (...args) => this._setCockpitDisclosure(...args),
_cockpitDisclosureOpen: (kind) => this._cockpitDisclosureOpen(kind),
_setMapStack: (...args) => this._setMapStack(...args),
_syncPanelCollapseButton: (...args) =>
this._syncPanelCollapseButton(...args),
_syncShareState: (...args) => this._syncShareState(...args),
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
},
readHud: () => this.hud,
readCockpit: () => this.cockpitView,
readDataManager: () => this._dataManager,
readShareLinks: () => this.shareLinkManager,
readCelestialRing: () => this.celestialRing,
readContextMode: () => this._contextMode,
readContextChanging: () => this._contextModeChanging,
readDisplayPortalActive: () => this._cockpitDisplayPortalActive,
});
this._layerBindings = new LayerBindings({
viewer,
services: {
imageryHost: () =>
resolveImageryHost({
viewer,
tileset: mapStackController?.getImageryHostTileset?.(),
}),
cachedGroundFloor: services.cachedGroundFloor,
warmGroundFloor: services.warmGroundFloor,
cctvLayer: services.cctvLayer,
},
readControls: () => ({
hud: this.hud,
_contextControls: this._contextControls,
_cctvControls: this._cctvControls,
_radioControls: this._radioControls,
_streetLevelControls: this._streetLevelControls,
}),
operations: {
_updateTrafficSyncChip: (...args) =>
this._updateTrafficSyncChip(...args),
_updateGlobalLoadingFeedback: (...args) =>
this._updateGlobalLoadingFeedback(...args),
_syncContextModeButtons: (...args) =>
this._syncContextModeButtons(...args),
_stampNavigation: (...args) => this._stampNavigation(...args),
_runExplicitCctvFocus: (...args) => this._runExplicitCctvFocus(...args),
_runExplicitWorldFocus: (...args) =>
this._runExplicitWorldFocus(...args),
runImmediateNavigation: (...args) =>
this.runImmediateNavigation(...args),
_showToast: (...args) => this._showToast(...args),
},
feedback: this._feedback,
shareRestoration: this._shareRestoration,
});
this._windowResizeHandler = null;
this._disposed = false;
this._mapStackChangeHandler = null;
this._cockpitDisplayModeHandler = null;
this._locationNavigation = new LocationNavigation({
viewer,
placeSearch,
navigation: this._navigation,
readCockpit: () => this.cockpitView,
services: {
CITY_POIS: services.CITY_POIS,
searchAndFlyTo: services.searchAndFlyTo,
LocationSearch: services.LocationSearch,
OrbitController: services.OrbitController,
suspendDetection: services.suspendDetection,
resumeDetection: services.resumeDetection,
trafficLayer: services.trafficLayer,
flyToPresetLocation: services.flyToPresetLocation,
flyToPOI: services.flyToPOI,
GLOBE_VIEW: services.GLOBE_VIEW,
flyToGlobeView: services.flyToGlobeView,
interruptCameraMotion: services.interruptCameraMotion,
interruptGlobeRotation: services.interruptGlobeRotation,
flightsLayer: services.flightsLayer,
militaryFlightsLayer: services.militaryFlightsLayer,
satellitesLayer: services.satellitesLayer,
aisLiveVesselsLayer: services.aisLiveVesselsLayer,
militaryAwarenessLayer: services.militaryAwarenessLayer,
rocketLaunchesLayer: services.rocketLaunchesLayer,
},
elements: {
_locationSearch: this._locationSearch,
_searchToggle: this._searchToggle,
_resetGlobeBtn: this._resetGlobeBtn,
_cockpitResetGlobeBtn: this._cockpitResetGlobeBtn,
},
operations: {
_publishLocation: (view) =>
useLocationStore(applicationPinia()).publish(view),
_connectLocation: (controls) =>
useLocationStore(applicationPinia()).connect(controls),
_beginDeferredNavigation: (...args) =>
this._beginDeferredNavigation(...args),
_reassertNavigationHandoff: (...args) =>
this._reassertNavigationHandoff(...args),
_settleLocationSearchUi: (...args) =>
this._settleLocationSearchUi(...args),
_runExplicitNavigation: (...args) =>
this._runExplicitNavigation(...args),
_stampNavigation: (...args) => this._stampNavigation(...args),
_updateTrafficSyncChip: (...args) =>
this._updateTrafficSyncChip(...args),
_showToast: (...args) => this._showToast(...args),
},
});
this._lastTrafficChipUpdateAt = 0;
// Intel HUD
this.hud = new IntelHUD(viewer, {
placeSearch,
summaryService: requestServices?.summary,
});
this._recording.hud = this.hud;
this._cockpitCoordinator = new CockpitCoordinator({
viewer,
services: {
flightsLayer: services.flightsLayer,
militaryFlightsLayer: services.militaryFlightsLayer,
isTr3b: services.isTr3b,
toggleTr3b: services.toggleTr3b,
militaryAwarenessLayer: services.militaryAwarenessLayer,
cachedGroundFloor: services.cachedGroundFloor,
cachedMeshFloor: services.cachedMeshFloor,
GROUND_FLOOR_LIFT_M: services.GROUND_FLOOR_LIFT_M,
meshFloorPreferred: services.meshFloorPreferred,
warmGroundFloor: services.warmGroundFloor,
sampleMeshFloorCells: services.sampleMeshFloorCells,
holdContinuousRender: services.holdContinuousRender,
releaseContinuousRender: services.releaseContinuousRender,
fetchRegionalBrief: services.fetchRegionalBrief,
regionalDistanceM: services.regionalDistanceM,
weatherCodeLabel: services.weatherCodeLabel,
},
elements: {
_ppToggles: this._ppToggles,
_cockpitDisplayPanel: this._cockpitDisplayPanel,
_hudBtn: this._hudBtn,
_detectionBtn: this._detectionBtn,
_sliderPanel: this._sliderPanel,
_models3dBtn: this._models3dBtn,
},
operations: {
publishCockpit: {
presence: (view) =>
useCockpitStore(applicationPinia()).publishPresence(view),
brief: (view) =>
useCockpitStore(applicationPinia()).publishBrief(view),
context: (view) =>
useCockpitStore(applicationPinia()).publishContext(view),
instruments: (view) =>
useCockpitStore(applicationPinia()).publishInstruments(view),
route: (view) =>
useCockpitStore(applicationPinia()).publishRoute(view),
vision: (view) =>
useCockpitStore(applicationPinia()).publishVision(view),
signals: (rows) =>
useCockpitStore(applicationPinia()).publishSignals(rows),
},
_layoutRightPanels: (...args) => this._layoutRightPanels(...args),
_setCockpitVision: (...args) => this._setCockpitVision(...args),
_stampNavigation: (...args) => this._stampNavigation(...args),
getAircraftTrackingTarget: (...args) =>
this.getAircraftTrackingTarget(...args),
_scheduleRightPanelLayout: (...args) =>
this._scheduleRightPanelLayout(...args),
},
readDataManager: () => this._dataManager,
readContext: () => this.getContextModeState(),
readActiveStyle: () => this.activeStyle,
readUtilityDisclosureOpen: () =>
this._cockpitDisclosureOpen('display') ||
this._cockpitDisclosureOpen('radio'),
enterPanels: () => this._panelChrome.enterCockpit(),
exitPanels: () => this._panelChrome.exitCockpit(),
});
// Full-globe sun/moon ring. It is a crisp screen-space overlay above the
// Cesium canvas but below the HUD/detection/readout z ladder.
this.celestialRing = new CelestialRing(viewer, {
enabled: false,
store: useCelestialRingStore(applicationPinia()),
onAutoDisable: () =>
this.setCelestialRingEnabled(false, {
syncShare: !!this.shareLinkManager,
focus: false,
}),
});
// Share Link Manager
this.shareLinkManager = new ShareLinkManager(viewer, {
onRestore: async (state) => {
return this._visualSettings.restoreShareState(state);
},
isNavigationCurrent: (generation) =>
generation === this._navigationGeneration,
cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(),
// A host may open a share code without a `/share/<code>` address.
initialShareCode,
});
this.shareLinkManager.setFollowSubjectProvider(({ nowMs }) =>
readShareFollowSubject({ viewer: this.viewer, nowMs }),
);
this.shareLinkManager.setPanelStateProvider(() =>
this._buildSharePanelState(),
);
this.shareLinkManager.setStyleParamStateProvider((styleName) => {
const shader = STYLES[styleName];
const stage = this.stages[styleName];
if (!shader?.uniforms || !stage) return null;
return Object.fromEntries(
Object.keys(shader.uniforms).map((uniformName) => [
uniformName,
stage.uniforms[uniformName],
]),
);
});
this._shareState = createStateChannel(() => this._readShareState());
this._shareState.subscribe(
({ state }) => {
this.shareLinkManager.onToggleChange(
state.bloomEnabled,
state.sharpenEnabled,
state.options,
);
},
{ emitCurrent: false },
);
// Parse before panel chrome initializes so every valid share URL starts
// from deterministic markup defaults instead of recipient-local panel
// preferences. Encoded panel fields are applied after all panels exist.
this._shareRestoration.attachLinks(this.shareLinkManager);
this._aircraftDisplay = new AircraftDisplay({
elements: {
_models3dBtn: this._models3dBtn,
},
readDataManager: () => this._dataManager,
layout: () => this._layoutRightPanels(),
});
// The shared world-overlay host must own its one postRender lane before
// detection and tracked-readout initialize. It stays transparent until a
// production source explicitly registers entries.
initWorldOverlay(viewer);
// Its focusable mirror of the painted cards, which the scene chrome
// renders.
const overlayActionsStore = useWorldOverlayActionsStore(applicationPinia());
overlayActionsStore.connect({ activate: activateWorldOverlayAction });
this._overlayActionsUnsubscribe = subscribeWorldOverlayActions((view) =>
overlayActionsStore.publish(view),
);
// Initialize detection overlay BEFORE style stages so the composite
// stage is first in the post-process pipeline
initDetection(
viewer,
[
trafficLayer,
flightsLayer,
militaryFlightsLayer,
localAdsbLayer,
satellitesLayer,
cctvLayer,
bikeshareLayer,
transitLayer,
aisLiveVesselsLayer,
].filter(Boolean),
(modeLabel) => {
this._updateDetectionButton(modeLabel);
},
);
initTrackedReadout(viewer);
setDetectionStyle(this.activeStyle);
this._setDetectionDensity();
this._initStages();
this._initBloomSharpen();
this._displayBindings = new DisplayBindings({
viewer,
services: {
cycleDetectionMode: services.cycleDetectionMode,
isScopeMaskEnabled: services.isScopeMaskEnabled,
},
elements: {
_locationSearch: this._locationSearch,
_bloomBtn: this._bloomBtn,
_bloomSlider: this._bloomSlider,
_sharpenBtn: this._sharpenBtn,
_sharpenSlider: this._sharpenSlider,
_scopeBtn: this._scopeBtn,
_scopeFeatherSlider: this._scopeFeatherSlider,
_hudLayoutSelect: this._hudLayoutSelect,
_hudBtn: this._hudBtn,
_cyberSonarBtn: this._cyberSonarBtn,
_cyberSonarRings: this._cyberSonarRings,
_cyberSonarRange: this._cyberSonarRange,
_cyberSonarIntensity: this._cyberSonarIntensity,
_cyberSonarOpacity: this._cyberSonarOpacity,
_cyberSonarSector: this._cyberSonarSector,
_cleanViewBtn: this._cleanViewBtn,
_cleanViewExitBtn: this._cleanViewExitBtn,
_detectionDensitySlider: this._detectionDensitySlider,
_detectionBtn: this._detectionBtn,
_detectionFadeSlider: this._detectionFadeSlider,
_detectionOpacitySlider: this._detectionOpacitySlider,
_detectionAllocationBtns: this._detectionAllocationBtns,
_celestialBtn: this._celestialBtn,
_models3dBtn: this._models3dBtn,
},
operations: {
setStyle: (...args) => this.setStyle(...args),
_updateHudButtonState: (...args) => this._updateHudButtonState(...args),
_syncShareState: (...args) => this._syncShareState(...args),
_toggleOrbit: (...args) => this._toggleOrbit(...args),
toggleCleanView: (...args) => this.toggleCleanView(...args),
_toggleCctvEnabled: (...args) => this._toggleCctvEnabled(...args),
_setBloomEnabled: (...args) => this._setBloomEnabled(...args),
_setBloomIntensity: (...args) => this._setBloomIntensity(...args),
_setSharpenEnabled: (...args) => this._setSharpenEnabled(...args),
_setSharpenIntensity: (...args) => this._setSharpenIntensity(...args),
_setHudVariant: (...args) => this._setHudVariant(...args),
_setCyberSonarEnabled: (...args) => this._setCyberSonarEnabled(...args),
_setCyberSonarSetting: (...args) => this._setCyberSonarSetting(...args),
_setDetectionDensity: (...args) => this._setDetectionDensity(...args),
_setDetectionAllocation: (...args) =>
this._setDetectionAllocation(...args),
_setDetectionFade: (...args) => this._setDetectionFade(...args),
_setScope: (...args) => this._visualSettings._setScope(...args),
setCelestialRingEnabled: (...args) =>
this.setCelestialRingEnabled(...args),
_setModels3dEnabled: (...args) => this._setModels3dEnabled(...args),
_syncModels3dModeRow: (...args) => this._syncModels3dModeRow(...args),
_setModels3dMode: (...args) => this._setModels3dMode(...args),
},
readState: () => ({
shareLinkManager: this.shareLinkManager,
hud: this.hud,
bloomEnabled: this.bloomEnabled,
sharpenEnabled: this.sharpenEnabled,
celestialRing: this.celestialRing,
celestialRingEnabled: this.celestialRingEnabled,
_models3dEnabled: this._models3dEnabled,
_models3dModeBtns: this._models3dModeBtns,
}),
claimDetection: () => {
this._visualSettings._detectionUserOverridden = true;
},
});
this._initUI();
this._initMapStackControl();
// Headers first: panel chrome builds its toggle labels from them.
this._applyRuntimeStaticHeaderText();
this._initPanelChrome();
this._panelNavigation = new PanelNavigation({
ui: this._ui,
stacks: { left: this._leftPanelStack, right: this._rightPanelStack },
setPanelCollapsed: (...args) =>
this._panelChrome.setPanelCollapsed(...args),
});
this._panelNavigation.init();
this._initLocaleSelector();
this._sizeDisplayLabelColumn();
this._initLeftPanelAdaptiveLayout();
this._initRightPanelAdaptiveLayout();
this._initRadioPanel();
this._initCctvPanel();
this._initStreetLevelPanel();
this._initGlobalContextPanel();
this._initLocationBar();
this._initMobileShell();
this._initShareButton();
this._initCameraOrientationControls();
this._initClearSelectedLayersButton();
this._initHUDToggle();
this._initModels3dToggle();
this._applyGlobalPostDefaults();
this._initOrbit();
this._initRecordingOverlay();
this._startAnimationLoop();
this._startTrafficChipTicker();
this._publishActiveStyle();
this._updateLocationMiniStatus();
this._shareRestoration.start();
// Keep the parameter panel from overlapping toggle controls.
this._layoutRightPanels();
this._syncCctvPanelViewport();
this._windowResizeHandler = () => {
this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true });
this._syncCctvPanelViewport();
this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true });
};
window.addEventListener('resize', this._windowResizeHandler);
// The loading-chip ticker is stopped while the tab is hidden (it can do no
// useful work off-screen and must not hold a 60ms timer there). Resample on
// return so the time-driven reducer catches up on real elapsed time — and
// re-arms its own ticker if the batch is still running.
this._feedback.observeVisibility();
this._layerBindings.observeCamera();
}
// Compatibility reads for existing controls, scene snapshots and Cockpit.
/** Advance camera authority and settle any older search UI immediately. */
_stampNavigation({
cancelPendingSelection = true,
clearSearchedLocation = true,
} = {}) {
return this._navigation._stampNavigation(...arguments);
}
/** Release every follow owner while preserving Contact and vessel selection. */
_releaseFollowCamera({
preserveVesselSelection = true,
preserveCameraFlight = false,
trackingOrigin = 'tool',
} = {}) {
return this._navigation._releaseFollowCamera(...arguments);
}
/** Accept a delayed lookup without releasing its current camera owner. */
_beginDeferredNavigation(
noun = 'location',
{ cancelPendingSelection = true } = {},
) {
return this._navigation._beginDeferredNavigation(...arguments);
}
/** Public lifecycle seam used by voice location navigation. */
beginDeferredLocationNavigation() {
return this._beginDeferredNavigation('location');
}
/** Public final-authority seam used by voice geocoding. */
reassertDeferredLocationNavigation(generation) {
return this._reassertNavigationHandoff(generation);
}
/** Public immediate route used by voice destinations. */
runImmediateLocationNavigation(navigate) {
return this.runImmediateNavigation('location', navigate);
}
/** Public authority facade used by validated voice camera destinations. */
runImmediateNavigation(noun, navigate, releaseOptions = undefined) {
return this._runExplicitNavigation(noun, navigate, releaseOptions);
}
/** Supersede deferred work when an owner-specific route handles release. */
supersedeDeferredNavigation() {
return this._stampNavigation();
}
/** Route a valid vessel/fire request through the shared navigation policy. */
_runExplicitWorldFocus(detail, fly) {
return this._runExplicitNavigation(detail?.kind || 'target', fly);
}
/** Return the aircraft tracker owned before a multi-step Cockpit transaction. */
getAircraftTrackingTarget() {
return aircraftTrackingTarget(this.cockpitView?.readAircraftInfo?.());
}
/** Apply a temporary cockpit-only CRT/NVG/FLIR/NOIR post-process override. */
_setCockpitVision(mode, active, { revealParameters = false } = {}) {
return this._visualSettings._setCockpitVision(...arguments);
}
/** Reveal shared style parameters, optionally opening Cockpit Display first. */
_revealCockpitStyleParameters({ openDisplay = false } = {}) {
return this._visualSettings._revealCockpitStyleParameters(...arguments);
}
/**
* Sets the bloom intensity, updates the slider UI, and applies the value.
* @param {number} intensity - Raw intensity percentage.
* @param {object} [options]
* @param {boolean} [options.syncShare=true] - Whether to push state to the share link.
* @returns {void}
*/
_setBloomIntensity(intensity, { syncShare = true } = {}) {
return this._visualSettings._setBloomIntensity(...arguments);
}
/**
* Wires up all primary UI event listeners: style buttons, keyboard shortcuts
* (1-8 style keys, H/O/V/F/D/C hotkeys, Escape), AI prompt input with
* debounce, bloom/sharpen/HUD toggles, detection density slider, and
* clean-view toggle.
* @returns {void}
*/
_initUI() {
this._displayBindings._initUI();
}
/**
* Publishes the language picker, which ProviderSettingsView renders. A
* choice reloads the page; _confirmLocaleReload asks first when that would
* lose work.
* @returns {void}
*/
_initLocaleSelector() {
// Work this shell can see that a reload drops. Other modules register
// their own (registerUnsavedWork in src/i18n/index.js).
this._unsavedWorkChecks = [
registerUnsavedWork(() =>
this.services?.cctvLayer
?.getUIState?.()
?.cameras?.some((camera) => camera.calDirty)
? msg('unsaved CCTV calibration')
: null,
),
registerUnsavedWork(() =>
this.cockpitView?.active ? msg('the open cockpit') : null,
),
];
// A host that does not initialize Core's locale shows no picker.
if (this._localePickerEnabled === false) return;
useLocaleStore(applicationPinia()).publishPicker({
current: getLocale(),
onSelect: (locale) => this._chooseLocale(locale),
});
}
/**
* Switch to `locale`: nothing for the active language, otherwise store it
* and reload once _confirmLocaleReload agrees.
* @param {string} locale
* @returns {'unchanged'|'declined'|'switched'}
*/
_chooseLocale(locale) {
return applyLocaleChoice(locale, {
current: getLocale(),
confirmSwitch: () => this._confirmLocaleReload(),
switchLocale: (next) => persistLocaleAndReload(next),
});
}
/**
* Switching language reloads the page. While that would lose work the page
* cannot restore, ask first.
* @returns {boolean} true to switch.
*/
_confirmLocaleReload() {
const lost = describeUnsavedWork();
if (!lost.length || typeof globalThis.confirm !== 'function') return true;
return globalThis.confirm(
t(
'Switching language reloads the page. This will be lost: {items}. Switch anyway?',
{ items: lost.join(', ') },
),
);
}
/**
* Sizes one Display-panel label column to the longest label in the active
* language, so slider tracks line up. Counts characters (labels are
* monospace and often hidden); CSS turns the count into a width.
* @returns {void}
*/
_sizeDisplayLabelColumn() {
const labels = document.querySelectorAll(
'#pp-toggles .pp-slider-row > .pp-slider-mini-label',
);
let longest = 0;
for (const label of labels) {
longest = Math.max(longest, label.textContent.trim().length);
}
if (longest > 0) {
document.documentElement.style.setProperty(
'--pp-label-chars',
String(longest),
);
}
}
/**
* Rewrites a header's last text node, keeping the leading icon span that a
* textContent write would destroy.
* @param {Element|null} element
* @param {string} text
* @returns {void}
*/
_setHeaderText(element, text) {
if (!element) return;
const textNode = Array.from(element.childNodes)
.reverse()
.find(
(node) => node.nodeType === Node.TEXT_NODE && node.nodeValue?.trim(),
);
if (textNode) textNode.nodeValue = text;
else element.textContent = text;
}
/**
* Translates at boot the header texts that share their element with an
* icon or a <br> (data-i18n-runtime in the templates).
* @returns {void}
*/
_applyRuntimeStaticHeaderText() {
this._setHeaderText(
document.querySelector('#control-panel .panel-title'),
t('VISUAL PRESETS'),
);
this._setHeaderText(
document.querySelector('#location-bar .location-toolbar-label'),
t('LOCATION'),
);
// One span holds both mode descriptions around a <br>; keep the <br>.
const standbyDescription = document.querySelector(
'#context-mode-standby span',
);
if (standbyDescription) {
standbyDescription.replaceChildren(
document.createTextNode(
t('CONTACTS — nearest planes · vessels · sites'),
),
document.createElement('br'),
document.createTextNode(
t('SPACE MISSIONS — launches & orbital assets'),
),
);
}
}
/**
* Phone layout: a bottom tab bar presenting one panel sheet at a time
* (src/ui/mobileShell.js). Inactive above 720px.
* @returns {void}
*/
_initMobileShell() {
this._mobileShell = initMobileShell({
setPanelCollapsed: (panelId, collapsed, options) =>
this._panelChrome.setPanelCollapsed(panelId, collapsed, options),
onSheetsClosed: () => this._syncShareState(),
onTabOpened: (sheet) => {
void this._onMobileTabOpened(sheet);
},
publish: (view) =>
useDisplayStore(applicationPinia()).publishPhoneShell(view),
afterPublish: (callback) => {
void nextTick(callback);
},
});
}
/** Reveal a tracked aircraft through Contacts when its phone tab is opened. */
async _onMobileTabOpened(sheet) {
const context = this.getContextModeState();
if (
sheet !== 'context' ||
this._disposed ||
!context ||
context.mode ||
context.changing ||
context.entering ||
!this.getAircraftTrackingTarget()
)
return false;
try {
const result = await this.setContextMode('contacts');
if (result?.ok) return true;
if (result?.cancelled || this._disposed) return false;
} catch {
if (this._disposed) return false;
}
this._showToast(
t('Contacts could not complete the requested transition; try again'),
);
return false;
}
/**
* Renders the owner-approved map stack chip row from the matching controller
* entries. Cesium ion/Bing chips remain keyboard-focusable but unavailable,
* with an accessible explanation, until a CESIUM_ION_TOKEN is configured.
* @returns {void}
*/
_initMapStackControl() {
if (!this.mapStackController) return;
const displayStore = useDisplayStore(applicationPinia());
this._mapSourceControls?.destroy();
displayStore.publishMapSource(null);
this._mapSourceControls = createMapSourceControls({
controller: this.mapStackController,
publish: (view) => displayStore.publishMapSource(view),
subscribe: (onChange) => {
window.addEventListener('gev:map-stack-changed', onChange);
return () =>
window.removeEventListener('gev:map-stack-changed', onChange);
},
claimSelection: () => this.shareLinkManager?.claimRestoreLane?.('map'),
onStateChanged: () => this._syncShareState(),
onError: (message) => this._showToast(message),
});
displayStore.connectMapSource(this._mapSourceControls);
}
/**
* Switches the active map/globe source stack.
* @param {string} stackId - Map stack id.
* @param {object} [options]
* @param {boolean} [options.syncShare=true] - Whether to update the share link.
* @returns {Promise<void>}
*/
async _setMapStack(stackId, { syncShare = true } = {}) {
if (!this.mapStackController) return;
return this._mapSourceControls.select(stackId, { syncShare });
}
/**
* Syncs the map stack chip row and status chip with controller state. The
* lit chip always follows `state.activeId`, never the click — a rejected or
* superseded switch therefore leaves the genuinely active stack lit.
* @param {object} state - Map stack controller state.
* @returns {void}
*/
_renderMapStackState(state) {
this._mapSourceControls?.render(state);
}
_setDetectionAllocation(strategy, { syncShare = true, persist = true } = {}) {
return this._visualSettings._setDetectionAllocation(...arguments);
}
/**
* Pushes the current visual state (bloom, sharpen, HUD, detection) to
* the ShareLinkManager so the URL hash stays in sync.
* @returns {void}
*/
_syncShareState() {
if (this._disposed) return;
this._shareState.publish({ type: 'settings-changed' });
}
_setCommandDockPanelPinState(
panelId,
pin,
{ restore = false, persist = true, syncShare = true } = {},
) {
return this._panelChrome._setCommandDockPanelPinState(...arguments);
}
/**
* Configures intentional hover-expand / leave-collapse behavior on a panel.
* Uses separate open/close timers to prevent accidental flicker from fast
* mouse passes. Wheel events cancel pending opens to avoid surprise expansion
* during scroll-through.
* @param {string} panelId - DOM id of the panel element.
* @param {object} [options]
* @param {number} [options.openDelayMs=850] - Hover dwell time before auto-expanding.
* @param {number} [options.closeDelayMs=1000] - Delay after pointer leaves before collapsing.
* @returns {void}
*/
_initAutoHoverPanel(
panelId,
{ openDelayMs = 850, closeDelayMs = 1000 } = {},
) {
return this._panelChrome._initAutoHoverPanel(...arguments);
}
_initGlobalContextPanel() {
const {
radioLayer,
militaryInstallationsLayer,
militaryAwarenessLayer,
rocketLaunchesLayer,
} = this.services;
const contextStore = useContextStore(applicationPinia());
this._contextControls = new ContextControls({
elements: {
_globalContextFlightsBtn: this._globalContextFlightsBtn,
_globalContextMissionsBtn: this._globalContextMissionsBtn,
_installationsSearchBtn: this._installationsSearchBtn,
},
installations: militaryInstallationsLayer,
publish: (view) => contextStore.publish(view),
actions: {
getCockpit: () => this.cockpitView,
refreshRadio: () => this._renderRadioState(radioLayer.getUIState()),
claimVisualAuthority: () =>
this.shareLinkManager?.claimRestoreLane?.('visual'),
syncDetection: () => this._syncContactsDetection(),
scheduleLayout: () => this._scheduleRightPanelLayout(),
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
showToast: (message) => {
if (!this._disposed) this._showToast(message);
},
setClearBusy: (busy) => {
if (!this._disposed) this._clearLayersControl?.setBusy(busy);
},
},
});
// The Contacts layer publishes its panel; the view calls its navigation.
contextStore.connectAwareness(militaryAwarenessLayer);
// A panel left by an earlier shell must not outlive its layer.
this._awarenessPanelUnsubscribe?.();
contextStore.publishAwareness(null);
this._awarenessPanelUnsubscribe = militaryAwarenessLayer?.subscribePanel?.(
(view) => contextStore.publishAwareness(view),
);
// The Space Missions layer publishes its roster and selected mission.
contextStore.connectMissions(rocketLaunchesLayer);
this._missionsPanelUnsubscribe?.();
contextStore.publishMissions(null);
this._missionsPanelUnsubscribe = rocketLaunchesLayer?.subscribePanel?.(
(view) => contextStore.publishMissions(view),
);
// And its replay vehicle overlay, which the scene chrome renders.
const missionReplayStore = useMissionReplayStore(applicationPinia());
this._missionReplayUnsubscribe?.();
missionReplayStore.publish(null);
this._missionReplayUnsubscribe =
rocketLaunchesLayer?.subscribeReplayOverlay?.((view) =>
missionReplayStore.publish(view),
);
}
/** Wire the independent Radio companion controls. */
_initRadioPanel() {
const { radioLayer } = this.services;
this._radioControls?.destroy();
this._radioControls = new RadioControls({
elements: {
_cockpitDisplayToggleBtn: this._cockpitDisplayToggleBtn,
_cockpitRadioEnableBtn: this._cockpitRadioEnableBtn,
_cockpitRadioNextBtn: this._cockpitRadioNextBtn,
_cockpitRadioPlayBtn: this._cockpitRadioPlayBtn,
_cockpitRadioPrevBtn: this._cockpitRadioPrevBtn,
_cockpitRadioToggleBtn: this._cockpitRadioToggleBtn,
_cockpitRadioVolume: this._cockpitRadioVolume,
_cockpitUtilityControls: this._cockpitUtilityControls,
_radioEnableBtn: this._radioEnableBtn,
_radioFilter: this._radioFilter,
_radioLayerState: this._radioLayerState,
_radioNextBtn: this._radioNextBtn,
_radioPanel: this._radioPanel,
_radioPlayBtn: this._radioPlayBtn,
_radioPlaybackState: this._radioPlaybackState,
_radioPrevBtn: this._radioPrevBtn,
_radioStationHomepage: this._radioStationHomepage,
_radioStationMeta: this._radioStationMeta,
_radioStationName: this._radioStationName,
_radioStationTags: this._radioStationTags,
_radioStopBtn: this._radioStopBtn,
_radioTunerSlider: this._radioTunerSlider,
_radioVolume: this._radioVolume,
_radioVolumeValue: this._radioVolumeValue,
},
radio: radioLayer,
canvas: this.viewer?.canvas,
actions: {
isRegistered: () => this._dataManager?.layers?.has('radio'),
isEnabled: () => this._dataManager?.isEnabled('radio'),
setEnabled: (enabled, options) =>
this._dataManager.setEnabled('radio', enabled, options),
setParams: (params, options) =>
this._dataManager?.setLayerParams('radio', params, options),
getLifecycle: () =>
this._dataManager?.getLayerLifecycleState?.('radio'),
runUserAction: (...args) => this._runUserFacingContextAction(...args),
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
revealStyleParameters: () => this._revealCockpitStyleParameters(),
setSignalCollapsed: (value) =>
this.cockpitView?.setSignalCollapsed(value),
isCockpitActive: () => this.cockpitView?.active,
signalUserCollapsed: () => this.cockpitView?.signalUserCollapsed,
layoutCockpit: () => this.cockpitView?.scheduleContextLayout(),
// An open local receiver keeps the shared Radio panel expanded.
preservePanelStateDuringClear: () =>
this._preservePanelStateDuringLayerClear ||
Boolean(this._localSdrControls?.isActive()),
scheduleLayout: () => this._scheduleRightPanelLayout(),
},
});
this._localSdrControls?.destroy();
this._localSdrControls = null;
const receiver = this.services.localAdsbLayer?.receiver;
if (receiver) {
this._localSdrControls = new LocalSdrControls({
document,
receiver,
feeds: this.services.localAdsbLayer?.feeds || null,
radio: radioLayer,
actions: {
isLocalAdsbEnabled: () =>
Boolean(this._dataManager?.isEnabled('local-adsb')),
setLocalAdsbEnabled: (enabled) =>
this._dataManager?.setEnabled('local-adsb', enabled, {
origin: 'user',
}),
scheduleLayout: () => this._scheduleRightPanelLayout(),
},
});
}
}
/**
* Activates an explicit CCTV target, then releases tracking before its camera
* flight. Cockpit mode keeps tracking and suppresses only the flight.
* @param {Function} activate CCTV target activation returning its camera ID.
* @param {Function} focus CCTV camera flight receiving the activated ID.
* @returns {*} Focus operation result.
*/
_runExplicitCctvFocus(activate, focus) {
if (this._disposed) return false;
const cameraId = activate();
if (!cameraId) return false;
return this._runExplicitNavigation('camera', () => focus(cameraId));
}
/** Compose the Street Level panel controls from its layer and application actions. */
_initStreetLevelPanel() {
const { streetLevelLayer } = this.services;
this._streetLevelControls?.destroy();
this._streetLevelControls = null;
if (!this._streetLevelPanel || !streetLevelLayer) return;
// Photo framing yields to newer navigation and releases tracking like
// any deferred flight.
streetLevelLayer.attachNavigation?.({
begin: (noun) => this._beginDeferredNavigation(noun),
reassert: (generation) => this._reassertNavigationHandoff(generation),
});
this._streetLevelControls = new StreetLevelControls({
root: this._streetLevelPanel,
layer: streetLevelLayer,
actions: {
isEnabled: () => this._dataManager?.isEnabled('street-level') === true,
setEnabled: (enabled) =>
this._dataManager?.setEnabled('street-level', enabled, {
origin: 'user',
}),
setParams: (params, options) =>
this._dataManager?.setLayerParams('street-level', params, options),
setPanelCollapsed: (collapsed, options) =>
this.setPanelCollapsed('street-level-panel', collapsed, options),
showToast: (message) => this._showToast(message),
// Lets the panel open for a user, voice or tool switch-on, not a restore.
subscribeEnableRequests: (listener) =>
this._dataManager?.subscribeVisibilityRequests?.((change) => {
if (change?.layerId === 'street-level' && change.enabled)
listener(change.origin);
}) || null,
},
});
this._streetLevelControls.connect();
}
/** Compose camera panel controls from the existing camera port and application actions. */
_initCctvPanel() {
const { cctvLayer } = this.services;
this._cctvControls?.destroy();
this._cctvControls = new CctvControls({
elements: {
_cctvAdjustBtn: this._cctvAdjustBtn,
_cctvAutoHopBtn: this._cctvAutoHopBtn,
_cctvCalReadout: this._cctvCalReadout,
_cctvCalibResetBtn: this._cctvCalibResetBtn,
_cctvCalibSaveBtn: this._cctvCalibSaveBtn,
_cctvCoverageBtn: this._cctvCoverageBtn,
_cctvEnableBtn: this._cctvEnableBtn,
_cctvFocusBtn: this._cctvFocusBtn,
_cctvFrame: this._cctvFrame,
_cctvFrameWrap: this._cctvFrameWrap,
_cctvVideo: this._cctvVideo,
_cctvMeta: this._cctvMeta,
_cctvNearestBtn: this._cctvNearestBtn,
_cctvNextBtn: this._cctvNextBtn,
_cctvPanel: this._cctvPanel,
_cctvPrevBtn: this._cctvPrevBtn,
_cctvProjectionBtn: this._cctvProjectionBtn,
_cctvQualityChip: this._cctvQualityChip,
_cctvSelect: this._cctvSelect,
_cctvSourceBadge: this._cctvSourceBadge,
_cctvSummary: this._cctvSummary,
},
cctv: cctvLayer,
actions: {
isEnabled: () => this._dataManager?.isEnabled('cctv'),
setParams: (params, options) =>
this._dataManager?.setLayerParams('cctv', params, options),
toggleEnabled: (...args) => this._toggleCctvEnabled(...args),
runExplicitFocus: (...args) => this._runExplicitCctvFocus(...args),
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
showToast: (message) => this._showToast(message),
syncViewport: () => this._syncCctvPanelViewport(),
},
});
}
/**
* Toggles the CCTV layer enabled state. When enabling and no camera is active,
* auto-focuses on the nearest camera.
* @param {boolean} [forceState] - Explicit on/off. Omit to toggle.
* @returns {Promise<boolean>} True if the layer is now in the requested state.
*/
async _toggleCctvEnabled(forceState) {
const { cctvLayer } = this.services;
if (this._disposed) return false;
if (!this._dataManager || !this._dataManager.layers?.has('cctv')) {
this._showToast(t('CCTV layer unavailable'));
return false;
}
const enabled = this._dataManager.isEnabled('cctv');
const target = typeof forceState === 'boolean' ? forceState : !enabled;
if (target === enabled) return true;
await runCctvLayerEnableTransition({
target,
setEnabled: (next) =>
this._dataManager.setEnabled('cctv', next, { origin: 'user' }),
readOwnership: () => ({
trackedEntity: this.viewer?.trackedEntity,
cockpitActive: !!this.cockpitView?.active,
}),
shouldFocus: () =>
!this._disposed &&
this._dataManager.isEnabled('cctv') &&
!this._cctvControls?.getState()?.activeCameraId,
activate: () => cctvLayer.focusNearest({ focus: false }),
fly: (cameraId) =>
this._runExplicitCctvFocus(
() => cameraId,
(selectedId) => cctvLayer.focusCamera(selectedId, 1.6),
),
});
return true;
}
/**
* Programmatically collapses or expands a panel, persists the state,
* and triggers layout recalculation for dependent panels.
* @param {string} panelId - DOM id of the panel.
* @param {boolean} collapsed - Whether to collapse the panel.
* @param {object} [options] Disclosure ownership options.
* @param {boolean} [options.explicit=false] Whether a direct user action owns the panel lane.
* @returns {void}
*/
setPanelCollapsed(
panelId,
collapsed,
{
explicit = false,
restore = false,
persist = true,
syncShare = true,
} = {},
) {
return this._panelChrome.setPanelCollapsed(...arguments);
}
/**
* Toggles "clean view" mode which hides all UI panels via a CSS body class.
* @param {boolean} [forceEnabled] - Explicit on/off. Omit to toggle.
* @returns {void}
*/
toggleCleanView(forceEnabled) {
const shouldEnable =
typeof forceEnabled === 'boolean'
? forceEnabled
: !document.body.classList.contains('ui-clean-view');
document.body.classList.toggle('ui-clean-view', shouldEnable);
if (this._cleanViewBtn) {
this._cleanViewBtn.classList.toggle('active', shouldEnable);
}
this._scheduleLeftPanelLayout();
}
// ── Public control facade ──────────────────────────────────────────────
// Deliberate API for voice tools and scripting. Every setter keeps the DOM
// sliders, share-link state, and scene snapshots in sync, and returns
// { ok, ...resultingState } so callers confirm only what actually happened.
/**
* Sets HUD visibility mode. 'auto' restores style-driven show/hide.
* @param {'on'|'off'|'auto'} mode - Visibility mode.
* @returns {{ok: boolean, visible?: boolean, layout?: string, error?: string}}
*/
setHudVisible(mode) {
const normalized = String(mode ?? '').toLowerCase();
if (!['on', 'off', 'auto'].includes(normalized)) {
return { ok: false, error: `Unknown HUD visibility mode: ${mode}` };
}
this.shareLinkManager?.claimRestoreLane?.('visual');
this.hud.setMode(normalized);
this._updateHudButtonState();
this._syncShareState();
return {
ok: true,
visible: !!this.hud.visible,
mode: normalized,
layout: this.hud.getVariant(),
};
}
/**
* Switches the HUD layout variant.
* @param {'tactical'|'minimal'|'cyber'} variantName - Layout variant.
* @returns {{ok: boolean, layout?: string, visible?: boolean, error?: string}}
*/
setHudLayout(variantName) {
const variant = String(variantName ?? '').toLowerCase();
if (!isHudLayout(variant)) {
return { ok: false, error: `Unknown HUD layout: ${variantName}` };
}
this.shareLinkManager?.claimRestoreLane?.('visual');
this._setHudVariant(variant, { applyVisualDefaults: true });
return {
ok: true,
layout: this.hud.getVariant(),
visible: !!this.hud.visible,
};
}
/**
* Controls the detection overlay: on/off, mode, and density percent.
* Density writes the slider AND the engine so share links and scene
* snapshots stay truthful.
* @param {object} [options]
* @param {boolean} [options.enabled] - false forces OFF; true restores the current density profile.
* @param {'sparse'|'balanced'|'dense'|'panoptic'} [options.mode] - Profile (legacy aliases accepted).
* @param {number} [options.densityPct] - 0-100 density percent.
* @param {'elastic'|'weighted'} [options.allocationStrategy] - Layer-capacity policy.
* @param {number} [options.fadePct] - Fade distance as 0-40% of the keyhole radius.
* @param {number} [options.outsideOpacityPct] - Opacity beyond the fade distance, 0-100%.
* @returns {{ok: boolean, detectionMode?: string, densityPct?: number|null, error?: string}}
*/
setDetection({
enabled,
mode,
densityPct,
allocationStrategy,
fadePct,
outsideOpacityPct,
} = {}) {
return this._visualSettings.setDetection(...arguments);
}
/**
* Switches the basemap stack and reports whether the switch landed.
* @param {string} stackId - One of mapStackController.getStacks() ids.
* @returns {Promise<{ok: boolean, activeStack?: string, error?: string|null, available?: string[]}>}
*/
async setMapStack(stackId) {
if (!this.mapStackController) {
return { ok: false, error: 'Map stack controller unavailable' };
}
const stacks = this.mapStackController.getStacks();
const target = stacks.find((stack) => stack.id === stackId);
if (!target) {
return {
ok: false,
error: `Unknown map stack: ${stackId}`,
available: stacks.map((s) => s.id),
};
}
if (!target.available) {
return {
ok: false,
error: `${target.label} requires a Cesium ion token`,
activeStack: this.mapStackController.getActiveId(),
};
}
await this._setMapStack(stackId);
const state = this.mapStackController.getState();
const landed = state.activeId === stackId;
return {
ok: landed,
activeStack: state.activeId,
error: landed ? null : state.lastError || 'Map stack did not switch',
};
}
/**
* Controls bloom post-processing. Intensity is the UI percent (0-200).
* @param {object} [options]
* @param {boolean} [options.enabled]
* @param {number} [options.intensityPct] - 0-200.
* @returns {{ok: boolean, bloom: {enabled: boolean, intensityPct: number|null}}}
*/
setBloom({ enabled, intensityPct } = {}) {
return this._visualSettings.setBloom(...arguments);
}
/**
* Controls sharpen post-processing. Intensity is the UI percent (0-100).
* @param {object} [options]
* @param {boolean} [options.enabled]
* @param {number} [options.intensityPct] - 0-100.
* @returns {{ok: boolean, sharpen: {enabled: boolean, intensityPct: number|null}}}
*/
setSharpen({ enabled, intensityPct } = {}) {
return this._visualSettings.setSharpen(...arguments);
}
/**
* Controls the celestial ring. The Display button uses `focus=true` when the
* ring is disabled or unavailable at the current zoom, turning the control
* into a reveal action instead of requiring a separate globe-navigation step.
* @param {boolean} enabled
* @param {object} [options]
* @param {boolean} [options.syncShare=true]
* @param {boolean} [options.focus=false]
* @returns {{ok:boolean, celestialRing:{enabled:boolean,visible:boolean}, cameraFocused:boolean, error?:string}}
*/
setCelestialRingEnabled(enabled, { syncShare = true, focus = false } = {}) {
return this._visualSettings.setCelestialRingEnabled(...arguments);
}
/**
* Enables/disables clean view (hides all UI chrome).
* @param {boolean} [enabled] - Omit to toggle.
* @returns {{ok: boolean, cleanView: boolean}}
*/
setCleanView(enabled) {
this.toggleCleanView(enabled);
return {
ok: true,
cleanView: document.body.classList.contains('ui-clean-view'),
};
}
/** Apply Cyber-only sonar settings through the shared visual controls. */
setCyberSonar(settings) {
return setCyberSonarControls(this, settings);
}
/**
* Full control-state snapshot — single source for voice read-back so the
* agent confirms from the same state it acted on.
* @returns {object} Current style/stack/HUD/detection/post-processing state.
*/
getControlState() {
return {
style: this.activeStyle || 'normal',
mapStack: this.mapStackController?.getActiveId?.() || null,
hud: {
visible: !!this.hud?.visible,
layout: this.hud?.getVariant?.() || null,
sonar: getCyberSonarControlState(),
},
detection: this.getDetectionState(),
bloom: {
enabled: !!this.bloomEnabled,
intensityPct: this._visualSettings.bloomIntensityPct,
},
sharpen: {
enabled: !!this.sharpenEnabled,
intensityPct: this._visualSettings.sharpenIntensityPct,
},
celestialRing: {
enabled: this.celestialRingEnabled,
visible: !!this.celestialRing?.visible,
},
orbiting: !!this.orbitController?.active,
models3d: {
enabled: !!this._models3dEnabled,
mode: this._models3dMode || 'proximity',
},
recording: !!this._recording._recordingMode,
cleanView: document.body.classList.contains('ui-clean-view'),
};
}
/**
* Captures the current camera position and orientation as a serializable object.
* @returns {{lat: number, lon: number, alt: number, heading: number, pitch: number, roll: number}|null}
*/
getCameraState() {
const carto = this.viewer.camera.positionCartographic;
if (!carto) return null;
return {
lat: Cesium.Math.toDegrees(carto.latitude),
lon: Cesium.Math.toDegrees(carto.longitude),
alt: carto.height,
heading: Cesium.Math.toDegrees(this.viewer.camera.heading),
pitch: Cesium.Math.toDegrees(this.viewer.camera.pitch),
roll: Cesium.Math.toDegrees(this.viewer.camera.roll),
};
}
/**
* Flies the camera to a previously captured camera state using cubic ease-in-out.
* @param {{lat: number, lon: number, alt: number, heading?: number, pitch?: number, roll?: number}} cameraState
* @param {number} [duration=2.8] - Flight duration in seconds.
* @returns {void}
*/
applyCameraState(cameraState, duration = 2.8) {
if (!cameraState) return;
this.viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(
cameraState.lon,
cameraState.lat,
cameraState.alt,
),
orientation: {
heading: Cesium.Math.toRadians(cameraState.heading || 0),
pitch: Cesium.Math.toRadians(cameraState.pitch || -35),
roll: Cesium.Math.toRadians(cameraState.roll || 0),
},
duration: Math.max(0.2, duration || 0),
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
});
}
/**
* Restores a full visual state snapshot, applying style, bloom, sharpen,
* HUD, detection, and per-style shader uniforms. Used by scene recipes
* and share-link restore. Async so the map-stack switch resolves before
* the share state is synced; callers may fire-and-forget.
* @param {object} [state={}] - Visual state object (as returned by getVisualState).
* @param {object} [options]
* @param {(() => boolean)|null} [options.isCurrent] Caller liveness predicate.
* The map-stack switch is this method's ONLY suspension point, and the
* shader-uniform writes come after it — so a caller superseded while that
* switch is in flight would otherwise resume and commit the look of a
* state the operator has already moved past. Scene playback reproduced
* exactly that: a stale shot's uniforms landing on top of the live run.
* Omit it and the method behaves as it always has.
* @returns {Promise<boolean>} Whether the state was committed.
*/
async applyVisualState(state = {}, { isCurrent = null } = {}) {
return this._visualSettings.applyVisualState(...arguments);
}
/**
* Applies recording-friendly post-processing and shader uniform overrides.
* @param {object} preset
*/
applyCinematicPreset(preset = {}) {
return this._visualSettings.applyCinematicPreset(...arguments);
}
// ── Parameter Sliders ─────────────────────────
/**
* Rebuilds the parameter slider panel for the given style's shader uniforms.
* Creates a labeled range input for each tunable uniform. Hides the panel
* for 'normal' mode which has no shader parameters.
* @param {string} styleName - Style name whose uniforms to display.
* @returns {void}
*/
_updateSliderPanel(styleName, { reveal = false } = {}) {
return this._visualSettings._updateSliderPanel(...arguments);
}
// ── Style switching ───────────────────────────
/**
* Switches the active visual style. Handles full lifecycle:
* 1. Crossfades the previous shader stage intensity to 0.
* 2. Crossfades the new shader stage intensity to 1.
* 3. Applies style preset defaults (bloom/sharpen/HUD) if applyPreset is true.
* 4. Updates button highlights, style indicator, slider panel, HUD, and detection overlay.
* @param {string} styleName - Target style ('normal'|'retro'|'surveillance'|'thermal'|'anime'|'noir').
* @param {object} [options]
* @param {boolean} [options.applyPreset=true] - Whether to apply STYLE_PRESET_DEFAULTS for the new style.
* @param {boolean} [options.userInitiated=false] - Whether a direct user preset choice owns the Cyber exit style.
* @param {boolean} [options.preserveCyberRestore=false] - Whether an automatic style update keeps the Cyber entry snapshot.
* @returns {void}
*/
setStyle(
styleName,
{
applyPreset = true,
revealParameters = applyPreset,
restore = false,
} = {},
) {
return this._visualSettings.setStyle(...arguments);
}
// ── Shader transitions ────────────────────────
/**
* Style animation loop — self-stopping (perf wave 2). Runs only while a
* crossfade is in flight or an animated (time-uniform) stage is visible,
* holding continuous scene render for exactly that long. Re-armed by
* _startTransition and by _setStageIntensity enabling an animated stage.
* The traffic sync chip no longer rides this loop — it has its own 500 ms
* interval (see _startTrafficChipTicker).
*/
_startAnimationLoop() {
this._visualEffects.startAnimationLoop();
}
/**
* Release camera ownership when a resolved Location destination starts.
* Contact mode and its selected subject remain intact so FOCUS can return to
* that subject after the user finishes inspecting the destination.
* @returns {boolean} Whether a Contact subject remains selected.
*/
beginLocationNavigation() {
this._stampNavigation();
this.cockpitView?.exit({ restoreTracking: false });
return this._releaseFollowCamera({ preserveVesselSelection: false });
}
/** Wire the top-center action that clears only manager-owned data layers. */
_initClearSelectedLayersButton() {
if (!this._clearSelectedLayersBtn) return;
this._clearLayersControl?.destroy();
this._clearLayersControl = bindClearLayersControl(
this._clearSelectedLayersBtn,
() => this.clearSelectedLayers(),
);
}
/** Wire Google Maps-style tilt and north-up camera actions. */
_initCameraOrientationControls() {
this._cameraOrientationControls?.destroy();
this._cameraOrientationControls = bindCameraOrientationControls({
viewer: this.viewer,
elements: {
tiltButton: this._tiltMapBtn,
northButton: this._northUpBtn,
},
runNavigation: (noun, navigate) =>
this._navigation.runOrientation(noun, navigate),
showToast: (message) => this._showToast(message),
});
}
// ── Share Button ─────────────────────────────
/**
* Wires the share button. By default it copies the current share link to the
* clipboard; a host may pass `services.shareLink(url)` to share it another way
* (a system share sheet, for example). It resolves to 'shared', 'copied',
* 'cancelled' (no toast) or false (failed).
* @returns {void}
*/
_initShareButton() {
this._lifetime.listen(this._shareBtn, 'click', async () => {
const share = this.services.shareLink;
let result;
if (typeof share === 'function') {
// A host's hook may return a plain value or throw; both are answers.
result = await Promise.resolve()
.then(() => {
const href = this.shareLinkManager.buildLink();
return href ? share(href) : false;
})
.catch(() => false);
} else {
result = (await this.shareLinkManager.copyLink()) ? 'copied' : false;
}
if (this._disposed || result === 'cancelled') return;
this._showToast(
result === 'shared'
? t('Link shared')
: result === 'copied'
? t('Link copied!')
: t('Copy failed'),
);
});
}
// ── HUD Toggle ───────────────────────────────
/**
* Wires the HUD toggle button, initializes the default HUD variant to 'tactical',
* and sets up the detection mode cycle button.
* @returns {void}
*/
/**
* Wires the DISPLAY-rail "3D" toggle to the flights layer's `models3d` param.
* ON by default in `proximity` mode (owner directive 2026-08-22): the fleet
* renders as 3D glTF models once the camera is zoomed in past the layer's
* altitude ceiling, and only the nearest MODEL_MAX in view are admitted, so
* the default costs nothing at globe scale. `all` is the deliberate opt-in;
* turning the toggle off returns the fleet to flat billboards. The TRACKED
* contact is independent of this toggle (see trackedModelRegime.js).
* @returns {void}
*/
_initHUDToggle() {
this._setHudVariant('tactical');
this.hud.setMode('on');
this._updateHudButtonState();
this._lifetime.listen(this._cockpitDisplayToggleBtn, 'click', () => {
this._setCockpitDisclosure?.(
'display',
!this._cockpitDisclosureOpen('display'),
);
});
this._initCockpitDisplayPortal();
}
/**
* Recalculates the CCTV panel max-height based on its current top position
* and the window height, enabling internal scroll without viewport overflow.
* @returns {void}
*/
_syncCctvPanelViewport() {
if (!this._cctvPanel) return;
const inner = this._cctvPanel.querySelector('.cctv-panel-inner');
this._lifetime.frame(() => {
if (this._cctvPanel.parentElement?.id === 'right-context-rail') {
this._cctvPanel.style.maxHeight = '';
if (inner) inner.style.maxHeight = '';
this._scheduleRightPanelLayout();
return;
}
const rect = this._cctvPanel.getBoundingClientRect();
const availableHeight = Math.max(
190,
Math.floor(window.innerHeight - rect.top - 12),
);
this._cctvPanel.style.maxHeight = `${availableHeight}px`;
if (inner) {
inner.style.maxHeight = `${availableHeight}px`;
}
});
}
/** Terminal result for the complete initial share restoration. */
get initialRestorePromise() {
return (
this._initialShareRestorePromise ||
Promise.resolve({ status: 'not-requested' })
);
}
/**
* Tear down the StyleManager — cancel animation loop, clear intervals,
* and release resources. Call this before discarding the instance to
* prevent leaked rAF loops and event listeners.
* @returns {Promise<void>} Resolves after focused-session state restoration.
*/
async dispose() {
const { destroyTrackedReadout, destroyWorldOverlay, destroyDetection } =
this.services;
if (this._disposed) return;
this._shareRestoration.destroy();
this._feedback.clearLoadingStatus();
this._disposed = true;
this._navigation.stop();
this._shareState.destroy();
this._locationNavigation.destroy();
this._lifetime.destroy();
for (const unregister of this._unsavedWorkChecks?.splice(0) ?? [])
unregister();
useLocaleStore(applicationPinia()).publishPicker(null);
this._recording.destroy();
this._mobileShell?.destroy();
this._panelNavigation?.destroy();
this._ui.destroy();
this._panelChrome.destroy();
this._feedback.destroy();
this._displayBindings.destroy();
this._mapSourceControls?.destroy();
useDisplayStore(applicationPinia()).connectMapSource(null);
useDisplayStore(applicationPinia()).publishMapSource(null);
this._cameraOrientationControls?.destroy();
this._clearLayersControl?.destroy();
this._cctvControls?.destroy();
this.services?.streetLevelLayer?.attachNavigation?.(null);
this._streetLevelControls?.destroy();
this._radioControls?.destroy();
this._localSdrControls?.destroy();
this._awarenessPanelUnsubscribe?.();
this._awarenessPanelUnsubscribe = null;
this._missionsPanelUnsubscribe?.();
this._missionsPanelUnsubscribe = null;
this._missionReplayUnsubscribe?.();
this._missionReplayUnsubscribe = null;
useMissionReplayStore(applicationPinia()).publish(null);
const contextStore = useContextStore(applicationPinia());
contextStore.connectAwareness(null);
contextStore.publishAwareness(null);
contextStore.connectMissions(null);
contextStore.publishMissions(null);
this._cockpitCoordinator.stop();
this._visualSettings.stop();
this.shareLinkManager?.destroy();
this._layerBindings.stop();
// Invalidate any in-flight Context transaction the same way a newer request
// would. Without this, a reinstatement already past its awaits could
// re-enable a mode's entry layer and republish `_contextMode` while the
// rest of teardown is tearing those very layers down.
this._contextControls.stop();
this._navigation.destroy();
// Close camera-entry seams synchronously. Context restoration may await
// layer work, so leaving these listeners attached until afterward lets a
// focus event release tracking or start a flight during teardown.
await this._contextControls.restoreForDisposal();
// IR boost teardown BEFORE detaching the data manager: restore fog and
// un-boost both aircraft layers so a surviving viewer or replacement
// manager doesn't inherit sensor state (review P2, 2026-08-16).
this._visualSettings.releaseIrBoost();
this._cockpitCoordinator.destroy();
this._contextControls.disconnect();
this._layerBindings.disconnect();
if (this._windowResizeHandler) {
window.removeEventListener('resize', this._windowResizeHandler);
this._windowResizeHandler = null;
}
destroyTrackedReadout();
destroyDetection();
this._overlayActionsUnsubscribe?.();
this._overlayActionsUnsubscribe = null;
destroyWorldOverlay();
const overlayActionsStore = useWorldOverlayActionsStore(applicationPinia());
overlayActionsStore.connect(null);
overlayActionsStore.publish(null);
this.celestialRing?.destroy();
this._visualSettings.destroy();
}
}