import { CockpitViewController, CockpitDisplayPortal } from './ui/cockpit.js'; import { ContextControls } from './ui/context.js'; import { CctvControls } from './ui/cctv.js'; import { RadioControls } from './ui/radio.js'; import { LocationControls, LocationSearch } from './ui/location.js'; import { bindClearLayersControl } from './ui/layers.js'; import { createMapSourceControls } from './ui/mapSource.js'; import { VisualEffects, STYLES, GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './ui/effects.js'; import { bindDisplayControls } from './ui/displayControls.js'; import { bindApplicationShortcuts, createStyleParameters } from './ui/visualInput.js'; import { layoutLeftPanelRail, layoutRightPanelRail } from './ui/panelRails.js'; import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js'; import * as Cesium from 'cesium'; import { BLOOM_SCALE_VERSION, clampBloomIntensity, decodeBloomIntensity } from './bloom.js'; import { CITY_POIS, GLOBE_VIEW, flyToGlobeView, flyToPresetLocation, flyToPOI, searchAndFlyTo } from './locations.js'; import { interruptCameraMotion } from './cameraVerbs.js'; import { aircraftTrackingTarget, enterCockpitWithTracking, } from './cockpitTracking.js'; import { IntelHUD } from './hud.js'; import { ShareLinkManager } from './sharelink.js'; import { isExplicitLayerStateOrigin, LayerStateCoordinator, } from './data/layerState.js'; import { OrbitController } from './orbit.js'; import { CelestialRing, getKeyholeFadeTuning, isCelestialRingStyleSupported, setKeyholeFadeTuning, } from './celestialRing.js'; import { destroyTrackedReadout, initTrackedReadout } from './data/trackedReadout.js'; import { destroyWorldOverlay, initWorldOverlay } from './overlays/worldOverlay.js'; import { destroyDetection, initDetection, cycleMode as cycleDetectionMode, getDetectionDiagnostics as readDetectionDiagnostics, getDetectionTuning, getMode as getDetectionMode, setMode as setDetectionModeByLabel, suspendDetection, resumeDetection, setDetectionStyle, setDetectionTuning, } from './data/detection.js'; import { ALLOCATION_STRATEGIES, canonicalizeDensity, defaultDensityForProfile, normalizeAllocationStrategy, normalizeProfile, profileForDensity, } from './data/detectionPolicy.js'; import trafficLayer from './data/traffic.js'; import flightsLayer from './data/flights.js'; import militaryFlightsLayer from './data/militaryFlights.js'; import { isTr3b, toggleTr3b } from './data/tr3bRegistry.js'; import satellitesLayer from './data/satellites.js'; import cctvLayer from './data/cctv.js'; import radioLayer from './data/radio.js'; import bikeshareLayer from './data/bikeshare.js'; import aisLiveVesselsLayer from './data/aisLiveVessels.js'; import militaryAwarenessLayer from './data/militaryAwareness.js'; import militaryInstallationsLayer from './data/militaryInstallations.js'; import rocketLaunchesLayer from './data/rocketLaunches.js'; import { aggregateLayerLoading, canPresentDeferredStatusNotice, createGlobalStatusNotice, createLoadingFeedbackState, createTrafficSyncFeedbackState, presentGlobalLoadingStatus, reduceLoadingFeedback, reduceTrafficSyncFeedback } from './loadingFeedback.js'; import { setSplitFlapText } from './splitFlap.js'; import { cockpitEntryAllowed } from './contextModePolicy.js'; import { applyCockpitVisionStageIntensities, captureCockpitVisionBaseline, normalizeCockpitVisionMode } from './cockpitVisionPolicy.js'; import { applyContactsDetection, shareCacheNeedsHeal, shareableDetectionState, } from './contactsDetectionPolicy.js'; import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js'; import { runCctvLayerEnableTransition } from './cctvFocusPolicy.js'; import { registerCctvFocusRequestListener, routeCctvFocusRequest, } from './cctvFocusRequest.js'; import { flyToWorldTarget, registerWorldFocusRequestListener, routeWorldFocusRequest, } from './worldFocus.js'; import { beginDeferredNavigation, reassertNavigationHandoff, registerNavigationAuthorityListener, runExplicitNavigation, stampInitialShareGesture, } from './navigationPolicy.js'; import { cachedGroundFloor, cachedMeshFloor, GROUND_FLOOR_LIFT_M, meshFloorPreferred, warmGroundFloor, } from './data/groundFloor.js'; import { sampleMeshFloorCells } from './data/meshFloorSampler.js'; import { holdContinuousRender, releaseContinuousRender, governorRequestRender } from './renderGovernor.js'; import { setScopeMaskEnabled, isScopeMaskEnabled, setScopeMaskFeather, getScopeMaskFeather, setScopeTerminusOverride, getScopeTerminusOverride, clampScopeTerminusPct, } from './scopeMask.js'; import { fetchRegionalBrief, regionalDistanceM, weatherCodeLabel, } from './data/regionalBrief.js'; /** Versioned localStorage namespace prefix to invalidate stale panel layouts. */ const PANEL_LAYOUT_STORAGE_VERSION = 'v6'; const SHARE_PANEL_STATE_SPECS = Object.freeze([ { id: 'control-panel', pinnable: true }, { id: 'location-bar', pinnable: true }, { id: 'data-panel' }, { id: 'cctv-panel' }, { id: 'radio-panel' }, { id: 'scene-panel' }, { id: 'global-context-panel' }, { id: 'pp-toggles' }, { id: 'param-slider-panel' }, ]); /** Standard map-view panels cleared out of the way on a fresh Cockpit entry. */ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([ 'data-panel', 'cctv-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'radio-panel', ]); /** * Position keys are versioned separately from collapsed-state keys so layout * default changes (e.g. right-rail origin) can reset positions without also * resetting every panel's open/closed preference. */ const PANEL_POSITION_STORAGE_VERSION = 'v8'; const DETECTION_ALLOCATION_STORAGE_KEY = 'gev:detection-allocation:v1'; /** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */ const PANEL_Z_BASE = 100; const PANEL_Z_MAX = 139; const COCKPIT_LAYOUT_SETTLE_MS = 240; /** * Fixed UI regions that can occupy the left accordion's vertical lane. * Rectangles are filtered at runtime for visibility and horizontal overlap, * so right-side/center controls do not reduce the lane unless they actually * intersect it at the current viewport size. */ const LEFT_STACK_OBSTACLE_SELECTOR = [ '#cockpit-hud .cockpit-topline', '#cockpit-hud .cockpit-topline > div', '#title-bar', '#style-indicator', '#top-center-actions', '#traffic-sync-chip', '#cctv-sync-chip', '#intel-hud .hud-top-left', '#intel-hud .hud-top-right', '#intel-hud .hud-bottom-left', '#intel-hud .hud-bottom-right', '#intel-hud .hud-top-bar', '#intel-hud .hud-bottom-bar', '#intel-hud .hud-left-edge', '#intel-hud .hud-right-edge', '#cockpit-context', '#cesium-credits .cesium-credit-logoContainer', '#cesium-credits .cesium-credit-textContainer', '#location-bar', '#control-panel', '#gev-voice-control', '#pp-toggles', '#param-slider-panel', ].join(', '); /** * Fixed UI regions that can occupy the right control lane. Runtime rectangle * filtering keeps the rail clear of whichever HUD variant is currently * visible without tying the layout to one screen height. */ const RIGHT_STACK_OBSTACLE_SELECTOR = [ '#cockpit-hud .cockpit-topline', '#cockpit-hud .cockpit-topline > div', '#title-bar', '#style-indicator', '#top-center-actions', '#traffic-sync-chip', '#cctv-sync-chip', '#intel-hud .hud-top-left', '#intel-hud .hud-top-right', '#intel-hud .hud-bottom-left', '#intel-hud .hud-bottom-right', '#intel-hud .hud-top-bar', '#intel-hud .hud-bottom-bar', '#intel-hud .hud-left-edge', '#intel-hud .hud-right-edge', '#cockpit-context', '#cockpit-signal-stream', '#cesium-credits .cesium-credit-logoContainer', '#cesium-credits .cesium-credit-textContainer', '#command-dock', '#gev-voice-control', ].join(', '); /** Display labels shown in the mini-status readout for each active style. */ const STYLE_STATUS_LABELS = { normal: 'NORMAL', retro: 'CRT', surveillance: 'NVG', thermal: 'FLIR', anime: 'ANIME', noir: 'NOIR', snow: 'SNOW', }; /** * 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. * - Draggable/collapsible panel system with localStorage persistence, * z-order stacking, and viewport-clamped positioning. * - 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 { /** * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. * @param {object} [options] */ constructor(viewer, { mapStackController = null, placeSearch } = {}) { this.viewer = viewer; this.mapStackController = mapStackController; this.placeSearch = placeSearch; this._visualEffects = new VisualEffects({ viewer, requestRender: governorRequestRender, holdRender: holdContinuousRender, releaseRender: releaseContinuousRender, }); this.activeStyle = 'normal'; document.documentElement.dataset.gevStyle = this.activeStyle; // True once the user manually changes detection (button/key/slider/voice). // Gates per-style detection defaults so they never stomp an explicit choice. this._detectionUserOverridden = false; // Bloom/sharpen state this._recordingMode = false; this._recordingConfig = { hidePanels: true, hudMode: 'minimal', safeFrame: '16:9' }; this._preRecordingHudState = null; this._panelZCounter = PANEL_Z_BASE + 10; this._lastLoadingFeedbackUpdateAt = 0; this._loadingFeedbackState = createLoadingFeedbackState(); this._loadingFeedbackEvent = null; this._loadingFeedbackTicker = null; this._globalStatusNotice = null; this._shareTrackingAcquiringKey = null; this._shareTrackingNoticeGeneration = 0; this._globeResetPromise = null; this._dataManager = null; this._leftStackLayoutFrame = null; this._leftStackReconsiderAutoCollapse = false; this._leftStackResizeObserver = null; this._leftStackMutationObserver = null; this._leftStackHudTransitionHandler = null; this._leftStackCollapsedHeights = new Map(); this._leftStackPreferredPanelId = null; this._rightPanelStack = document.getElementById('right-context-rail'); this._rightStackLayoutFrame = null; this._rightStackReconsiderAutoCollapse = false; this._rightStackResizeObserver = null; this._rightStackMutationObserver = null; this._rightStackHudTransitionHandler = null; this._rightStackPreferredPanelId = null; this._adaptivePanelSettleTimer = null; this._windowResizeHandler = null; this._loadingVisibilityHandler = null; this._cctvRequestFocusHandler = null; this._removeCctvRequestFocusListener = null; this._worldRequestFocusHandler = null; this._removeWorldRequestFocusListener = null; this._removeNavigationAuthorityListener = null; this._navigationOwnerChangedRemover = null; this._navigationGeneration = 0; this._activeLocationSearchGeneration = null; this._initialShareState = null; this._initialShareNavigationGeneration = null; this._initialShareRestoreTimeout = null; this._layerStateCoordinator = null; this._layerStateRestorePromise = null; this._awarenessSelectedHandler = null; this._awarenessClearedHandler = null; this._disposed = false; this._draggableResizeObserver = null; // DOM refs this._styleIndicator = document.getElementById('active-style-name'); this._sliderPanel = document.getElementById('param-slider-panel'); this._sliderContainer = document.getElementById('param-sliders'); this._ppToggles = document.getElementById('pp-toggles'); this._bloomBtn = document.getElementById('bloom-toggle'); this._bloomSliderRow = document.getElementById('bloom-slider-row'); this._bloomSlider = document.getElementById('bloom-intensity-slider'); this._bloomSliderValue = document.getElementById('bloom-intensity-value'); this._sharpenBtn = document.getElementById('sharpen-toggle'); this._sharpenSliderRow = document.getElementById('sharpen-slider-row'); this._sharpenSlider = document.getElementById('sharpen-intensity-slider'); this._sharpenSliderValue = document.getElementById('sharpen-intensity-value'); this._hudBtn = document.getElementById('hud-toggle'); this._hudLayoutRow = document.getElementById('hud-layout-row'); this._hudLayoutSelect = document.getElementById('hud-layout-select'); this._detectionSliderRow = document.getElementById('detection-slider-row'); this._detectionDensitySlider = document.getElementById('detection-density-slider'); this._detectionDensityValue = document.getElementById('detection-density-value'); this._detectionAllocationRow = document.getElementById('detection-allocation-row'); this._detectionAllocationBtns = [ document.getElementById('detection-allocation-elastic'), document.getElementById('detection-allocation-weighted'), ].filter(Boolean); this._detectionFadeRow = document.getElementById('detection-fade-row'); this._detectionFadeSlider = document.getElementById('detection-fade-slider'); this._detectionFadeValue = document.getElementById('detection-fade-value'); this._detectionOpacityRow = document.getElementById('detection-opacity-row'); this._detectionOpacitySlider = document.getElementById('detection-opacity-slider'); this._detectionOpacityValue = document.getElementById('detection-opacity-value'); let storedDetectionAllocation = 'ELASTIC'; try { storedDetectionAllocation = localStorage.getItem(DETECTION_ALLOCATION_STORAGE_KEY) || 'ELASTIC'; } catch { /* storage can be unavailable in privacy/test contexts */ } this._detectionAllocationPreference = normalizeAllocationStrategy(storedDetectionAllocation); this._celestialBtn = document.getElementById('celestial-toggle'); this._scopeBtn = document.getElementById('scope-toggle'); this._scopeFeatherSlider = document.getElementById('scope-feather-slider'); this._scopeFeatherValue = document.getElementById('scope-feather-value'); this._mapStackChips = document.getElementById('map-stack-chips'); this._mapStackStatus = document.getElementById('map-stack-status'); this._mapStackChangeHandler = null; this._cleanViewBtn = document.getElementById('clean-view-toggle'); this._cleanViewExitBtn = document.getElementById('clean-view-exit'); this._dataPanel = document.getElementById('data-panel'); this._scenePanel = document.getElementById('scene-panel'); this._cctvPanel = document.getElementById('cctv-panel'); this._radioPanel = document.getElementById('radio-panel'); this._contextRadioDock = document.getElementById('context-radio-dock'); this._contextRadioToggleBtn = document.getElementById('context-radio-toggle-btn'); this._contextRadioMini = document.getElementById('context-radio-mini'); this._contextRadioMiniEnableBtn = document.getElementById('context-radio-mini-enable-btn'); this._contextRadioDetailsBtn = document.getElementById('context-radio-details-btn'); this._contextRadioMiniCloseBtn = document.getElementById('context-radio-mini-close-btn'); this._contextRadioMiniStation = document.getElementById('context-radio-mini-station'); this._contextRadioMiniPrevBtn = document.getElementById('context-radio-mini-prev-btn'); this._contextRadioMiniPlayBtn = document.getElementById('context-radio-mini-play-btn'); this._contextRadioMiniNextBtn = document.getElementById('context-radio-mini-next-btn'); this._contextRadioMiniVolume = document.getElementById('context-radio-mini-volume'); this._contextRadioMiniVolumeValue = document.getElementById('context-radio-mini-volume-value'); this._cockpitUtilityControls = document.getElementById('cockpit-utility-controls'); this._cockpitDisplayToggleBtn = document.getElementById('cockpit-display-toggle-btn'); this._cockpitDisplayPanel = document.getElementById('cockpit-display-panel'); this._cockpitDisplayPortal = null; this._cockpitDisplayModeHandler = null; this._cockpitRadioToggleBtn = document.getElementById('cockpit-radio-toggle-btn'); this._cockpitRadioPanel = document.getElementById('cockpit-radio-panel'); this._cockpitRadioEnableBtn = document.getElementById('cockpit-radio-enable-btn'); this._cockpitRadioStation = document.getElementById('cockpit-radio-station'); this._cockpitRadioPrevBtn = document.getElementById('cockpit-radio-prev-btn'); this._cockpitRadioPlayBtn = document.getElementById('cockpit-radio-play-btn'); this._cockpitRadioNextBtn = document.getElementById('cockpit-radio-next-btn'); this._cockpitRadioVolume = document.getElementById('cockpit-radio-volume'); this._cockpitRadioVolumeValue = document.getElementById('cockpit-radio-volume-value'); this._radioLayerState = document.getElementById('radio-layer-state'); this._radioEnableBtn = document.getElementById('radio-enable-btn'); this._radioFilter = document.getElementById('radio-filter'); this._radioStationName = document.getElementById('radio-station-name'); this._radioStationMeta = document.getElementById('radio-station-meta'); this._radioStationTags = document.getElementById('radio-station-tags'); this._radioTuner = document.getElementById('radio-tuner'); this._radioTunerSlider = document.getElementById('radio-tuner-slider'); this._radioTunerNeedle = document.getElementById('radio-tuner-needle'); this._radioTunerBandLabel = document.getElementById('radio-tuner-band-label'); this._radioTunerValue = document.getElementById('radio-tuner-value'); this._radioTunerStation = document.getElementById('radio-tuner-station'); this._radioPrevBtn = document.getElementById('radio-prev-btn'); this._radioPlayBtn = document.getElementById('radio-play-btn'); this._radioNextBtn = document.getElementById('radio-next-btn'); this._radioStopBtn = document.getElementById('radio-stop-btn'); this._radioVolume = document.getElementById('radio-volume'); this._radioVolumeValue = document.getElementById('radio-volume-value'); this._radioPlaybackState = document.getElementById('radio-playback-state'); this._radioStationHomepage = document.getElementById('radio-station-homepage'); this._globalContextFlightsBtn = document.getElementById('global-context-flights-btn'); this._globalContextMissionsBtn = document.getElementById('global-context-missions-btn'); this._contextModeStandby = document.getElementById('context-mode-standby'); this._contextFlightsView = document.getElementById('context-flights-view'); this._contextMissionsView = document.getElementById('context-missions-view'); this._installationsSearchBtn = document.getElementById('installations-search-btn'); this._leftPanelStack = document.getElementById('left-panel-stack'); this._cctvEnableBtn = document.getElementById('cctv-enable-btn'); this._cctvNearestBtn = document.getElementById('cctv-nearest-btn'); this._cctvPrevBtn = document.getElementById('cctv-prev-btn'); this._cctvNextBtn = document.getElementById('cctv-next-btn'); this._cctvSelect = document.getElementById('cctv-camera-select'); this._cctvFocusBtn = document.getElementById('cctv-focus-btn'); this._cctvCoverageBtn = document.getElementById('cctv-coverage-btn'); this._cctvAutoHopBtn = document.getElementById('cctv-auto-hop-btn'); this._cctvProjectionBtn = document.getElementById('cctv-projection-btn'); this._cctvQualityChip = document.getElementById('cctv-quality-chip'); this._cctvAdjustBtn = document.getElementById('cctv-adjust-btn'); this._cctvCalReadout = document.getElementById('cctv-cal-readout'); this._cctvCalibSaveBtn = document.getElementById('cctv-calib-save-btn'); this._cctvCalibResetBtn = document.getElementById('cctv-calib-reset-btn'); this._cctvFrame = document.getElementById('cctv-frame'); this._cctvFrameWrap = document.getElementById('cctv-frame-wrap'); this._cctvSourceBadge = document.getElementById('cctv-source-badge'); this._cctvMeta = document.getElementById('cctv-meta'); this._cctvSummary = document.getElementById('cctv-summary'); this._shareBtn = document.getElementById('share-btn'); this._clearSelectedLayersBtn = document.getElementById('clear-selected-layers'); this._globalLoadingStatus = document.getElementById('global-loading-status'); this._globalLoadingLabel = document.getElementById('global-loading-label'); this._globalLoadingDetail = document.getElementById('global-loading-detail'); this._resetGlobeBtn = document.getElementById('reset-globe-view'); this._cockpitResetGlobeBtn = document.getElementById('cockpit-reset-globe'); this._styleButtons = document.getElementById('style-buttons'); this._trafficSyncChip = document.getElementById('traffic-sync-chip'); this._trafficSyncLabel = document.getElementById('traffic-sync-label'); this._trafficSyncProgress = document.getElementById('traffic-sync-progress'); this._cctvSyncChip = document.getElementById('cctv-sync-chip'); this._cctvSyncLabel = document.getElementById('cctv-sync-label'); this._cctvSyncProgress = document.getElementById('cctv-sync-progress'); this._toast = document.getElementById('toast'); this._locationSearch = document.getElementById('location-search'); this._searchToggle = document.getElementById('search-toggle'); this._locationPills = document.getElementById('location-pills'); this._poiRow = document.getElementById('poi-row'); this._locationBarDivider = document.getElementById('location-bar-divider'); this._styleMiniValue = document.getElementById('style-mini-value'); this._locationMiniCity = document.getElementById('location-mini-city'); this._locationMiniPoi = document.getElementById('location-mini-poi'); this._safeFrameOverlay = document.getElementById('safe-frame-overlay'); this._safeFrameBox = document.getElementById('safe-frame-box'); this._activeLocationId = null; this._expandedCityId = null; this._activePoiIndex = null; this._currentTarget = null; // Cesium.Cartesian3 of current POI target this._currentPoi = null; // Current POI data object // Formatted address of the last free-text geocode search. Preset pills set // _activeLocationId instead; a search has no preset record, so this is the // only thing the mini-status can report for it. this._searchedLocationLabel = null; this._trafficSyncFeedbackState = createTrafficSyncFeedbackState(); this._trafficTransitionTimer = null; this._lastTrafficChipUpdateAt = 0; // Orbit controller this.orbitController = new OrbitController(viewer); this._orbitIndicator = null; // Intel HUD this.hud = new IntelHUD(viewer); this._cockpitVisionMode = 'optical'; this._cockpitVisionRestore = null; this._cockpitPanelRestore = null; // True only while the open Data Layers panel is the reason Cockpit's // Contact panel is collapsed. A user-collapsed Contact panel must remain // collapsed when Data Layers closes. this._cockpitContextCollapsedForDataPanel = false; /** Pre-Contacts detection state, restored on deactivation (see _syncContactsDetection). */ this._contactsDetectionRestore = null; this.cockpitView = new CockpitViewController(viewer, { services: { flightsLayer, militaryFlightsLayer, isTr3b, toggleTr3b, militaryAwarenessLayer, formatAwarenessLabel, cachedGroundFloor, cachedMeshFloor, GROUND_FLOOR_LIFT_M, meshFloorPreferred, warmGroundFloor, sampleMeshFloorCells, holdContinuousRender, releaseContinuousRender, fetchRegionalBrief, regionalDistanceM, weatherCodeLabel, }, onVisionChange: (mode, active, options) => this._setCockpitVision(mode, active, options), onCameraTakeover: () => this._stampNavigation({ cancelPendingSelection: false }), getInheritedVisionLabel: () => ( STYLE_STATUS_LABELS[this.activeStyle] || String(this.activeStyle || 'normal').toUpperCase() ), isEntryAllowed: () => cockpitEntryAllowed({ contextMode: this._contextMode, contextModeChanging: this._contextModeChanging, flightsEnabled: !!this._dataManager?.isEnabled('flights'), militaryEnabled: !!this._dataManager?.isEnabled('military'), }), onEntered: () => { // A new Cockpit session owns both side rails. Clear standard map-view // panels once on entry; NEXT/PREVIOUS never reaches this callback, so // panels the operator opens while already inside remain untouched. this._cockpitPanelRestore = new Map(); this._cockpitContextCollapsedForDataPanel = false; for (const panelId of COCKPIT_ENTRY_COLLAPSE_PANEL_IDS) { const panel = document.getElementById(panelId); if (panel) { this._cockpitPanelRestore.set(panelId, panel.classList.contains('collapsed')); } this.setPanelCollapsed(panelId, true, { persist: false, syncShare: false, }); } this.cockpitView?.setContextCollapsed(false); this.cockpitView?.setSignalCollapsed(false, { user: true }); }, onExited: () => { const restore = this._cockpitPanelRestore; this._cockpitPanelRestore = null; this._cockpitContextCollapsedForDataPanel = false; if (!restore) return; for (const [panelId, wasCollapsed] of restore) { this.setPanelCollapsed(panelId, wasCollapsed, { persist: false, syncShare: false, }); } }, restoreTrackingFrame: (entity) => { const [layerId, ...idParts] = String(entity?.gevTrackedId || '').split(':'); const trackedId = idParts.join(':'); if (!trackedId) return false; if (layerId === 'flights') return flightsLayer.refocusTrackedById?.(trackedId) === true; if (layerId === 'military') return militaryFlightsLayer.refocusTrackedById?.(trackedId) === true; return false; }, }); // 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, onAutoDisable: () => this.setCelestialRingEnabled(false, { syncShare: !!this.shareLinkManager, focus: false, }), }); // Share Link Manager this.shareLinkManager = new ShareLinkManager(viewer, { onRestore: async (state) => { const { style, bloom, sharpen, bloomIntensity, bloomVersion, sharpenIntensity, hudVariant, hudVisible, detectionMode, detectionDensity, detectionAllocation, detectionFadePct, detectionOutsideOpacityPct, celestialRing, scopeEnabled, scopeFeatherPct, scopeTerminusPct, mapStack, panelState, styleParams, } = state || {}; // Ignore the retired 'ai-edit' style from older share links. if (style && style !== 'normal' && style !== 'ai-edit') { this.setStyle(style, { applyPreset: true, revealParameters: false, restore: true }); } if (styleParams && style && this.stages[style] && STYLES[style]?.uniforms) { for (const [uniformName, uniformValue] of Object.entries(styleParams)) { if (!Object.hasOwn(STYLES[style].uniforms, uniformName)) continue; this.stages[style].uniforms[uniformName] = uniformValue; } this._updateSliderPanel(style, { reveal: false }); } if (typeof bloomIntensity === 'number' && this._bloomSlider) { const intensity = decodeBloomIntensity(bloomIntensity, bloomVersion); this._setBloomIntensity(intensity, { syncShare: false }); } if (typeof sharpenIntensity === 'number' && this._sharpenSlider) { const pct = Math.max(0, Math.min(100, Math.round(sharpenIntensity))); this._sharpenSlider.value = String(pct); this._sharpenSliderValue.textContent = `${pct}%`; this._applySharpenIntensity(pct / 100); } if (typeof bloom === 'boolean') this._setBloomEnabled(bloom); if (typeof sharpen === 'boolean') this._setSharpenEnabled(sharpen); if (hudVariant) this._setHudVariant(hudVariant); if (typeof hudVisible === 'boolean') { this.hud.setMode(hudVisible ? 'on' : 'off'); this._updateHudButtonState(); } if (typeof detectionDensity === 'number' && this._detectionDensitySlider) { const pct = canonicalizeDensity(detectionDensity); this._detectionDensitySlider.value = String(pct); this._detectionDensityValue.textContent = `${pct}%`; this._applyDetectionDensityFromUi(); } if (detectionAllocation) { this._setDetectionAllocation(detectionAllocation, { syncShare: false, persist: false }); } if (typeof detectionFadePct === 'number' && this._detectionFadeSlider) { this._detectionFadeSlider.value = String(detectionFadePct); } if (typeof detectionOutsideOpacityPct === 'number' && this._detectionOpacitySlider) { this._detectionOpacitySlider.value = String(detectionOutsideOpacityPct); } this._applyDetectionFadeFromUi(); if (detectionMode) this._setDetectionMode(detectionMode); if (typeof celestialRing === 'boolean') { this.setCelestialRingEnabled(celestialRing, { syncShare: false, focus: false }); } if (typeof scopeEnabled === 'boolean') { setScopeMaskEnabled(scopeEnabled); this._scopeBtn?.classList.toggle('active', scopeEnabled); this._scopeBtn?.setAttribute('aria-pressed', String(scopeEnabled)); } if (typeof scopeFeatherPct === 'number' && this._scopeFeatherSlider) { const pct = Math.max(0, Math.min(100, Math.round(scopeFeatherPct))); this._scopeFeatherSlider.value = String(pct); if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; setScopeMaskFeather(pct / 100); } // null restores the altitude-adaptive ramp; a number pins the terminus // (clamped to the supported 94..100 band, same as the `sce` hash key). if (scopeTerminusPct === null) setScopeTerminusOverride(null); else if (typeof scopeTerminusPct === 'number') { const pinned = clampScopeTerminusPct(scopeTerminusPct); setScopeTerminusOverride(pinned == null ? null : pinned / 100); } const mapStackRestore = mapStack ? this._setMapStack(mapStack, { syncShare: false }) : Promise.resolve(); if (panelState) this._restorePanelState(panelState); await mapStackRestore; this._syncShareState(); }, isNavigationCurrent: (generation) => generation === this._navigationGeneration, cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(), }); 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]]), ); }); // 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._initialShareState = this.shareLinkManager.parseInitialHash(); this._detectionBtn = document.getElementById('detection-toggle'); this._models3dBtn = document.getElementById('models3d-toggle'); this._models3dModeRow = document.getElementById('models3d-mode-row'); this._models3dModeBtns = [ document.getElementById('models3d-mode-proximity'), document.getElementById('models3d-mode-all'), ]; // DISPLAY-rail 3D-aircraft toggle (flights layer param). DEFAULT-ON in // PROXIMITY (owner directive 2026-08-22) — mirrors the `models3d` default in // layerState.js and `_models3dEnabled` in both flight layers, and the `active` // class the button carries in index.html. A fresh boot skips layer-state // restoration, so these initializers are the only thing keeping the lit // button and the armed layer in agreement. this._models3dEnabled = true; this._models3dMode = 'proximity'; // 'proximity' (nearest in view) | 'all' (every in-view plane) // 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); // Initialize detection overlay BEFORE style stages so the composite // stage is first in the post-process pipeline initDetection(viewer, [trafficLayer, flightsLayer, militaryFlightsLayer, satellitesLayer, cctvLayer, bikeshareLayer, aisLiveVesselsLayer], (modeLabel) => { this._updateDetectionButton(modeLabel); }); initTrackedReadout(viewer); setDetectionStyle(this.activeStyle); this._applyDetectionDensityFromUi(); this._initStages(); this._initBloomSharpen(); this._initUI(); this._initMapStackControl(); this._initPanelChrome(); this._initLeftPanelAdaptiveLayout(); this._initRightPanelAdaptiveLayout(); this._initRadioPanel(); this._initCctvPanel(); this._initGlobalContextPanel(); this._initLocationBar(); this._initShareButton(); this._initClearSelectedLayersButton(); this._initHUDToggle(); this._initModels3dToggle(); this._applyGlobalPostDefaults(); this._initOrbit(); this._initRecordingOverlay(); this._startAnimationLoop(); this._startTrafficChipTicker(); this._updateStyleMiniStatus(); this._updateLocationMiniStatus(); // Restore from URL hash if present const savedState = this._initialShareState; this._initialShareRestorePromise = savedState ? new Promise((resolve) => { this._resolveInitialShareRestore = resolve; }) : Promise.resolve({ status: 'not-requested', share: null, layers: [] }); if (savedState) { this._hasShareState = true; // Reserve camera authority now; the delayed mesh-friendly flight may // run only if no newer user, voice, or tracking navigation has won. this._initialShareNavigationGeneration = this._beginDeferredNavigation( 'shared view', { cancelPendingSelection: false }, ); this._initialShareRestoreTimeout = setTimeout(() => { this._initialShareRestoreTimeout = null; if (this._disposed) return; const generation = this._initialShareNavigationGeneration; const applyCamera = Number.isInteger(generation) && this._reassertNavigationHandoff(generation); void (async () => { try { const share = await this.shareLinkManager.applyState(savedState, { applyCamera, navigationToken: generation, }); const layers = await (this._layerStateRestorePromise || Promise.resolve([])); const tracking = share.camera === 'applied' ? await this._layerStateCoordinator?.restoreShareTrackingSelection?.() : { status: 'superseded', cleared: this._layerStateCoordinator?.cancelPendingShareTracking?.( 'shared-camera-superseded', { clearSelection: true }, ) === true, }; this.shareLinkManager.completeInitialRestore(); this._settleInitialShareRestore({ status: 'settled', share, layers, tracking }); } catch (error) { this.shareLinkManager.completeInitialRestore(); this._settleInitialShareRestore({ status: 'failed', error: String(error?.message || error), share: null, layers: [], }); } })(); }, 1500); } else { this._syncShareState(); } // A recipient can orbit before or during the delayed share flight. That // gesture keeps ordinary layer state but revokes the passive base camera // and selected-subject Follow so delayed work cannot seize navigation. this._initialShareGestureHandler = () => { if ( this._disposed || !this._hasShareState || !this._resolveInitialShareRestore ) return; stampInitialShareGesture((options) => this._stampNavigation(options)); }; this.viewer?.canvas?.addEventListener('pointerdown', this._initialShareGestureHandler, { passive: true, }); this.viewer?.canvas?.addEventListener('wheel', this._initialShareGestureHandler, { passive: true, }); // 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._loadingVisibilityHandler = () => { if (!document.hidden) this._updateGlobalLoadingFeedback(); }; document.addEventListener('visibilitychange', this._loadingVisibilityHandler); this._cctvRequestFocusHandler = (event) => routeCctvFocusRequest( event, (activate, focus) => this._runExplicitCctvFocus(activate, focus), (cameraId, durationSec) => cctvLayer.focusCamera(cameraId, durationSec), ); this._removeCctvRequestFocusListener = registerCctvFocusRequestListener( window, this._cctvRequestFocusHandler, ); this._worldRequestFocusHandler = (event) => routeWorldFocusRequest( event, (detail, fly) => this._runExplicitWorldFocus(detail, fly), (detail) => flyToWorldTarget(this.viewer, detail), ); this._removeWorldRequestFocusListener = registerWorldFocusRequestListener( window, this._worldRequestFocusHandler, ); this._navigationOwnerChangedRemover = viewer.trackedEntityChanged.addEventListener((entity) => { if (entity && !this._disposed) this._stampNavigation({ cancelPendingSelection: false }); }); // Vessel/installation focus flies without ever assigning a tracked entity, // so it cannot reach the listener above. It announces instead. this._removeNavigationAuthorityListener = registerNavigationAuthorityListener( window, (event) => { if (this._disposed) return; this._stampNavigation({ cancelPendingSelection: event?.detail?.cancelPendingSelection !== false, }); }, ); } // Compatibility reads for existing controls, scene snapshots and Cockpit. get stages() { return this._visualEffects.stages; } get transitions() { return this._visualEffects.transitions; } get bloomEnabled() { return this._visualEffects.bloomEnabled; } get sharpenEnabled() { return this._visualEffects.sharpenEnabled; } get _bloomStage() { return this._visualEffects.bloomStage; } get _sharpenStage() { return this._visualEffects.sharpenStage; } /** Advance camera authority and settle any older search UI immediately. */ _stampNavigation({ cancelPendingSelection = true, clearSearchedLocation = true } = {}) { this._navigationGeneration += 1; // A newer destination owns the camera, so the last free-text search is no // longer where we are. DEFERRED navigation opts out here and clears at the // reassert seam instead: a geocode that never resolves moves no camera, and // a lookup that fails must not blank a readout that is still true. if (clearSearchedLocation) this.clearSearchedLocation(); if (cancelPendingSelection) { if (this._hasShareState && this._resolveInitialShareRestore && !this._layerStateCoordinator) { this._initialShareSelectionSuperseded = true; } const passivelyClearedShareSelection = this._layerStateCoordinator ?.cancelPendingShareTracking?.( 'superseded-by-explicit-navigation', { clearSelection: true }, ) === true; try { flightsLayer.cancelPendingTrackingRestore?.(); } catch { /* best effort */ } try { militaryFlightsLayer.cancelPendingTrackingRestore?.(); } catch { /* best effort */ } try { satellitesLayer.cancelPendingTrackingRestore?.(); } catch { /* best effort */ } // A deliberate destination supersedes share-selected entities that have // not arrived yet. Active owners publish their clear when released. if (!passivelyClearedShareSelection && !flightsLayer.getTrackedInfo?.()) { this._dataManager?.setLayerParams('flights', { selectedFlightsTrackingId: null, }, { origin: 'tool' }); } if (!passivelyClearedShareSelection && !militaryFlightsLayer.getTrackedInfo?.()) { this._dataManager?.setLayerParams('military', { selectedMilitaryTrackingId: null, }, { origin: 'tool' }); } if (!passivelyClearedShareSelection && !satellitesLayer.getTrackedInfo?.()) { this._dataManager?.setLayerParams('satellites', { selectedSatTrackingId: null, }, { origin: 'tool' }); } } if (this._activeLocationSearchGeneration !== null) { this._settleLocationSearchUi(this._activeLocationSearchGeneration); } return this._navigationGeneration; } /** Settle only the search generation that still owns the shared input UI. */ _settleLocationSearchUi(generation) { if (this._activeLocationSearchGeneration !== generation) return; this._activeLocationSearchGeneration = null; this._locationSearch?.classList.remove('searching', 'expanded'); if (this._locationSearch) this._locationSearch.value = ''; this._locationSearch?.blur(); } /** Release every follow owner while preserving Contact and vessel selection. */ _releaseFollowCamera({ preserveVesselSelection = true, preserveCameraFlight = false, trackingOrigin = 'tool', } = {}) { let contactSelected = false; try { contactSelected = Boolean( militaryAwarenessLayer.releaseCameraOwnership?.({ preserveVesselSelection, origin: trackingOrigin, }), ); } catch { try { flightsLayer.stopTracking?.({ origin: trackingOrigin }); } catch { /* best-effort release */ } try { militaryFlightsLayer.stopTracking?.({ origin: trackingOrigin }); } catch { /* best-effort release */ } if (!preserveVesselSelection) { try { aisLiveVesselsLayer.clearSelection?.(); } catch { /* best-effort release */ } } } try { satellitesLayer.stopTracking?.({ origin: trackingOrigin }); } catch { /* best-effort release */ } try { rocketLaunchesLayer.releaseCameraOwnership?.(); } catch { /* best-effort release */ } this.viewer.trackedEntity = undefined; interruptCameraMotion('explicit-navigation'); this._stopOrbit(); if (!preserveCameraFlight) this.viewer.camera.cancelFlight(); try { this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); } catch { /* teardown race */ } return contactSelected; } /** Run one immediate destination through the shared ownership policy. */ _runExplicitNavigation(noun, navigate, releaseOptions = undefined) { return runExplicitNavigation({ disposed: this._disposed, cockpitActive: !!this.cockpitView?.active, noun, showToast: (text) => this._showToast(text), stamp: () => this._stampNavigation(), release: () => this._releaseFollowCamera(releaseOptions), navigate, }); } /** Accept a delayed lookup without releasing its current camera owner. */ _beginDeferredNavigation(noun = 'location', { cancelPendingSelection = true } = {}) { return beginDeferredNavigation({ disposed: this._disposed, cockpitActive: !!this.cockpitView?.active, noun, showToast: (text) => this._showToast(text), // The searched-location readout survives the STAMP; only a flight that // actually starts invalidates it (see the release hook below). stamp: () => this._stampNavigation({ cancelPendingSelection, clearSearchedLocation: false }), }); } /** Final authority check and release immediately before a delayed flight. */ _reassertNavigationHandoff(generation) { return reassertNavigationHandoff({ generation, currentGeneration: this._navigationGeneration, cockpitActive: !!this.cockpitView?.active, disposed: this._disposed, showToast: (text) => this._showToast(text), // Reached only once the handoff is granted, immediately before the // deferred flight starts — so a lookup that failed, was superseded, or // was refused by the cockpit leaves the old readout standing. release: () => { this.clearSearchedLocation(); return this._releaseFollowCamera(); }, }); } /** 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?.()); } /** * On window resize, keep the draggable panel on-screen — a panel positioned near an edge can fall * outside a now-smaller viewport (audit U2). pp-toggles is right-pinned, so re-pin (horizontal) and * clamp its top. No-op until the panel has been positioned (explicit inline top). * @returns {void} */ _reclampDraggablePanels() { const el = this._ppToggles; if (!el || !el.style.top || el.style.top === 'auto') return; const top = parseInt(el.style.top, 10); if (!Number.isFinite(top)) return; el.style.top = `${this._clampToViewport(0, top, el).top}px`; this._pinPanelToRight(el); } /** * Creates one CesiumJS PostProcessStage per visual style and registers * it with the scene. Each stage starts with intensity 0 (invisible) * so crossfade transitions can animate it in later. * @returns {void} */ _initStages() { this._visualEffects.initStyles(); } /** * Single write path for style-stage intensity: keeps `enabled` in * lockstep so zero-intensity stages cost nothing (safe now that the * scope is explicit — see _initStages). The stage enables on the same * frame the first non-zero intensity lands, so crossfades never pop. * @param {Cesium.PostProcessStage} stage - Style post-process stage. * @param {number} value - Intensity in [0, 1]. * @returns {void} */ _setStageIntensity(stage, value) { this._visualEffects.setStageIntensity(stage, value); } /** * Re-sync every stage's `enabled` flag from its CURRENT intensity. * * The cockpit-vision policy helpers (src/cockpitVisionPolicy.js) are pure * intensity math — they write `uniforms.intensity` directly and know * nothing about the enabled/intensity lockstep _setStageIntensity owns. * Without this sweep a stage the policy raised to 1 would stay DISABLED * and cockpit NVG/FLIR/CRT would render nothing at all. (Inert while the * chain is permanently enabled; load-bearing again once the explicit * scope frees the zero-intensity stages — see _initStages.) * @returns {void} */ _syncStagesEnabledFromIntensity() { this._visualEffects.syncStagesEnabledFromIntensity(); } /** * Contacts-scoped detection (owner playtest 2026-08-18: "when you click on * Contacts, detections should just turn on, and they should stay on in * Cockpit or in third-person tracking inside Contacts"). * * The scope is the CONTACTS SESSION, not Cockpit. Cockpit enter/exit and * third-person tracking are moves WITHIN that session and deliberately do not * touch detection — an earlier build hooked this to cockpit enter/exit, which * is exactly what turned detections off when the owner left the cockpit. * * Called from `_syncContextModeButtons`, the single funnel every * `_contextMode` mutation routes through, and gated on the transaction having * SETTLED (`!_contextModeChanging`) so a failed activation can never strand * detection on. * @returns {void} */ _syncContactsDetection() { if (this._contextModeChanging) return; const result = applyContactsDetection({ active: this._contextMode === 'flights', restore: this._contactsDetectionRestore, // A map style picked DURING the session owns detection on the way out — // its auto-enable preset is younger than the entry snapshot. styleOwnsDetection: !this._detectionUserOverridden && Boolean(STYLE_PRESET_DEFAULTS[this.activeStyle]?.detection), // The snapshot must cover everything activation mutates — the preset // writes DENSITY as well as mode, so a mode-only snapshot returned // OFF @ 25% as OFF @ 75% and the next manual enable came back Dense. getState: () => { const state = this.getDetectionState(); return { mode: state.detectionMode, densityPct: state.densityPct }; }, // Owner playtest: the force-on lands on the tactical look the military // styles apply — the SAME preset object — not on whatever profile the // operator last happened to leave detection at. applyPreset: () => this._applyDetectionPreset(MILITARY_DETECTION_PRESET), // The preset applier IS the state replayer: same density-then-mode order, // same slider writes, so a restore round-trips exactly. restoreState: (state) => this._applyDetectionPreset(state), }); const hadOwnership = Boolean(this._contactsDetectionRestore); this._contactsDetectionRestore = result.restore; // Serialization reads that ownership: while Contacts holds it the link // carries the SAVED snapshot, and once released it carries live state. The // share cache therefore goes stale on any ownership transition, whether or // not the detection engine itself moved — and it does not always move. // Exiting while a military style owns detection returns changed:false (the // style's preset already matches), and returning early there left a copied // link claiming the operator's pre-Contacts values while the map showed // Dense @ 75%. if (!shareCacheNeedsHeal({ changed: result.changed, hadOwnership, hasOwnership: Boolean(result.restore), })) return; if (result.changed) this._syncDetectionUiFromEngine(); this._syncShareState(); } /** Apply a temporary cockpit-only CRT/NVG/FLIR/NOIR post-process override. */ _setCockpitVision(mode, active, { revealParameters = false } = {}) { const next = active ? normalizeCockpitVisionMode(mode) : 'optical'; if (!this.stages) return; if (!active) { if (this._cockpitVisionRestore) { for (const [name, intensity] of Object.entries(this._cockpitVisionRestore)) { if (this.stages[name]) this._setStageIntensity(this.stages[name], intensity); } } this._cockpitVisionRestore = null; this._cockpitVisionMode = 'optical'; this._syncIrBoost(); // Cockpit exit: fall back to the map preset's IR state this._updateSliderPanel(this.activeStyle, { reveal: false }); this._revealCockpitStyleParameters({ openDisplay: revealParameters }); return; } if (!this._cockpitVisionRestore) { this._cockpitVisionRestore = captureCockpitVisionBaseline(this.stages, this.transitions); } if (next === 'optical') { applyCockpitVisionStageIntensities(this.stages, next, this._cockpitVisionRestore); this._syncStagesEnabledFromIntensity(); this._cockpitVisionMode = next; this._syncIrBoost(); this._updateSliderPanel(this.activeStyle, { reveal: false }); this._revealCockpitStyleParameters({ openDisplay: revealParameters }); return; } const target = applyCockpitVisionStageIntensities(this.stages, next, this._cockpitVisionRestore); this._syncStagesEnabledFromIntensity(); this._cockpitVisionMode = next; this._syncIrBoost(); // Cockpit vision override ('nvg'/'thermal' boost; CRT/NOIR clear) this._updateSliderPanel(target || null, { reveal: false }); this._revealCockpitStyleParameters({ openDisplay: revealParameters }); } /** IR hot-target boost (owner playtest 2026-08-16): under the luminance- * mapped NVG/FLIR looks the 3D fleets flip to flat white so contacts read * HOT instead of vanishing mid-gray; restored when the look exits. The * EFFECTIVE look is Cockpit's vision override while Cockpit is active * ('nvg'/'thermal', which can differ from the map preset in BOTH * directions), otherwise the map preset ('surveillance'/'thermal'). */ _syncIrBoost() { const cockpitMode = this.cockpitView?.active ? this._cockpitVisionMode : null; const effective = cockpitMode && cockpitMode !== 'optical' ? cockpitMode : this.activeStyle; const irBoost = effective === 'surveillance' || effective === 'thermal' || effective === 'nvg'; this._dataManager?.setLayerParams('flights', { irBoost }); this._dataManager?.setLayerParams('military', { irBoost }); // Fog blends distant geometry toward an effectively-BLACK color in this // app (the Cesium globe is hidden), so beyond ~100 km every 3D aircraft // fogs to a black silhouette — lighting and shaders can't reach past it // (owner cockpit-FLIR field rounds, 2026-08-16). IR sensors see through // haze, so the boost styles simply turn fog off; the prior state restores // on exit. Transition-guarded so repeated syncs don't clobber the saved value. const scene = this.viewer?.scene; if (scene?.fog && irBoost !== this._irBoostActive) { this._irBoostActive = irBoost; if (irBoost) { this._irFogWasEnabled = scene.fog.enabled; scene.fog.enabled = false; } else if (this._irFogWasEnabled != null) { scene.fog.enabled = this._irFogWasEnabled; this._irFogWasEnabled = null; } scene.requestRender?.(); } } /** Keep Cockpit's inherited label and restore target aligned with the active map preset. */ _syncCockpitInheritedStyle() { if (!this.cockpitView?.active || !this.stages) return; this._cockpitVisionRestore = Object.fromEntries( Object.keys(this.stages).map((name) => [name, name === this.activeStyle ? 1 : 0]), ); for (const name of Object.keys(this.stages)) this.transitions.delete(name); this.cockpitView.setVisionMode(this.cockpitView.visionMode); } /** Reveal shared style parameters, optionally opening Cockpit Display first. */ _revealCockpitStyleParameters({ openDisplay = false } = {}) { if (!this.cockpitView?.active || !this._sliderPanel?.classList.contains('active')) return; if (openDisplay && this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') !== 'true') { this._setCockpitDisclosure?.('display', true); return; } if (this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') !== 'true') return; this._sliderPanel.classList.remove('collapsed'); this._syncPanelCollapseButton(this._sliderPanel); requestAnimationFrame(() => requestAnimationFrame(() => { this._sliderPanel?.scrollIntoView?.({ block: 'nearest' }); })); } /** * Configures Cesium's built-in bloom stage and adds a custom unsharp-mask * sharpen stage to the post-process pipeline. Both start disabled. * @returns {void} */ _initBloomSharpen() { this._visualEffects.initPostProcess(this._sharpenSlider ? parseInt(this._sharpenSlider.value, 10) / 100 : 0.6); } /** * Reads the current bloom intensity percentage from the effects controller. * @returns {number} Clamped bloom intensity (0-200). */ _getBloomIntensity() { return this._visualEffects.bloomIntensity; } /** * Enables or disables the Cesium bloom stage based on both the user toggle * and whether the computed strength exceeds the perceptual threshold (0.06). * @returns {void} */ _syncBloomStageEnabled() { this._visualEffects.syncBloomEnabled(); } /** * 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 } = {}) { governorRequestRender('bloom'); const clamped = clampBloomIntensity(intensity); if (this._bloomSlider) this._bloomSlider.value = String(clamped); if (this._bloomSliderValue) this._bloomSliderValue.textContent = `${clamped}%`; this._applyBloomIntensity(clamped); if (syncShare) this._syncShareState(); } /** * Maps a bloom intensity percentage to Cesium bloom stage uniforms. * Uses smoothstep easing (Hermite interpolation: 3t^2 - 2t^3) to * produce a perceptually linear glow ramp from zero to full strength. * @param {number} intensity - Bloom intensity percentage (0-200). * @returns {void} */ _applyBloomIntensity(intensity) { this._visualEffects.applyBloomIntensity(intensity); } /** * Toggles bloom on/off, syncs button state, and reveals/hides the intensity slider row. * @param {boolean} enabled - Whether bloom should be active. * @returns {void} */ _setBloomEnabled(enabled) { governorRequestRender('bloom'); this._visualEffects.setBloomEnabled(enabled); this._syncBloomStageEnabled(); this._bloomBtn.classList.toggle('active', this.bloomEnabled); this._bloomSliderRow.classList.toggle('visible', this.bloomEnabled); if (this.bloomEnabled) { this._applyBloomIntensity(this._getBloomIntensity()); } this._syncShareState(); this._layoutRightPanels(); } /** * Maps a normalized sharpen value (0-1) to the unsharp-mask `amount` uniform. * Range: 0.1 (subtle) to 2.1 (aggressive edge enhancement). * @param {number} val - Normalized sharpen intensity (0.0 to 1.0). * @returns {void} */ _applySharpenIntensity(val) { this._visualEffects.applySharpenIntensity(val); } /** * Toggles sharpening on/off, syncs button state, and reveals/hides the intensity slider row. * @param {boolean} enabled - Whether sharpening should be active. * @returns {void} */ _setSharpenEnabled(enabled) { governorRequestRender('sharpen'); this._visualEffects.setSharpenEnabled(enabled); this._sharpenBtn.classList.toggle('active', this.sharpenEnabled); if (this._sharpenSliderRow) { this._sharpenSliderRow.classList.toggle('visible', this.sharpenEnabled); } if (this.sharpenEnabled && this._sharpenSlider) { this._applySharpenIntensity(parseInt(this._sharpenSlider.value, 10) / 100); } this._syncShareState(); this._layoutRightPanels(); } /** * 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._applicationShortcuts?.destroy(); this._applicationShortcuts = bindApplicationShortcuts({ documentRef: document, searchInput: this._locationSearch, actions: { setStyle: (style) => this.setStyle(style), dismissSearch: () => { if (this._locationSearch.classList.contains('expanded')) { this._locationSearch.classList.remove('expanded'); this._locationSearch.value = ''; this._locationSearch.blur(); } }, toggleHud: () => { this.shareLinkManager?.claimRestoreLane?.('visual'); this.hud.toggle(); this._updateHudButtonState(); this._syncShareState(); }, toggleOrbit: () => this._toggleOrbit(), toggleCleanView: () => this.toggleCleanView(), toggleLayers: () => document.getElementById('data-panel').classList.toggle('active'), cycleDetection: () => { this.shareLinkManager?.claimRestoreLane?.('visual'); this._detectionUserOverridden = true; cycleDetectionMode(); this._syncShareState(); }, toggleCctv: () => this._toggleCctvEnabled(), }, }); this._displayControls?.destroy(); this._displayControls = bindDisplayControls({ elements: { styleButtons: document.querySelectorAll('.style-btn'), bloomButton: this._bloomBtn, bloomSlider: this._bloomSlider, sharpenButton: this._sharpenBtn, sharpenSlider: this._sharpenSlider, scopeButton: this._scopeBtn, scopeFeatherSlider: this._scopeFeatherSlider, hudLayout: this._hudLayoutSelect, hudButton: this._hudBtn, cleanViewButton: this._cleanViewBtn, cleanViewExitButton: this._cleanViewExitBtn, densitySlider: this._detectionDensitySlider, detectionButton: this._detectionBtn, allocationButtons: this._detectionAllocationBtns, fadeSliders: [this._detectionFadeSlider, this._detectionOpacitySlider], celestialButton: this._celestialBtn, modelsButton: this._models3dBtn, modelModeButtons: this._models3dBtn ? this._models3dModeBtns : [], }, actions: { setStyle: (style) => this.setStyle(style), toggleBloom: () => { this.shareLinkManager?.claimRestoreLane?.('visual'); this._setBloomEnabled(!this.bloomEnabled); }, setBloomIntensity: (value) => { this.shareLinkManager?.claimRestoreLane?.('visual'); this._setBloomIntensity(value); }, toggleSharpen: () => { this.shareLinkManager?.claimRestoreLane?.('visual'); this._setSharpenEnabled(!this.sharpenEnabled); }, toggleScope: () => { this.shareLinkManager?.claimRestoreLane?.('visual'); const next = !isScopeMaskEnabled(); setScopeMaskEnabled(next); this._scopeBtn.classList.toggle('active', next); this._scopeBtn.setAttribute('aria-pressed', String(next)); this._syncShareState(); }, setScopeFeather: (value) => { this.shareLinkManager?.claimRestoreLane?.('visual'); const pct = Math.max(0, Math.min(100, value || 0)); if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; setScopeMaskFeather(pct / 100); this._syncShareState(); }, setSharpenIntensity: (pct) => { this.shareLinkManager?.claimRestoreLane?.('visual'); if (this._sharpenSliderValue) this._sharpenSliderValue.textContent = `${pct}%`; this._applySharpenIntensity(pct / 100); this._syncShareState(); }, setHudLayout: (value) => { this.shareLinkManager?.claimRestoreLane?.('visual'); this._setHudVariant(value); }, toggleCleanView: () => this.toggleCleanView(), exitCleanView: () => this.toggleCleanView(false), setDensity: (value) => { this.shareLinkManager?.claimRestoreLane?.('visual'); this._detectionUserOverridden = true; const pct = canonicalizeDensity(value); this._detectionDensitySlider.value = String(pct); if (this._detectionDensityValue) this._detectionDensityValue.textContent = `${pct}%`; this._applyDetectionDensityFromUi(); this._syncShareState(); }, setAllocation: (value) => { this.shareLinkManager?.claimRestoreLane?.('visual'); this._detectionUserOverridden = true; this._setDetectionAllocation(value); }, setFade: () => { this.shareLinkManager?.claimRestoreLane?.('visual'); this._applyDetectionFadeFromUi(); this._syncShareState(); }, toggleCelestial: () => { const ringIsVisible = !!this.celestialRing?.visible; if (!this.celestialRingEnabled || !ringIsVisible) { this.setCelestialRingEnabled(true, { focus: true }); } else { this.setCelestialRingEnabled(false); } }, toggleHud: () => { this.shareLinkManager?.claimRestoreLane?.('visual'); this.hud.toggle(); this._updateHudButtonState(); this._syncShareState(); }, cycleDetection: () => { this.shareLinkManager?.claimRestoreLane?.('visual'); this._detectionUserOverridden = true; cycleDetectionMode(); this._syncShareState(); }, toggleModels: () => { this._setModels3dEnabled(!this._models3dEnabled); this._syncModels3dModeRow(); }, setModelsMode: (mode) => this._setModels3dMode(mode), }, }); } /** * 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; this._mapSourceControls?.destroy(); this._mapSourceControls = createMapSourceControls({ container: this._mapStackChips, statusElement: this._mapStackStatus, controller: this.mapStackController, 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), }); } /** * 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} */ 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); } /** * Reads and canonicalizes the five-stop density control. The engine derives * Sparse/Balanced/Dense from the same stop. * @returns {void} */ _applyDetectionDensityFromUi() { if (!this._detectionDensitySlider) return; const pct = canonicalizeDensity(this._detectionDensitySlider.value); this._detectionDensitySlider.value = String(pct); if (this._detectionDensityValue) this._detectionDensityValue.textContent = `${pct}%`; setDetectionTuning({ densityPct: pct }); this._updateDetectionButton(getDetectionMode()); } /** Apply responsive keyhole fade controls from normalized UI percentages. */ _applyDetectionFadeFromUi() { const fadePct = Math.max(0, Math.min(40, Math.round(Number(this._detectionFadeSlider?.value) || 0))); const outsideOpacityValue = this._detectionOpacitySlider?.value; const outsideOpacityPct = Math.max( 0, Math.min(100, Math.round(outsideOpacityValue == null ? 3 : Number(outsideOpacityValue) || 0)), ); if (this._detectionFadeSlider) this._detectionFadeSlider.value = String(fadePct); if (this._detectionFadeValue) this._detectionFadeValue.textContent = `${fadePct}%`; if (this._detectionOpacitySlider) this._detectionOpacitySlider.value = String(outsideOpacityPct); if (this._detectionOpacityValue) this._detectionOpacityValue.textContent = `${outsideOpacityPct}%`; setKeyholeFadeTuning({ fadeRatio: fadePct / 100, outsideOpacity: outsideOpacityPct / 100, }); this.viewer.scene.requestRender?.(); } _setDetectionAllocation(strategy, { syncShare = true, persist = true } = {}) { const raw = String(strategy || '').trim().toUpperCase(); if (!ALLOCATION_STRATEGIES.includes(raw)) return false; const normalized = normalizeAllocationStrategy(raw); this._detectionAllocationPreference = normalized; setDetectionTuning({ allocationStrategy: normalized }); for (const button of this._detectionAllocationBtns) { const active = button.dataset.allocation === normalized; button.classList.toggle('active', active); button.setAttribute('aria-checked', String(active)); } if (persist) { try { localStorage.setItem(DETECTION_ALLOCATION_STORAGE_KEY, normalized); } catch { /* best effort */ } } if (syncShare) this._syncShareState(); return true; } _syncDetectionUiFromEngine() { const tuning = getDetectionTuning(); if (this._detectionDensitySlider) this._detectionDensitySlider.value = String(tuning.densityPct); if (this._detectionDensityValue) this._detectionDensityValue.textContent = `${tuning.densityPct}%`; this._setDetectionAllocation(tuning.allocationStrategy, { syncShare: false, persist: false }); const fadeTuning = getKeyholeFadeTuning(); if (this._detectionFadeSlider) this._detectionFadeSlider.value = String(Math.round(fadeTuning.fadeRatio * 100)); if (this._detectionOpacitySlider) { this._detectionOpacitySlider.value = String(Math.round(fadeTuning.outsideOpacity * 100)); } this._applyDetectionFadeFromUi(); this._updateDetectionButton(getDetectionMode()); } /** * Activates a detection overlay mode by label (e.g. 'OFF', 'SPARSE', 'PANOPTIC'). * @param {string} modeLabel - Detection mode label to set. * @returns {void} */ _setDetectionMode(modeLabel) { if (!modeLabel) return; setDetectionModeByLabel(modeLabel); this._syncDetectionUiFromEngine(); this._syncShareState(); } /** * Switches the HUD layout variant (e.g. 'tactical', 'minimal') and syncs * the layout dropdown if present. * @param {string} variantName - HUD variant identifier. * @returns {void} */ _setHudVariant(variantName) { if (!variantName) return; this.hud.setVariant(variantName); if (this._hudLayoutSelect && this._hudLayoutSelect.value !== this.hud.getVariant()) { this._hudLayoutSelect.value = this.hud.getVariant(); } this._syncShareState(); this._scheduleAdaptivePanelLayout({ settle: true }); } /** * Keeps both responsive panel lanes and Cockpit's utility strip on the same * measured layout commit. HUD visibility transitions can outlive the first * animation frame, so variant changes receive one bounded settling pass. * @param {{settle?: boolean}} [options] Whether to remeasure after transitions. * @returns {void} */ _scheduleAdaptivePanelLayout({ settle = false } = {}) { this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true }); this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true }); this.cockpitView?.scheduleContextLayout(); if (!settle) return; clearTimeout(this._adaptivePanelSettleTimer); this._adaptivePanelSettleTimer = setTimeout(() => { this._adaptivePanelSettleTimer = null; this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true }); this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true }); this.cockpitView?.scheduleContextLayout(); }, COCKPIT_LAYOUT_SETTLE_MS); } /** * Applies preset defaults (bloom, sharpen, shader uniforms, HUD variant) * when a military-class style (CRT, NVG, FLIR) is selected. Does nothing * for styles without entries in STYLE_PRESET_DEFAULTS. * @param {string} styleName - The style whose defaults to apply. * @returns {void} */ _applyStylePresetDefaults(styleName) { const preset = STYLE_PRESET_DEFAULTS[styleName]; if (!preset) return; if (preset.styleParams && typeof preset.styleParams === 'object') { for (const [targetStyle, params] of Object.entries(preset.styleParams)) { const stage = this.stages[targetStyle]; if (!stage || !params || typeof params !== 'object') continue; for (const [uniformName, uniformValue] of Object.entries(params)) { if (stage.uniforms[uniformName] === undefined) continue; stage.uniforms[uniformName] = uniformValue; governorRequestRender('style-param'); } } } const bloomInput = preset.bloom || {}; if (typeof bloomInput.intensity === 'number' && this._bloomSlider) { this._setBloomIntensity(clampBloomIntensity(bloomInput.intensity), { syncShare: false }); } if (typeof bloomInput.enabled === 'boolean') { this._setBloomEnabled(bloomInput.enabled); } const sharpenInput = preset.sharpen || {}; if (typeof sharpenInput.intensity === 'number' && this._sharpenSlider) { const sharpenPct = Math.max(0, Math.min(100, Math.round(sharpenInput.intensity))); this._sharpenSlider.value = String(sharpenPct); this._sharpenSliderValue.textContent = `${sharpenPct}%`; this._applySharpenIntensity(sharpenPct / 100); } if (typeof sharpenInput.enabled === 'boolean') { this._setSharpenEnabled(sharpenInput.enabled); } if (preset.hudVariant) { this._setHudVariant(preset.hudVariant); } if (typeof preset.hudVisible === 'boolean') { this.hud.setMode(preset.hudVisible ? 'on' : 'off'); this._updateHudButtonState(); } // A style may set a detection default (e.g. military styles -> Dense for // the "epic" default view), but ONLY if the user hasn't manually changed // detection this session. Detection is user-controlled and persists across // style switches, so an explicit Sparse/Off choice is never stomped. if (preset.detection && !this._detectionUserOverridden) { this._applyDetectionPreset(preset.detection); } } /** * Apply a detection preset's density and mode through the real UI path. * * Deliberately does NOT consult `_detectionUserOverridden` — the CALLER owns * that decision. The style path checks it (an explicit Sparse/Off must * survive a style switch); Cockpit entry does not (owner: detection is on in * the cockpit "regardless"). * @param {{mode?: string, densityPct?: number}} det Preset detection config. * @returns {void} */ _applyDetectionPreset(det) { if (!det) return; if (typeof det.densityPct === 'number' && this._detectionDensitySlider) { const pct = canonicalizeDensity(det.densityPct); this._detectionDensitySlider.value = String(pct); if (this._detectionDensityValue) this._detectionDensityValue.textContent = `${pct}%`; this._applyDetectionDensityFromUi(); } if (det.mode) this._setDetectionMode(String(det.mode).toUpperCase()); } /** * Applies the global post-processing baseline (GLOBAL_POST_DEFAULTS) at * startup before any share-link restore runs. Sets bloom, sharpen, HUD, * and detection to their factory defaults. * @returns {void} */ _applyGlobalPostDefaults() { const defaults = GLOBAL_POST_DEFAULTS; if (typeof defaults.bloom?.intensity === 'number' && this._bloomSlider) { this._setBloomIntensity(clampBloomIntensity(defaults.bloom.intensity), { syncShare: false }); } if (typeof defaults.bloom?.enabled === 'boolean') { this._setBloomEnabled(defaults.bloom.enabled); } if (typeof defaults.sharpen?.intensity === 'number' && this._sharpenSlider) { const sharpenPct = Math.max(0, Math.min(100, Math.round(defaults.sharpen.intensity))); this._sharpenSlider.value = String(sharpenPct); this._sharpenSliderValue.textContent = `${sharpenPct}%`; this._applySharpenIntensity(sharpenPct / 100); } if (typeof defaults.sharpen?.enabled === 'boolean') { this._setSharpenEnabled(defaults.sharpen.enabled); } if (defaults.hudVariant) { this._setHudVariant(defaults.hudVariant); } if (typeof defaults.hudVisible === 'boolean') { this.hud.setMode(defaults.hudVisible ? 'on' : 'off'); this._updateHudButtonState(); } if (defaults.detectionMode) { this._setDetectionMode(defaults.detectionMode); } if (typeof defaults.detectionDensity === 'number' && this._detectionDensitySlider) { const density = canonicalizeDensity(defaults.detectionDensity); this._detectionDensitySlider.value = String(density); this._detectionDensityValue.textContent = `${density}%`; this._applyDetectionDensityFromUi(); } this._setDetectionAllocation( this._detectionAllocationPreference || defaults.detectionAllocation || 'ELASTIC', { syncShare: false, persist: false }, ); if (this._detectionFadeSlider) { this._detectionFadeSlider.value = String(defaults.detectionFadePct ?? 7); } if (this._detectionOpacitySlider) { this._detectionOpacitySlider.value = String(defaults.detectionOutsideOpacityPct ?? 1); } this._applyDetectionFadeFromUi(); if (typeof defaults.celestialRing === 'boolean') { this.setCelestialRingEnabled(defaults.celestialRing, { syncShare: false, focus: false }); } } /** * Pushes the current visual state (bloom, sharpen, HUD, detection) to * the ShareLinkManager so the URL hash stays in sync. * @returns {void} */ /** * Detection as a DURABLE preference, for serialization into a share link. * * While Contacts is active it OWNS detection and forces Dense @ 75%. That is * a session-scoped override, not something the operator chose: it is undone * verbatim on deactivation. Serializing the forced values shipped a link that * pinned Dense @ 75% on the recipient — as a durable preference, with no * Contacts mode present to explain or undo it — even though the author's own * setting was (say) OFF @ 50%. Publish what deactivation would restore. * * `_contactsDetectionRestore` is exactly that snapshot and is null whenever * Contacts does not own detection, so the live values are used normally. */ _shareableDetectionState() { return shareableDetectionState({ owned: this._contactsDetectionRestore, liveMode: getDetectionMode(), liveDensityPct: parseInt(this._detectionDensitySlider?.value || '50', 10), }); } _syncShareState() { const detection = this._shareableDetectionState(); this.shareLinkManager.onToggleChange(this.bloomEnabled, this.sharpenEnabled, { bloomIntensity: this._getBloomIntensity(), bloomVersion: BLOOM_SCALE_VERSION, sharpenIntensity: parseInt(this._sharpenSlider?.value || '49', 10), hudVariant: this.hud.getVariant(), hudVisible: this.hud.visible, detectionMode: detection.mode, detectionDensity: detection.densityPct, detectionAllocation: getDetectionTuning().allocationStrategy, detectionFadePct: parseInt(this._detectionFadeSlider?.value || '7', 10), detectionOutsideOpacityPct: parseInt(this._detectionOpacitySlider?.value || '1', 10), celestialRingEnabled: this.celestialRingEnabled, scopeEnabled: isScopeMaskEnabled(), scopeFeatherPct: Math.round(getScopeMaskFeather() * 100), // null when adaptive — the share layer omits `sce` entirely in that case. scopeTerminusPct: getScopeTerminusOverride() == null ? null : Math.round(getScopeTerminusOverride() * 100), mapStack: this.mapStackController?.getActiveId?.() || 'photoreal', }); } /** * Updates the traffic sync status chip with loading phase label and progress. * Auto-hides after 1.5s when loading completes; stays visible while busy. * @param {boolean} [forceShow=false] - Force the chip visible regardless of busy state. * @returns {void} */ _updateTrafficSyncChip(forceShow = false, now = performance.now()) { if (!this._trafficSyncChip || !this._trafficSyncLabel || !this._trafficSyncProgress) return; const layers = this._dataManager?.getAll?.(); const traffic = Array.isArray(layers) ? layers.find((layer) => layer.id === 'traffic') : null; this._trafficSyncFeedbackState = reduceTrafficSyncFeedback( this._trafficSyncFeedbackState, { enabled: traffic?.enabled === true, stats: traffic?.stats || {}, forceShow }, now, ); const presentation = this._trafficSyncFeedbackState; // setSplitFlapText carries the same unchanged-text guard internally, and // the flap keeps textContent equal to the settled label throughout, so // this stays a no-op on the repeat ticks exactly as it did before. if (presentation.label) setSplitFlapText(this._trafficSyncLabel, presentation.label); // Written on every change INCLUDING the empty settled value — the reducer // clears the progress number once the sync lands, and a truthiness guard // here would strand the last "..." beside the settled label. if (this._trafficSyncProgress.textContent !== presentation.progressText) { this._trafficSyncProgress.textContent = presentation.progressText; } this._trafficSyncChip.classList.toggle('visible', presentation.visible); } /** * Initializes panel collapse buttons and restores persisted collapsed state. * Also sets up hover-expand behavior for the style presets and location bar panels. * @returns {void} */ _initPanelChrome() { for (const control of this._panelDisclosureControls || []) control.destroy(); this._panelDisclosureControls = []; const targets = new Map(); document.querySelectorAll('.panel-collapse-btn[data-collapse-target]').forEach((button) => { const targetId = button.dataset.collapseTarget; if (!targetId) return; if (!targets.has(targetId)) targets.set(targetId, []); targets.get(targetId).push(button); }); for (const [targetId, buttons] of targets) { const panel = document.getElementById(targetId); if (!panel) continue; this._panelDisclosureControls.push(bindPanelDisclosure({ panel, buttons, onChange: (collapsed, options) => this.setPanelCollapsed(targetId, collapsed, options), onEscape: (event) => this._collapsePanelOnEscape(event, targetId), })); this._restorePanelCollapsedState(targetId, { allowStored: !this._initialShareState, }); } // The command dock always starts compact; either wing reveals on hover, // focus, or click and collapses again after the interaction moves away. this.setPanelCollapsed('control-panel', true, { syncShare: false, persist: false }); this.setPanelCollapsed('location-bar', true, { syncShare: false, persist: false }); this._initAutoHoverPanel('control-panel', { openDelayMs: 140, closeDelayMs: 420 }); this._initAutoHoverPanel('location-bar', { openDelayMs: 140, closeDelayMs: 420 }); this._initCommandDockPins(); this._initCommandDockTrayMetrics(); this._maybeNotifyLayoutReset(); } /** * Collapses the nearest expanded panel that owns keyboard focus on Escape. * Nested panels consume the event first, so one key closes one level and * returns focus to that level's disclosure. If Escape was pressed on the * disclosure itself, remove focus after closing so the collapsed button does * not keep a stale keyboard ring. * @param {KeyboardEvent} event - Candidate Escape key event. * @param {string} panelId - Collapsible panel containing the listener. * @returns {boolean} Whether this panel handled the key. */ _collapsePanelOnEscape(event, panelId) { return collapsePanelOnEscape(event, { panel: document.getElementById(panelId), onChange: (collapsed, options) => this.setPanelCollapsed(panelId, collapsed, options), beforeCollapse: () => { if (panelId !== 'location-bar' || !this._locationSearch) return; this._locationSearch.classList.remove('expanded'); this._locationSearch.value = ''; this._locationSearch.blur(); }, }); } /** * Allows either command-dock tray to remain open until explicitly unpinned. * Both trays may be pinned; transient and error trays stack above them. * @returns {void} */ _initCommandDockPins() { document.querySelectorAll('.dock-pin-btn[data-pin-target]').forEach((button) => { button.addEventListener('click', (event) => { event.stopPropagation(); const panelId = button.dataset.pinTarget; this._setCommandDockPanelPinState(panelId); }); }); } _setCommandDockPanelPinState(panelId, pin, { restore = false, persist = true, syncShare = true, } = {}) { const panelEl = document.getElementById(panelId); const button = document.querySelector(`.dock-pin-btn[data-pin-target="${panelId}"]`); if (!panelEl || !button) return undefined; const shouldPin = typeof pin === 'boolean' ? pin : !panelEl.classList.contains('dock-pinned'); panelEl.classList.toggle('dock-pinned', shouldPin); button.setAttribute('aria-pressed', String(shouldPin)); document.querySelectorAll('#command-dock .dock-pinned-top').forEach((pinnedPanel) => { pinnedPanel.classList.remove('dock-pinned-top'); }); if (shouldPin) { panelEl.classList.add('dock-pinned-top'); this.setPanelCollapsed(panelId, false, { explicit: !restore, restore, persist, syncShare: false, }); } else { const remainingPinnedPanel = document.querySelector('#command-dock .dock-pinned'); remainingPinnedPanel?.classList.add('dock-pinned-top'); if (!restore && !panelEl.matches(':hover')) { this.setPanelCollapsed(panelId, true, { explicit: true, persist, syncShare: false, }); } } this._updateCommandDockTrayStack(); if (syncShare) { if (!restore) this.shareLinkManager?.claimRestoreLane?.('panel', panelId); this.shareLinkManager?.onPanelStateChange?.(); } return shouldPin; } /** * Tracks the live pinned-tray height so a hovered sibling can stack above it * without hardcoded content dimensions. * @returns {void} */ _initCommandDockTrayMetrics() { const dock = document.getElementById('command-dock'); if (!dock) return; this._commandDockTrayObserver?.disconnect?.(); if (typeof ResizeObserver === 'function') { this._commandDockTrayObserver = new ResizeObserver(() => this._updateCommandDockTrayStack()); dock.querySelectorAll('.dock-popover-content').forEach((tray) => { this._commandDockTrayObserver.observe(tray); }); } this._updateCommandDockTrayStack(); } /** * Writes each pinned tray height and their combined stack height as CSS * variables. The most recently pinned tray forms the upper level. * @returns {void} */ _updateCommandDockTrayStack() { const dock = document.getElementById('command-dock'); if (!dock) return; const locationPanel = dock.querySelector('#location-bar.dock-pinned:not(.collapsed)'); const presetsPanel = dock.querySelector('#control-panel.dock-pinned:not(.collapsed)'); const locationHeight = locationPanel?.querySelector('.dock-popover-content')?.getBoundingClientRect().height || 0; const presetsHeight = presetsPanel?.querySelector('.dock-popover-content')?.getBoundingClientRect().height || 0; const pinnedCount = Number(locationHeight > 0) + Number(presetsHeight > 0); const locationHeightPx = Math.ceil(locationHeight); const presetsHeightPx = Math.ceil(presetsHeight); const topPinnedPanel = dock.querySelector('.dock-pinned-top.dock-pinned:not(.collapsed)'); const lowerPinnedPanel = topPinnedPanel?.id === 'location-bar' ? presetsPanel : locationPanel; const lowerPinnedHeight = lowerPinnedPanel ?.querySelector('.dock-popover-content') ?.getBoundingClientRect().height || 0; const stackHeight = pinnedCount > 1 ? `calc(${locationHeightPx}px + ${presetsHeightPx}px + 1.2rem)` : `${locationHeightPx + presetsHeightPx}px`; dock.style.setProperty('--dock-location-pinned-height', `${locationHeightPx}px`); dock.style.setProperty('--dock-presets-pinned-height', `${presetsHeightPx}px`); dock.style.setProperty('--dock-lower-pinned-height', `${Math.ceil(lowerPinnedHeight)}px`); dock.style.setProperty('--dock-pinned-stack-height', stackHeight); dock.classList.toggle('dock-has-pinned-tray', pinnedCount > 0); dock.classList.toggle('dock-has-two-pinned-trays', pinnedCount > 1); } /** * One-time toast when stored v6 panel positions are superseded by the v7 * layout defaults (positions reset; collapsed states are preserved). * @returns {void} */ _maybeNotifyLayoutReset() { try { const marker = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.layoutResetNotified`; if (localStorage.getItem(marker)) return; localStorage.setItem(marker, '1'); const hadOldPositions = Object.keys(localStorage) .some((key) => key.startsWith('godsEyeView.v6.panelPos.')); if (hadOldPositions) { this._showToast('Panel layout updated — positions reset to new defaults'); } } catch { // storage unavailable } } /** * 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 } = {}) { const panel = document.getElementById(panelId); if (!panel) return; this._hoverPanelControls ??= new Map(); this._hoverPanelControls.get(panelId)?.destroy(); const controller = createHoverDisclosure({ panel, documentRef: document, disclosure: panel.querySelector(`[data-dock-toggle-target="${panelId}"]`), openDelayMs, closeDelayMs, isActive: () => !this._disposed, onChange: (collapsed, options) => this.setPanelCollapsed(panelId, collapsed, options), onEscape: (event) => this._collapsePanelOnEscape(event, panelId), focusTarget: panelId === 'control-panel' ? () => ( panel.querySelector('.map-stack-chip.active') || panel.querySelector('.map-stack-chip') ) : null, }); this._hoverPanelControls.set(panelId, controller); if (panelId === 'control-panel') { this._cancelMapSourceFocus?.(); this._cancelMapSourceFocus = controller.cancelPendingFocus; } } /** * Sets up drag-to-reposition for legacy floating controls. The right rail * and left accordion remain fixed so their HUD alignment is deterministic. * @returns {void} */ _initPanelDrag() { const dragSpecs = [ { id: 'pp-toggles', panel: this._ppToggles, handle: this._ppToggles?.querySelector('.panel-drag-handle.compact'), }, ].filter(Boolean); for (const spec of dragSpecs) { if (!spec.panel || !spec.handle) continue; this._restorePanelPosition(spec.id, spec.panel); this._makePanelDraggable(spec.id, spec.panel, spec.handle); } // Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its // full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and // the panel could still hang off the bottom (audit U2). Re-clamp on every size change. if (this._ppToggles && typeof ResizeObserver !== 'undefined') { this._draggableResizeObserver = new ResizeObserver(() => this._reclampDraggablePanels()); this._draggableResizeObserver.observe(this._ppToggles); } } _persistAwarenessSelection(event, cleared = false) { if (!this._dataManager) return; const origin = String(event?.detail?.origin || 'programmatic'); if (!isExplicitLayerStateOrigin(origin)) return; const layerId = String(event?.detail?.layerId || ''); const config = { flights: { key: 'selectedFlightsTrackingId', normalize: (value) => String(value ?? '').trim().toLowerCase() || null, }, military: { key: 'selectedMilitaryTrackingId', normalize: (value) => String(value ?? '').trim().toLowerCase() || null, }, satellites: { key: 'selectedSatTrackingId', normalize: (value) => { const candidate = Number(value); return Number.isFinite(candidate) && candidate > 0 ? Math.trunc(candidate) : null; }, }, }[layerId]; if (!config) return; const selectedValue = cleared ? null : config.normalize(event?.detail?.id); if (cleared || selectedValue === null) { this._dataManager.adoptLayerParams?.(layerId, { [config.key]: selectedValue, }, { origin }); return; } // A direct selection promotes a Context-owned tracker dependency into // durable visibility before its selected ID is normalized. Context exit // also keeps this adopted layer instead of tearing down the user's track. const visibilityAdopted = this._dataManager.adoptLayerVisibility?.( layerId, true, { origin, adoptedFromSelection: true }, ); if (visibilityAdopted === false) return; // Clear the prior family before publishing the replacement. Otherwise the // coordinator briefly sees two IDs and correctly treats them as an // ambiguous incoming state, which would discard the new durable target. for (const [otherLayerId, otherKey] of [ ['flights', 'selectedFlightsTrackingId'], ['military', 'selectedMilitaryTrackingId'], ['satellites', 'selectedSatTrackingId'], ]) { if (otherLayerId === layerId) continue; this._dataManager.setLayerParams(otherLayerId, { [otherKey]: null }, { origin }); } this._dataManager.adoptLayerParams?.(layerId, { [config.key]: selectedValue, }, { origin }); } /** * Connects the layer data manager for traffic sync, CCTV state subscription, * and layer enable/disable operations. * @param {object|null} dataManager - The DataManager instance, or null to detach. * @returns {void} */ attachDataManager(dataManager) { this._dataManager = dataManager || null; this.hud.attachDataManager(this._dataManager); this._updateTrafficSyncChip(); if (this._dataManagerUnsubscribe) { this._dataManagerUnsubscribe(); this._dataManagerUnsubscribe = null; } this._contextControls.connect(this._dataManager); if (typeof this._dataManager?.subscribe === 'function') { this._dataManagerUnsubscribe = this._dataManager.subscribe((change) => { this._loadingFeedbackEvent = change; this._updateGlobalLoadingFeedback(performance.now()); }); } this._updateGlobalLoadingFeedback(performance.now()); this._syncContextModeButtons(); this._cctvControls.connect(); this._radioControls.connect(); if (!this._awarenessSelectedHandler) { this._awarenessSelectedHandler = (event) => this._persistAwarenessSelection(event, false); this._awarenessClearedHandler = (event) => this._persistAwarenessSelection(event, true); window.addEventListener('gev:awareness-subject-selected', this._awarenessSelectedHandler); window.addEventListener('gev:awareness-subject-cleared', this._awarenessClearedHandler); } this._layerStateCoordinator?.destroy(); this._layerStateCoordinator = null; this._layerStateRestorePromise = null; if (this._dataManager) { this._layerStateCoordinator = new LayerStateCoordinator( this._dataManager, this.shareLinkManager, { onDurableStateChange: (state) => this._syncModels3dFromLayerState(state), onTrackingRestoreStatus: (result) => this._handleShareTrackingRestoreStatus(result), }, ); this._layerStateRestorePromise = this._layerStateCoordinator.start({ shareLayerState: this._initialShareState?.layerState || null, shareCreatedAtMs: this._initialShareState?.sharedAtMs ?? null, // Any valid camera/style share isolates recipient-local preferences, // including legacy and malformed-v2 layer payloads. allowLocalState: !this._initialShareState, }); if (this._initialShareSelectionSuperseded) { this._layerStateCoordinator.cancelPendingShareTracking( 'superseded-before-layer-coordinator-start', { clearSelection: true }, ); } void this._layerStateRestorePromise.then(() => { this._syncModels3dFromLayerState(this._layerStateCoordinator?.getDurableState()); }); } } _handleShareTrackingRestoreStatus(result) { if (!result || this._disposed) return; const trackingKey = `${result.layerId || ''}:${result.targetId ?? ''}`; if (result.classification === 'pending') { this._shareTrackingNoticeGeneration += 1; this._shareTrackingAcquiringKey = trackingKey; this._showGlobalStatusNotice('ACQUIRING', { state: 'acquiring', detail: `SHARED ${String(result.label || 'SUBJECT').toUpperCase()}`, persistent: true, }); return; } const ownsAcquiringNotice = this._shareTrackingAcquiringKey === trackingKey; if (ownsAcquiringNotice) { this._shareTrackingNoticeGeneration += 1; this._shareTrackingAcquiringKey = null; if (this._globalStatusNotice?.state === 'acquiring') { this._globalStatusNotice = null; this._updateGlobalLoadingFeedback(); } } if (result.classification === 'followed' || result.classification === 'cancelled') return; // A stale terminal result must never replace a newer target's acquisition. if (this._shareTrackingAcquiringKey) return; const noticeGeneration = ownsAcquiringNotice ? this._shareTrackingNoticeGeneration : ++this._shareTrackingNoticeGeneration; const subject = result.label || 'entity'; const message = result.classification === 'expired' ? `Shared ${subject} follow expired` : result.classification === 'source-unavailable' ? `Shared ${subject} could not be restored — feed unavailable` : `Shared ${subject} is unavailable`; const showAfterStartupCover = () => { requestAnimationFrame(() => { if (!canPresentDeferredStatusNotice( noticeGeneration, this._shareTrackingNoticeGeneration, this._disposed, )) return; const startupCover = document.getElementById('loading-screen'); if (!startupCover || getComputedStyle(startupCover).visibility === 'hidden') { this._showGlobalStatusNotice(message); return; } let fallbackTimer = null; const showOnce = () => { startupCover.removeEventListener('transitionend', showOnce); if (fallbackTimer) clearTimeout(fallbackTimer); if (canPresentDeferredStatusNotice( noticeGeneration, this._shareTrackingNoticeGeneration, this._disposed, )) this._showGlobalStatusNotice(message); }; startupCover.addEventListener('transitionend', showOnce, { once: true }); fallbackTimer = setTimeout(showOnce, 1000); }); }; if (this._resolveInitialShareRestore) { void this.initialRestorePromise.then(showAfterStartupCover); return; } showAfterStartupCover(); } get _contextMode() { return this._contextControls?._contextMode ?? null; } get _contextModeChanging() { return this._contextControls?._contextModeChanging ?? false; } get _preservePanelStateDuringLayerClear() { return this._contextControls?._preservePanelStateDuringLayerClear ?? false; } _initGlobalContextPanel() { this._contextControls = new ContextControls({ elements: { _globalContextPanel: document.getElementById('global-context-panel'), _globalContextFlightsBtn: this._globalContextFlightsBtn, _globalContextMissionsBtn: this._globalContextMissionsBtn, _contextModeStandby: this._contextModeStandby, _contextFlightsView: this._contextFlightsView, _contextMissionsView: this._contextMissionsView, _installationsSearchBtn: this._installationsSearchBtn, }, installations: militaryInstallationsLayer, 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); }, }, }); } _runUserFacingContextAction(...args) { return this._contextControls?._runUserFacingContextAction(...args); } _waitForContextLayerSettlement(...args) { return this._contextControls?._waitForContextLayerSettlement(...args); } _syncContextModeButtons(...args) { return this._contextControls?._syncContextModeButtons(...args); } /** Wire the independent Radio companion controls. */ _initRadioPanel() { this._radioControls?.destroy(); this._radioControls = new RadioControls({ elements: { _cockpitDisplayPanel: this._cockpitDisplayPanel, _cockpitDisplayToggleBtn: this._cockpitDisplayToggleBtn, _cockpitRadioEnableBtn: this._cockpitRadioEnableBtn, _cockpitRadioNextBtn: this._cockpitRadioNextBtn, _cockpitRadioPanel: this._cockpitRadioPanel, _cockpitRadioPlayBtn: this._cockpitRadioPlayBtn, _cockpitRadioPrevBtn: this._cockpitRadioPrevBtn, _cockpitRadioStation: this._cockpitRadioStation, _cockpitRadioToggleBtn: this._cockpitRadioToggleBtn, _cockpitRadioVolume: this._cockpitRadioVolume, _cockpitRadioVolumeValue: this._cockpitRadioVolumeValue, _cockpitUtilityControls: this._cockpitUtilityControls, _contextRadioDetailsBtn: this._contextRadioDetailsBtn, _contextRadioDock: this._contextRadioDock, _contextRadioMini: this._contextRadioMini, _contextRadioMiniCloseBtn: this._contextRadioMiniCloseBtn, _contextRadioMiniEnableBtn: this._contextRadioMiniEnableBtn, _contextRadioMiniNextBtn: this._contextRadioMiniNextBtn, _contextRadioMiniPlayBtn: this._contextRadioMiniPlayBtn, _contextRadioMiniPrevBtn: this._contextRadioMiniPrevBtn, _contextRadioMiniStation: this._contextRadioMiniStation, _contextRadioMiniVolume: this._contextRadioMiniVolume, _contextRadioMiniVolumeValue: this._contextRadioMiniVolumeValue, _contextRadioToggleBtn: this._contextRadioToggleBtn, _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, _radioTuner: this._radioTuner, _radioTunerBandLabel: this._radioTunerBandLabel, _radioTunerNeedle: this._radioTunerNeedle, _radioTunerSlider: this._radioTunerSlider, _radioTunerStation: this._radioTunerStation, _radioTunerValue: this._radioTunerValue, _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(), preservePanelStateDuringClear: () => this._preservePanelStateDuringLayerClear, scheduleLayout: () => this._scheduleRightPanelLayout(), }, }); } _setCockpitDisclosure(...args) { return this._radioControls?._setCockpitDisclosure?.(...args); } _setRadioDisclosure(...args) { return this._radioControls?._setRadioDisclosure?.(...args); } _syncContextRadioLauncherState(...args) { return this._radioControls?._syncContextRadioLauncherState?.(...args); } _renderRadioState(...args) { return this._radioControls?._renderRadioState?.(...args); } /** * 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 camera panel controls from the existing camera port and application actions. */ _initCctvPanel() { 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, _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, _cctvSyncChip: this._cctvSyncChip, _cctvSyncLabel: this._cctvSyncLabel, _cctvSyncProgress: this._cctvSyncProgress, }, 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(), setSplitFlapText, }, }); } /** * 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} True if the layer is now in the requested state. */ async _toggleCctvEnabled(forceState) { if (this._disposed) return false; if (!this._dataManager || !this._dataManager.layers?.has('cctv')) { this._showToast('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; } /** * Returns the versioned localStorage key for a panel's saved position. * @param {string} panelId - DOM id of the panel. * @returns {string} localStorage key. */ _panelStorageKey(panelId) { return `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelPos.${panelId}`; } /** * Returns the versioned localStorage key for a panel's collapsed state. * @param {string} panelId - DOM id of the panel. * @returns {string} localStorage key. */ _panelCollapseStorageKey(panelId) { return `godsEyeView.${PANEL_LAYOUT_STORAGE_VERSION}.panelCollapsed.${panelId}`; } /** * Restores a panel's collapsed/expanded state from localStorage. * Falls back to the CSS class default if no saved state exists. * @param {string} panelId - DOM id of the panel. * @returns {void} */ _restorePanelCollapsedState(panelId, { allowStored = true } = {}) { const panelEl = document.getElementById(panelId); if (!panelEl) return; let collapsed = panelEl.classList.contains('collapsed'); let stored = null; if (allowStored) { try { stored = localStorage.getItem(this._panelCollapseStorageKey(panelId)); if (stored === '1') collapsed = true; if (stored === '0') collapsed = false; } catch { // storage unavailable } } // DISPLAY starts COLLAPSED for a first-time visitor, then respects the // user's persisted choice like every other panel. // // It used to start expanded, to advertise the HUD / DETECT / 3D toggles. // That reason expired when those became ON by default: the rail now opens // to offer controls for things already happening, while competing with the // first-run mission card for the one first impression there is. A stored // choice still wins in both directions, so anyone who opens it keeps it. if (panelId === 'pp-toggles' && stored === null) collapsed = true; panelEl.classList.toggle('collapsed', collapsed); this._syncPanelCollapseButton(panelEl); } /** * Persists a panel's collapsed state ('1' or '0') to localStorage. * @param {string} panelId - DOM id of the panel. * @param {boolean} collapsed - Whether the panel is collapsed. * @returns {void} */ _savePanelCollapsedState(panelId, collapsed) { try { localStorage.setItem(this._panelCollapseStorageKey(panelId), collapsed ? '1' : '0'); } catch { // storage unavailable } } /** * Builds one fixed right-side rail from Display, CCTV, its parameter * controls, and Global Context (which owns the nested Radio companion). * The rail then measures the live HUD chrome at runtime so it can stay * aligned and within the available vertical corridor. * @returns {void} */ _initRightPanelAdaptiveLayout() { const stack = this._rightPanelStack; if (!stack || !this._ppToggles) return; this._ppToggles.style.removeProperty('top'); this._ppToggles.style.removeProperty('right'); this._ppToggles.style.removeProperty('bottom'); this._ppToggles.style.removeProperty('left'); this._ppToggles.style.removeProperty('z-index'); this._ppToggles.classList.remove('panel-draggable', 'panel-dragging'); this._ppToggles.querySelector('.pp-header-row')?.removeAttribute('title'); stack.prepend(this._ppToggles); const globalContextPanel = document.getElementById('global-context-panel'); if (this._cctvPanel) { this._cctvPanel.style.removeProperty('top'); this._cctvPanel.style.removeProperty('right'); this._cctvPanel.style.removeProperty('bottom'); this._cctvPanel.style.removeProperty('left'); this._cctvPanel.style.removeProperty('z-index'); this._cctvPanel.classList.remove('panel-draggable', 'panel-dragging'); stack.insertBefore(this._cctvPanel, globalContextPanel); this._syncPanelCollapseButton(this._cctvPanel); } if (this._sliderPanel) { this._sliderPanel.style.removeProperty('top'); this._sliderPanel.style.removeProperty('right'); this._sliderPanel.style.removeProperty('bottom'); this._sliderPanel.style.removeProperty('left'); this._sliderPanel.style.removeProperty('max-height'); const detectionGroup = this._detectionBtn?.closest('.pp-toggle-group'); if (detectionGroup) detectionGroup.after(this._sliderPanel); else this._ppToggles.append(this._sliderPanel); } if (typeof ResizeObserver !== 'undefined') { this._rightStackResizeObserver = new ResizeObserver(() => { this._scheduleRightPanelLayout(); }); this._rightStackResizeObserver.observe(stack); for (const panel of [this._ppToggles, this._cctvPanel, globalContextPanel]) { if (panel) this._rightStackResizeObserver.observe(panel); } document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR).forEach((element) => { this._rightStackResizeObserver.observe(element); }); } if (typeof MutationObserver !== 'undefined') { this._rightStackMutationObserver = new MutationObserver(() => { this._scheduleRightPanelLayout(); }); this._rightStackMutationObserver.observe(stack, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['class', 'hidden', 'data-variant'], }); const hud = document.getElementById('intel-hud'); if (hud) { this._rightStackMutationObserver.observe(hud, { subtree: true, attributes: true, attributeFilter: ['class', 'hidden', 'data-variant'], }); } } const transitionHud = document.getElementById('intel-hud'); if (transitionHud) { this._rightStackHudTransitionHandler = (event) => { if (event.propertyName === 'opacity' || event.propertyName === 'visibility') { this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true }); } }; transitionHud.addEventListener('transitionend', this._rightStackHudTransitionHandler); } this._scheduleRightPanelLayout(); } _scheduleRightPanelLayout({ reconsiderAutoCollapse = false } = {}) { if (reconsiderAutoCollapse) this._rightStackReconsiderAutoCollapse = true; if (!this._rightPanelStack || this._rightStackLayoutFrame !== null) return; this._rightStackLayoutFrame = requestAnimationFrame(() => { this._rightStackLayoutFrame = null; if (this._rightStackReconsiderAutoCollapse) { this._rightStackReconsiderAutoCollapse = false; for (const panel of this._rightPanelStack.querySelectorAll('.layout-auto-collapsed')) { panel.classList.remove('collapsed', 'layout-auto-collapsed'); this._syncPanelCollapseButton(panel); } } this._syncRightPanelAdaptiveLayout(); }); } /** * Places the right rail inside the visible HUD-safe corridor. When the * corridor is too short, the expanded panel receives the remaining height * with internal scrolling. Tactical HUD hides collapsed sibling launchers * while a panel is expanded; other HUD layouts keep them visible. * @returns {void} */ _syncRightPanelAdaptiveLayout() { layoutRightPanelRail({ stack: this._rightPanelStack, obstacles: document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR), windowRef: window, hud: { visible: this.hud.visible, variant: this.hud.getVariant() }, preferredPanelId: this._rightStackPreferredPanelId, onCollapse: (panel) => this._syncPanelCollapseButton(panel), onRetry: () => this._scheduleRightPanelLayout(), leftStack: this._leftPanelStack, displayPanel: this._ppToggles, readDisplayScrollTop: () => this._displayPortalScrollRestoreOwner === 'standard' ? this._standardDisplayScrollTop : (this._ppToggles?.scrollTop || 0), }); } /** * Initializes the adaptive left accordion. The layout engine measures the * actual HUD/chrome rectangles that intersect the left lane, then decides * whether collapsed sibling labels can remain visible beside the expanded * panel. No decision is keyed to a specific panel or HUD variant. * @returns {void} */ _initLeftPanelAdaptiveLayout() { const stack = this._leftPanelStack; if (!stack) return; if (typeof ResizeObserver !== 'undefined') { this._leftStackResizeObserver = new ResizeObserver(() => { this._scheduleLeftPanelLayout(); }); this._leftStackResizeObserver.observe(stack); stack.querySelectorAll(':scope > [data-panel-id]').forEach((panel) => { this._leftStackResizeObserver.observe(panel); const inner = [...panel.children].find((child) => !child.classList.contains('panel-glow')); if (inner) this._leftStackResizeObserver.observe(inner); }); document.querySelectorAll(LEFT_STACK_OBSTACLE_SELECTOR).forEach((element) => { this._leftStackResizeObserver.observe(element); }); } if (typeof MutationObserver !== 'undefined') { this._leftStackMutationObserver = new MutationObserver(() => { this._scheduleLeftPanelLayout(); }); this._leftStackMutationObserver.observe(stack, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['class'], }); const hud = document.getElementById('intel-hud'); if (hud) { this._leftStackMutationObserver.observe(hud, { attributes: true, attributeFilter: ['class', 'data-variant'], }); } const credits = document.getElementById('cesium-credits'); if (credits) { this._leftStackMutationObserver.observe(credits, { subtree: true, childList: true, }); } } const transitionHud = document.getElementById('intel-hud'); if (transitionHud) { this._leftStackHudTransitionHandler = (event) => { if (event.propertyName === 'opacity' || event.propertyName === 'visibility') { this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true }); // The Cockpit strip hangs off the HUD's REC readout, so it has to // remeasure on the same event: the readout keeps its rect through // the whole fade and only stops counting once the HUD has retired. this.cockpitView?.scheduleContextLayout(); } }; transitionHud.addEventListener('transitionend', this._leftStackHudTransitionHandler); } this._leftStackCockpitModeHandler = () => { // Cockpit mode repositions the peripheral HUD and reveals its own // bottom-left context card. Measure after those styles have committed so // the accordion remains in the same obstacle-safe lane instead of // jumping to a cockpit-specific top anchor. this._scheduleLeftPanelLayout(); requestAnimationFrame(() => this._scheduleLeftPanelLayout()); setTimeout(() => this._scheduleLeftPanelLayout(), 300); }; window.addEventListener('gev:cockpit-mode-changed', this._leftStackCockpitModeHandler); this._scheduleLeftPanelLayout(); } /** * Batches adaptive accordion work into one animation frame. * @returns {void} */ _scheduleLeftPanelLayout({ reconsiderAutoCollapse = false } = {}) { if (reconsiderAutoCollapse) this._leftStackReconsiderAutoCollapse = true; if (!this._leftPanelStack || this._leftStackLayoutFrame !== null) return; this._leftStackLayoutFrame = requestAnimationFrame(() => { this._leftStackLayoutFrame = null; if (this._leftStackReconsiderAutoCollapse) { this._leftStackReconsiderAutoCollapse = false; for (const panel of this._leftPanelStack.querySelectorAll('.layout-auto-collapsed')) { panel.classList.remove('collapsed', 'layout-auto-collapsed'); this._syncPanelCollapseButton(panel); } } this._syncLeftPanelAdaptiveLayout(); }); } /** * Measures a live obstacle-free corridor for the left accordion and toggles * focus mode only when the expanded panel plus sibling labels cannot fit. * Safe boundaries are written as viewport-relative CSS values. * @returns {void} */ _syncLeftPanelAdaptiveLayout() { layoutLeftPanelRail({ stack: this._leftPanelStack, obstacles: document.querySelectorAll(LEFT_STACK_OBSTACLE_SELECTOR), windowRef: window, hud: { visible: this.hud.visible, variant: this.hud.getVariant() }, preferredPanelId: this._leftStackPreferredPanelId, onCollapse: (panel) => this._syncPanelCollapseButton(panel), onRetry: () => this._scheduleLeftPanelLayout(), collapsedHeights: this._leftStackCollapsedHeights, onAligned: () => this._scheduleRightPanelLayout(), }); } /** * Updates collapse button glyphs based on panel state. Right-rail panels * use directional arrows; left-stack panels use +/- symbols. * @param {HTMLElement} panelEl - The panel DOM element. * @returns {void} */ _syncPanelCollapseButton(panelEl) { const isRightRail = ['pp-toggles', 'cctv-panel', 'global-context-panel'].includes(panelEl?.id); const collapsed = panelEl.classList.contains('collapsed'); panelEl.querySelectorAll('.panel-collapse-btn[data-collapse-target]').forEach((btn) => { const owner = btn.closest('[data-panel-id], #param-slider-panel'); if (owner !== panelEl) return; if (isRightRail) { btn.textContent = collapsed ? '◀' : '▶'; } else { btn.textContent = collapsed ? '+' : '−'; } btn.setAttribute('aria-expanded', String(!collapsed)); const panelName = panelEl.querySelector('.panel-title, .pp-header-label')?.textContent?.trim() || 'panel'; const action = collapsed ? 'Expand' : 'Collapse'; btn.title = `${action} ${panelName}`; btn.setAttribute('aria-label', `${action} ${panelName}`); if (panelEl.id === 'radio-panel') { const action = collapsed ? 'Expand' : 'Collapse'; btn.title = `${action} Radio`; btn.setAttribute('aria-label', `${action} Radio section`); } }); const dockToggle = panelEl.querySelector(`[data-dock-toggle-target="${panelEl.id}"]`); if (dockToggle) { const panelName = panelEl.querySelector('.panel-title, .location-toolbar-label')?.textContent?.trim() || 'panel'; const action = collapsed ? 'Expand' : 'Collapse'; dockToggle.setAttribute('aria-expanded', String(!collapsed)); dockToggle.setAttribute('aria-label', `${action} ${panelName}`); dockToggle.title = `${action} ${panelName}`; } if (panelEl.id === 'radio-panel' && this._contextRadioDetailsBtn) { this._contextRadioDetailsBtn.setAttribute('aria-expanded', String(!collapsed)); } if (panelEl.id === 'radio-panel' || panelEl.id === 'global-context-panel') { this._syncContextRadioLauncherState(); } } /** * Converts a panel from left-positioned to right-anchored so it expands * leftward on resize. Used for the right-rail parameter panel. * @param {HTMLElement} panelEl - The panel to re-anchor. * @returns {void} */ _pinPanelToRight(panelEl) { if (!panelEl) return; const rect = panelEl.getBoundingClientRect(); const rightOffset = Math.max(6, Math.round(window.innerWidth - rect.right)); panelEl.style.right = `${rightOffset}px`; panelEl.style.left = 'auto'; } /** * Restores a panel's top/left position from localStorage. * Right-rail panels are additionally pinned to the right edge. * @param {string} panelId - DOM id of the panel. * @param {HTMLElement} panelEl - The panel DOM element. * @returns {void} */ _restorePanelPosition(panelId, panelEl) { try { const raw = localStorage.getItem(this._panelStorageKey(panelId)); if (!raw) return; const pos = JSON.parse(raw); if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number') return; // Clamp to the viewport: a position saved at one window size would otherwise land off-screen at // another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too. const { left, top } = this._clampToViewport(Math.round(pos.left), Math.round(pos.top), panelEl); panelEl.style.left = `${left}px`; panelEl.style.top = `${top}px`; panelEl.style.right = 'auto'; panelEl.style.bottom = 'auto'; if (panelId === 'pp-toggles') { this._pinPanelToRight(panelEl); } } catch { // ignore malformed saved panel position } } /** * Clamp a desired left/top so the panel stays fully on-screen (6px inset), matching the drag * clamp (ui.js ~1822). Width/height are position-independent, so reading the rect first is safe. * @param {number} left - desired left (px) * @param {number} top - desired top (px) * @param {HTMLElement} panelEl - the panel element * @returns {{left:number, top:number}} */ _clampToViewport(left, top, panelEl) { const rect = panelEl.getBoundingClientRect(); const maxLeft = Math.max(6, window.innerWidth - rect.width - 6); const maxTop = Math.max(6, window.innerHeight - rect.height - 6); return { left: Math.max(6, Math.min(maxLeft, left)), top: Math.max(6, Math.min(maxTop, top)), }; } /** * Persists a panel's current bounding-rect position to localStorage. * @param {string} panelId - DOM id of the panel. * @param {HTMLElement} panelEl - The panel DOM element. * @returns {void} */ _savePanelPosition(panelId, panelEl) { const rect = panelEl.getBoundingClientRect(); try { localStorage.setItem(this._panelStorageKey(panelId), JSON.stringify({ left: Math.round(rect.left), top: Math.round(rect.top), })); } catch { // storage unavailable } } /** * Makes a panel draggable via its handle element. Implements: * - Z-order promotion: each pointerdown increments the global z-counter * so the clicked panel floats above siblings. * - Viewport clamping: drag moves are clamped to a 6px inset from all edges. * - Right-rail pinning: pp-toggles panel is re-anchored right after drag. * - CCTV viewport sync: cctv-panel recalculates scroll height after drag. * @param {string} panelId - DOM id of the panel. * @param {HTMLElement} panelEl - The panel DOM element. * @param {HTMLElement} handleEl - The drag handle element within the panel. * @returns {void} */ /** * Promotes a panel to the top of the panel z band [PANEL_Z_BASE, PANEL_Z_MAX]. * Renormalizes all promoted panels when the band is exhausted so panels can * never climb above the voice pill (150), toasts (200), or clean-view exit (300). * @param {HTMLElement} panelEl - Panel to bring to front. * @returns {void} */ _promotePanelZ(panelEl) { this._panelZCounter += 1; if (this._panelZCounter > PANEL_Z_MAX) { const promoted = [...document.querySelectorAll('.panel-draggable')] .filter((el) => el.style.zIndex) .sort((a, b) => Number(a.style.zIndex) - Number(b.style.zIndex)); let z = PANEL_Z_BASE + 1; for (const el of promoted) { el.style.zIndex = String(z); z += 1; } this._panelZCounter = z; } panelEl.style.zIndex = String(this._panelZCounter); } _makePanelDraggable(panelId, panelEl, handleEl) { // Z-order promotion: bring clicked panel to front of the stacking context panelEl.addEventListener('pointerdown', () => { this._promotePanelZ(panelEl); }); handleEl.addEventListener('pointerdown', (event) => { if (event.button !== 0) return; if (event.target.closest('.panel-collapse-btn')) return; if (event.target.closest('input, select, option, button:not(.panel-collapse-btn)')) return; event.preventDefault(); const rect = panelEl.getBoundingClientRect(); const startX = event.clientX; const startY = event.clientY; const offsetX = startX - rect.left; const offsetY = startY - rect.top; panelEl.style.left = `${rect.left}px`; panelEl.style.top = `${rect.top}px`; panelEl.style.right = 'auto'; panelEl.style.bottom = 'auto'; panelEl.classList.add('panel-dragging'); this._promotePanelZ(panelEl); const onMove = (moveEvent) => { const nextLeftRaw = moveEvent.clientX - offsetX; const nextTopRaw = moveEvent.clientY - offsetY; const maxLeft = Math.max(6, window.innerWidth - rect.width - 6); const maxTop = Math.max(6, window.innerHeight - rect.height - 6); const nextLeft = Math.max(6, Math.min(maxLeft, nextLeftRaw)); const nextTop = Math.max(6, Math.min(maxTop, nextTopRaw)); panelEl.style.left = `${nextLeft}px`; panelEl.style.top = `${nextTop}px`; if (panelId === 'pp-toggles') { this._layoutRightPanels(); } if (panelId === 'cctv-panel') { this._syncCctvPanelViewport(); } }; const onUp = () => { panelEl.classList.remove('panel-dragging'); window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); window.removeEventListener('pointercancel', onUp); if (panelId === 'pp-toggles') { this._pinPanelToRight(panelEl); } this._savePanelPosition(panelId, panelEl); if (panelId === 'cctv-panel') { this._syncCctvPanelViewport(); } }; window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); window.addEventListener('pointercancel', onUp); }); } _buildSharePanelState() { const specs = []; for (const spec of SHARE_PANEL_STATE_SPECS) { const panelEl = document.getElementById(spec.id); if (!panelEl) continue; // Responsive auto-collapse is presentation only; the recipient should // restore the user's explicit expanded preference at its own viewport. const collapsed = panelEl.classList.contains('layout-auto-collapsed') ? false : panelEl.classList.contains('collapsed'); const entry = { id: spec.id, collapsed }; if (spec.pinnable) entry.pinned = panelEl.classList.contains('dock-pinned'); specs.push(entry); } return specs.length ? { specs } : null; } _restorePanelState(panelState) { if (!panelState || !Array.isArray(panelState.specs)) return; const specsById = new Map(panelState.specs.map((spec) => [spec.id, spec])); for (const spec of SHARE_PANEL_STATE_SPECS) { const state = specsById.get(spec.id); if (!state || typeof state.collapsed !== 'boolean') continue; if (spec.pinnable && typeof state.pinned === 'boolean') { this._setCommandDockPanelPinState(spec.id, state.pinned, { restore: true, persist: false, syncShare: false, }); } const nextCollapsed = state.pinned && spec.pinnable ? false : state.collapsed; this.setPanelCollapsed(spec.id, nextCollapsed, { restore: true, persist: false, syncShare: false, }); } this.shareLinkManager?.onPanelStateChange?.(); } /** * 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, } = {}) { if (panelId === 'control-panel' && collapsed) this._cancelMapSourceFocus?.(); const panelEl = document.getElementById(panelId); if (!panelEl) return; if (explicit && !restore) this.shareLinkManager?.claimRestoreLane?.('panel', panelId); const nextCollapsed = Boolean(collapsed); const wasAutoCollapsed = panelEl.classList.contains('layout-auto-collapsed'); const leftOwnerPanel = this._leftPanelStack?.contains(panelEl) ? panelEl : null; const rightOwnerPanel = panelId === 'radio-panel' ? document.getElementById('global-context-panel') : (this._rightPanelStack?.contains(panelEl) ? panelEl : null); const priorLeftOwner = this._leftStackPreferredPanelId; const priorRightOwner = this._rightStackPreferredPanelId; if (explicit && !restore && !nextCollapsed && leftOwnerPanel) { this._leftStackPreferredPanelId = leftOwnerPanel.id; } else if (explicit && !restore && nextCollapsed && leftOwnerPanel?.id === this._leftStackPreferredPanelId) { this._leftStackPreferredPanelId = null; } if (explicit && !restore && !nextCollapsed && rightOwnerPanel) { this._rightStackPreferredPanelId = rightOwnerPanel.id; } else if ( explicit && !restore && nextCollapsed && rightOwnerPanel?.id === this._rightStackPreferredPanelId ) { this._rightStackPreferredPanelId = null; } if (panelEl.classList.contains('collapsed') === nextCollapsed && !wasAutoCollapsed) { this._syncPanelCollapseButton(panelEl); if (priorLeftOwner !== this._leftStackPreferredPanelId) { this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true }); } if (priorRightOwner !== this._rightStackPreferredPanelId) { this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true }); } return; } panelEl.classList.remove('layout-auto-collapsed'); if (!nextCollapsed && this.cockpitView?.active && panelId === 'data-panel') { this._cockpitContextCollapsedForDataPanel = !this.cockpitView.contextCollapsed; if (this._cockpitContextCollapsedForDataPanel) { this.cockpitView.setContextCollapsed(true); } } if (!nextCollapsed && panelId === 'global-context-panel' && this._contextRadioDock?.classList.contains('disclosure-open')) { this._setRadioDisclosure?.(false); } if (!nextCollapsed && panelId === 'radio-panel' && document.getElementById('global-context-panel')?.classList.contains('collapsed')) { this.setPanelCollapsed('global-context-panel', false, { restore, persist, syncShare }); } if (!nextCollapsed && !restore && panelId === 'location-bar') { const otherPanel = document.getElementById('control-panel'); if (otherPanel && !otherPanel.classList.contains('dock-pinned')) { this.setPanelCollapsed('control-panel', true, { restore, persist, syncShare }); } } else if (!nextCollapsed && !restore && panelId === 'control-panel') { const otherPanel = document.getElementById('location-bar'); if (otherPanel && !otherPanel.classList.contains('dock-pinned')) { this.setPanelCollapsed('location-bar', true, { restore, persist, syncShare }); } } panelEl.classList.toggle('collapsed', nextCollapsed); if (nextCollapsed && this.cockpitView?.active && panelId === 'data-panel' && this._cockpitContextCollapsedForDataPanel) { this._cockpitContextCollapsedForDataPanel = false; this.cockpitView.setContextCollapsed(false); } this._syncPanelCollapseButton(panelEl); if (persist !== false) this._savePanelCollapsedState(panelId, nextCollapsed); if (panelId === 'pp-toggles') { this._layoutRightPanels(); } if (this._rightPanelStack?.contains(panelEl)) { this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true }); } if (panelId === 'cctv-panel') { this._syncCctvPanelViewport(); } requestAnimationFrame(() => this._updateCommandDockTrayStack()); this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: this._leftPanelStack?.contains(panelEl) === true, }); if (syncShare) this.shareLinkManager?.onPanelStateChange?.(); } /** * 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'|'operator'|'minimal'} variantName - Layout variant. * @returns {{ok: boolean, layout?: string, visible?: boolean, error?: string}} */ setHudLayout(variantName) { const variant = String(variantName ?? '').toLowerCase(); if (!['tactical', 'operator', 'minimal'].includes(variant)) { return { ok: false, error: `Unknown HUD layout: ${variantName}` }; } this.shareLinkManager?.claimRestoreLane?.('visual'); this._setHudVariant(variant); return { ok: true, layout: this.hud.getVariant(), visible: !!this.hud.visible }; } /** * Reads current detection overlay state (engine mode + UI density percent). * @returns {{detectionMode: string, densityPct: number|null, allocationStrategy:string, fadePct:number, outsideOpacityPct:number}} */ getDetectionState() { const pct = this._detectionDensitySlider ? parseInt(this._detectionDensitySlider.value, 10) : null; return { detectionMode: getDetectionMode(), densityPct: pct, allocationStrategy: getDetectionTuning().allocationStrategy, fadePct: parseInt(this._detectionFadeSlider?.value || '7', 10), outsideOpacityPct: parseInt(this._detectionOpacitySlider?.value || '0', 10), }; } /** Read-only overlay diagnostics used by browser QA and regression harnesses. */ getDetectionDiagnostics() { return readDetectionDiagnostics(); } /** * 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 } = {}) { if (enabled !== undefined && typeof enabled !== 'boolean') { return { ok: false, error: `Invalid detection enabled value: ${enabled}`, ...this.getDetectionState() }; } let requestedProfile = null; if (typeof mode === 'string' && mode.trim()) { requestedProfile = normalizeProfile(mode); if (!requestedProfile) { return { ok: false, error: `Unknown detection mode: ${mode}`, ...this.getDetectionState() }; } } let requestedDensity = null; if (densityPct != null) { if (!Number.isFinite(Number(densityPct))) { return { ok: false, error: `Invalid density: ${densityPct}`, ...this.getDetectionState() }; } requestedDensity = canonicalizeDensity(Number(densityPct)); } if (requestedProfile && requestedProfile !== 'OFF' && requestedDensity != null && profileForDensity(requestedDensity) !== requestedProfile) { return { ok: false, error: `Detection mode ${requestedProfile} conflicts with density ${requestedDensity}%`, ...this.getDetectionState(), }; } let requestedAllocation = null; if (allocationStrategy != null) { requestedAllocation = String(allocationStrategy).trim().toUpperCase(); if (!ALLOCATION_STRATEGIES.includes(requestedAllocation)) { return { ok: false, error: `Unknown allocation strategy: ${allocationStrategy}`, ...this.getDetectionState() }; } } if (fadePct != null) { if (!Number.isFinite(Number(fadePct))) { return { ok: false, error: `Invalid fade distance: ${fadePct}`, ...this.getDetectionState() }; } } if (outsideOpacityPct != null) { if (!Number.isFinite(Number(outsideOpacityPct))) { return { ok: false, error: `Invalid outside opacity: ${outsideOpacityPct}`, ...this.getDetectionState() }; } } const hasExplicitVisualChange = typeof enabled === 'boolean' || requestedProfile !== null || requestedDensity !== null || requestedAllocation !== null || fadePct != null || outsideOpacityPct != null; if (hasExplicitVisualChange) { // Voice/scripted detection control counts as an explicit user choice, so // neither style presets nor a still-pending shared visual restore can // overwrite it afterward. this.shareLinkManager?.claimRestoreLane?.('visual'); this._detectionUserOverridden = true; } if (requestedAllocation) { this._setDetectionAllocation(requestedAllocation, { syncShare: false }); } if (fadePct != null && this._detectionFadeSlider) { this._detectionFadeSlider.value = String(Math.max(0, Math.min(40, Math.round(Number(fadePct))))); } if (outsideOpacityPct != null && this._detectionOpacitySlider) { this._detectionOpacitySlider.value = String(Math.max(0, Math.min(100, Math.round(Number(outsideOpacityPct))))); } if (fadePct != null || outsideOpacityPct != null) this._applyDetectionFadeFromUi(); if (requestedProfile && requestedProfile !== 'OFF' && requestedDensity == null) { requestedDensity = defaultDensityForProfile(requestedProfile); } if (requestedDensity != null && this._detectionDensitySlider) { this._detectionDensitySlider.value = String(requestedDensity); this._applyDetectionDensityFromUi(); } if (enabled === false || requestedProfile === 'OFF') { setDetectionModeByLabel('OFF'); } else if (requestedProfile) { setDetectionModeByLabel(requestedProfile); } else if (enabled === true && getDetectionMode() === 'OFF') { setDetectionModeByLabel(profileForDensity( requestedDensity ?? this._detectionDensitySlider?.value ?? 50, )); } this._syncDetectionUiFromEngine(); this._syncShareState(); return { ok: true, ...this.getDetectionState() }; } /** * 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 } = {}) { const current = () => ({ enabled: !!this.bloomEnabled, intensityPct: this._bloomSlider ? parseInt(this._bloomSlider.value, 10) : null, }); if (enabled !== undefined && typeof enabled !== 'boolean') { return { ok: false, error: `Invalid bloom enabled value: ${enabled}`, bloom: current() }; } if (intensityPct !== undefined && (typeof intensityPct !== 'number' || !Number.isFinite(intensityPct))) { return { ok: false, error: `Invalid bloom intensity: ${intensityPct}`, bloom: current() }; } const hasExplicitVisualChange = intensityPct !== undefined || enabled !== undefined; if (hasExplicitVisualChange) this.shareLinkManager?.claimRestoreLane?.('visual'); if (intensityPct !== undefined) { this._setBloomIntensity(Math.round(Math.max(0, Math.min(200, intensityPct)))); } if (enabled !== undefined) this._setBloomEnabled(enabled); return { ok: true, bloom: current(), }; } /** * 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 } = {}) { const current = () => ({ enabled: !!this.sharpenEnabled, intensityPct: this._sharpenSlider ? parseInt(this._sharpenSlider.value, 10) : null, }); if (enabled !== undefined && typeof enabled !== 'boolean') { return { ok: false, error: `Invalid sharpen enabled value: ${enabled}`, sharpen: current() }; } if (intensityPct !== undefined && (typeof intensityPct !== 'number' || !Number.isFinite(intensityPct))) { return { ok: false, error: `Invalid sharpen intensity: ${intensityPct}`, sharpen: current() }; } const hasExplicitVisualChange = intensityPct !== undefined || enabled !== undefined; if (hasExplicitVisualChange) this.shareLinkManager?.claimRestoreLane?.('visual'); if (intensityPct !== undefined) { const pct = Math.round(Math.max(0, Math.min(100, intensityPct))); if (this._sharpenSlider) this._sharpenSlider.value = String(pct); if (this._sharpenSliderValue) this._sharpenSliderValue.textContent = `${pct}%`; this._applySharpenIntensity(pct / 100); this._syncShareState(); } if (enabled !== undefined) this._setSharpenEnabled(enabled); return { ok: true, sharpen: current(), }; } /** Whether the full-globe celestial overlay is enabled by user preference. */ get celestialRingEnabled() { return !!this.celestialRing?.enabled; } /** * 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 } = {}) { const styleSupported = isCelestialRingStyleSupported(this.activeStyle); const current = () => ({ enabled: this.celestialRingEnabled, visible: !!this.celestialRing?.visible, }); if (typeof enabled !== 'boolean') { return { ok: false, celestialRing: current(), cameraFocused: false, error: `Invalid celestial ring enabled value: ${enabled}`, }; } if (typeof syncShare !== 'boolean' || typeof focus !== 'boolean') { return { ok: false, celestialRing: current(), cameraFocused: false, error: 'Celestial ring options must be boolean', }; } if (!styleSupported && enabled) { return { ok: false, celestialRing: current(), cameraFocused: false, error: 'Celestial ring is available only in Normal style', }; } if (syncShare) this.shareLinkManager?.claimRestoreLane?.('visual'); const nextEnabled = styleSupported && enabled; this.celestialRing?.setEnabled(nextEnabled); this._celestialBtn?.classList.toggle('active', nextEnabled); this._celestialBtn?.setAttribute('aria-pressed', String(nextEnabled)); if (this._celestialBtn) { this._celestialBtn.disabled = !styleSupported; this._celestialBtn.setAttribute('aria-disabled', String(!styleSupported)); this._celestialBtn.title = styleSupported ? 'Celestial ring — reveal the full globe' : 'Celestial ring — available in Normal style'; } let cameraFocused = false; if (nextEnabled && focus) { cameraFocused = !!this.celestialRing?.focusFullGlobe(); } if (syncShare) this._syncShareState(); return { ok: styleSupported || !enabled, celestialRing: current(), cameraFocused, }; } /** * Starts or stops orbiting the active POI. * @param {boolean} [enabled] - Omit to toggle. * @returns {{ok: boolean, orbiting: boolean, error?: string}} */ setOrbit(enabled) { const active = !!this.orbitController?.active; if (typeof enabled === 'boolean' && enabled === active) { return { ok: true, orbiting: active }; } if (enabled === false) { this._stopOrbit(); return { ok: true, orbiting: false }; } if (!this._currentTarget) { return { ok: false, orbiting: false, error: 'No active landmark to orbit — fly to a landmark first' }; } this._toggleOrbit(); return { ok: true, orbiting: !!this.orbitController?.active }; } /** * 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') }; } /** * Reads global context mode state for voice/state-sync consumers. * @returns {{mode: 'flights'|'space-missions'|null, active: boolean, changing: boolean, entering: 'flights'|'space-missions'|null, snapshotCaptured: boolean}} */ getContextModeState(...args) { return this._contextControls?.getContextModeState(...args); } /** * Sets global context mode (Contacts / Space Missions / off) for voice. * @param {'contacts'|'space-missions'|'off'|null} mode - Requested context target. * @param {object} [options] * @param {string|Symbol|null} [options.notificationToken] * @param {AbortSignal|null} [options.signal] * @param {Function|null} [options.isCurrent] * @param {boolean} [options.claimVisualAuthority] Whether this request is a * genuine operator/voice Context intent that should take the visual restore * lane. Cockpit choreography calls this facade INTERNALLY for its own * enter/rollback steps; those transitions are not a Context request by the * operator and must stay inert, so they pass `false`. * @returns {Promise<{ok:boolean, mode:'flights'|'space-missions'|null, active:boolean, action:string, error?:string}>} */ setContextMode(...args) { return this._contextControls?.setContextMode(...args); } /** * Returns cockpit status for voice/state sync and navigation operations. * @returns {{active:boolean, entryAllowed:boolean, visionMode:string, subject:{id:string,layerId:string}|null, navigation:{canPrevious:boolean,canNext:boolean,canFocus:boolean}|null, awareness?: object}|null} */ getCockpitState() { const snapshot = militaryAwarenessLayer.getContextSnapshot?.(); const info = this.cockpitView?.readAircraftInfo?.(); const active = Boolean(this.cockpitView?.active); const gateOpen = Boolean(this.cockpitView?.isEntryAllowed?.()); // "Could Cockpit be ENTERED right now" — so it is false while already // inside, unconditionally. Cockpit takes the entity off // `viewer.trackedEntity` on entry and NEXT puts one back, which made this // flip true/false between calls while `active` stayed true; readers // (including the voice model) read that as a broken half-entered state. const entryAllowed = !active && Boolean( gateOpen && info && this.viewer?.trackedEntity?.position, ); return { active, entryAllowed, // Why entry is unavailable, so a refusal can be explained rather than // guessed at. entryBlockedReason: entryAllowed || active ? null : (!gateOpen ? (this._contextModeChanging ? 'contacts-starting' : 'contacts-inactive') : 'no-tracked-aircraft'), visionMode: this.cockpitView?.visionMode || null, subject: info ? { id: info.icao24 || info.id || null, layerId: info.layerId || null, callsign: info.callsign || null, } : null, navigation: snapshot ? { canPrevious: Boolean(snapshot.navigation?.canPrevious), canNext: Boolean(snapshot.navigation?.canNext), canFocus: Boolean(snapshot.navigation?.canFocus), } : null, awareness: snapshot ? { radiusM: Number.isFinite(snapshot.radiusM) ? snapshot.radiusM : null, subject: snapshot.subject ? { id: snapshot.subject.id || null, layerId: snapshot.subject.layerId || null, } : null, cohorts: Array.isArray(snapshot.cohorts) ? snapshot.cohorts.map((cohort) => ({ id: cohort?.id || null, source: cohort?.source || null, count: Number.isFinite(cohort?.count) ? cohort.count : null, relationship: cohort?.relationship || null, reason: cohort?.reason || null, coverage: cohort?.coverage || null, })) : [], navigation: snapshot.navigation ? { canPrevious: Boolean(snapshot.navigation.canPrevious), canNext: Boolean(snapshot.navigation.canNext), canFocus: Boolean(snapshot.navigation.canFocus), } : null, } : null, activeTracked: this.cockpitView?.active ? Boolean(this.cockpitView?.trackedEntity) : false, activeMapView: !this.cockpitView?.active && entryAllowed, }; } /** * Point Cockpit entry at a requested contact layer before it enters. * * Reuses the filtered Context navigation NEXT already uses, so "cockpit in * that military helicopter" lands on the same contact "next military * helicopter" would. Cockpit flies aircraft only; vessel and installation * layers are refused by name rather than silently ignored. * @param {object} options Retarget request. * @param {string} options.targetLayer Requested contact layer. * @param {string|null} options.aircraftClass Optional class filter. * @param {{layerId: string}|null} options.currentTarget Current tracker. * @param {{layerId: string}|null} options.selectedTarget Pending selection. * @returns {{ok: boolean, retargeted?: boolean, error?: string}} Outcome. */ _retargetCockpitEntryLayer({ targetLayer, aircraftClass, currentTarget, selectedTarget }) { if (!['flights', 'military'].includes(targetLayer)) { return { ok: false, error: `Cockpit flies aircraft only — ${targetLayer} contacts cannot be entered`, }; } const activeLayer = selectedTarget?.layerId || currentTarget?.layerId || null; const alreadyOnLayer = activeLayer === targetLayer; if (alreadyOnLayer && !aircraftClass) return { ok: true, retargeted: false }; const moved = militaryAwarenessLayer?.navigateNext ? !!militaryAwarenessLayer.navigateNext({ targetLayer, aircraftClass, origin: 'voice', }) : false; if (moved) return { ok: true, retargeted: true }; // A filter that matched nothing still enters, as long as the layer is // already right — the operator asked for that layer and is on it. if (alreadyOnLayer) return { ok: true, retargeted: false }; const label = targetLayer === 'military' ? 'military' : 'civilian'; const filtered = aircraftClass ? `${aircraftClass} ` : ''; return { ok: false, error: `No ${filtered}${label} contact is available to enter — track one first, or say "next ${label}"`, }; } /** * Controls cockpit entry/exit and context navigation. * @param {'enter'|'exit'|'next'|'previous'|'status'} action - Cockpit action. * @param {object} [options] * @param {string|Symbol|null} [options.notificationToken] * @param {'flights'|'military'|'ais-live-vessels'|'military-installations'|null} [options.targetLayer] * @param {string|null} [options.aircraftClass] * @param {{layerId:'flights'|'military',id:string}|null} [options.selectedTarget] * @param {{layerId:'flights'|'military',id:string}|null} [options.rollbackTarget] * @returns {{ok:boolean, action:string, error?:string, state?:object}} */ controlCockpit(action, { notificationToken = null, targetLayer = null, aircraftClass = null, selectedTarget = null, rollbackTarget = undefined, } = {}) { const normalized = String(action || '').toLowerCase(); if (!this.cockpitView) { return { ok: false, action: 'control_cockpit', error: 'Cockpit controller unavailable', state: this.getCockpitState(), }; } if (normalized === 'status') { return { ok: true, action: 'control_cockpit', state: this.getCockpitState(), notificationToken: notificationToken || null, }; } if (normalized === 'enter') { // Entry is gated exactly as the manual entry chip is. Attempting it while // the gate is shut produced the half-entered look the operator reported // (a plane anchored under the camera with no cockpit around it), so // refuse with the reason instead of trying. if (!this.cockpitView.isEntryAllowed?.()) { return { ok: false, action: 'control_cockpit', error: this._contextModeChanging ? 'Contacts is still starting up — try Cockpit again in a moment' : 'Contacts must be active to enter Cockpit — say "open contacts" first', state: this.getCockpitState(), }; } let currentTarget = this.getAircraftTrackingTarget(); const layerForTarget = (target) => target?.layerId === 'military' ? militaryFlightsLayer : target?.layerId === 'flights' ? flightsLayer : null; // A requested layer retargets BEFORE entry, through the same filtered // navigation NEXT uses. Ignoring it entered on whatever was already // tracked and reported success, so "cockpit in that military helicopter" // silently put the operator in an airliner. if (targetLayer) { const requested = this._retargetCockpitEntryLayer({ targetLayer, aircraftClass, currentTarget, selectedTarget, }); if (!requested.ok) { return { ok: false, action: 'control_cockpit', error: requested.error, state: this.getCockpitState(), }; } if (requested.retargeted) { // The retarget is now the authority; a selection sampled before it // would drag entry back to the wrong layer. selectedTarget = null; rollbackTarget = rollbackTarget === undefined ? currentTarget : rollbackTarget; currentTarget = this.getAircraftTrackingTarget(); } } const selectedLayer = selectedTarget?.layerId === 'military' ? militaryFlightsLayer : selectedTarget?.layerId === 'flights' ? flightsLayer : null; const entry = enterCockpitWithTracking({ cockpitView: this.cockpitView, selectedLayer, selectedTarget, currentLayer: layerForTarget(currentTarget), rollbackLayer: layerForTarget( rollbackTarget === undefined ? currentTarget : rollbackTarget, ), rollbackTarget, selectionOrigin: 'voice', }); return { ok: entry.entered, action: 'control_cockpit', state: this.getCockpitState(), error: entry.error, }; } if (normalized === 'exit') { const exited = !!this.cockpitView.exit(); return { ok: exited, action: 'control_cockpit', state: this.getCockpitState(), error: exited ? null : 'Cockpit was already inactive', }; } if (normalized === 'next' || normalized === 'previous') { const changed = this.cockpitView.navigateContext( normalized === 'next' ? 1 : -1, { targetLayer, aircraftClass, origin: 'voice', }, ); return { ok: changed, action: 'control_cockpit', state: this.getCockpitState(), error: changed ? null : 'No further context target was available', }; } return { ok: false, action: 'control_cockpit', error: `Unknown cockpit action: ${action}`, state: this.getCockpitState(), }; } /** * 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 }, detection: this.getDetectionState(), bloom: { enabled: !!this.bloomEnabled, intensityPct: this._bloomSlider ? parseInt(this._bloomSlider.value, 10) : null, }, sharpen: { enabled: !!this.sharpenEnabled, intensityPct: this._sharpenSlider ? parseInt(this._sharpenSlider.value, 10) : null, }, celestialRing: { enabled: this.celestialRingEnabled, visible: !!this.celestialRing?.visible, }, orbiting: !!this.orbitController?.active, recording: !!this._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, }); } /** * Snapshots the full visual state (active style, bloom, sharpen, HUD, detection, * per-style shader uniform values) for serialization or scene recipe capture. * @returns {object} Serializable visual state object. */ getVisualState() { const styleParams = {}; for (const [styleName, stage] of Object.entries(this.stages)) { const shader = STYLES[styleName]; if (!shader?.uniforms) continue; styleParams[styleName] = {}; for (const uniformName of Object.keys(shader.uniforms)) { styleParams[styleName][uniformName] = stage.uniforms[uniformName]; } } return { style: this.activeStyle, bloom: { enabled: this.bloomEnabled, intensity: this._getBloomIntensity(), version: BLOOM_SCALE_VERSION, }, sharpen: { enabled: this.sharpenEnabled, intensity: parseInt(this._sharpenSlider?.value || '49', 10), }, hud: { visible: this.hud.visible, variant: this.hud.getVariant(), }, detection: { mode: getDetectionMode(), density: parseInt(this._detectionDensitySlider?.value || '50', 10), allocation: getDetectionTuning().allocationStrategy, fadePct: parseInt(this._detectionFadeSlider?.value || '7', 10), outsideOpacityPct: parseInt(this._detectionOpacitySlider?.value || '0', 10), }, scope: { enabled: isScopeMaskEnabled(), featherPct: Math.round(getScopeMaskFeather() * 100), }, mapStack: this.mapStackController?.getActiveId?.() || 'photoreal', styleParams, }; } /** * 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} Whether the state was committed. */ async applyVisualState(state = {}, { isCurrent = null } = {}) { const superseded = () => typeof isCurrent === 'function' && !isCurrent(); if (superseded()) return false; if (state.style && state.style !== this.activeStyle) { this.setStyle(state.style, { applyPreset: false }); } const bloomState = state.bloom || {}; if (typeof bloomState.intensity === 'number' && this._bloomSlider) { const intensity = decodeBloomIntensity( bloomState.intensity, bloomState.version ?? state.bloomVersion ?? BLOOM_SCALE_VERSION ); this._setBloomIntensity(intensity, { syncShare: false }); } if (typeof bloomState.enabled === 'boolean') { this._setBloomEnabled(bloomState.enabled); } const sharpenState = state.sharpen || {}; if (typeof sharpenState.intensity === 'number' && this._sharpenSlider) { const pct = Math.max(0, Math.min(100, Math.round(sharpenState.intensity))); this._sharpenSlider.value = String(pct); this._sharpenSliderValue.textContent = `${pct}%`; this._applySharpenIntensity(pct / 100); } if (typeof sharpenState.enabled === 'boolean') { this._setSharpenEnabled(sharpenState.enabled); } const hudState = state.hud || {}; if (hudState.variant) { this._setHudVariant(hudState.variant); } if (typeof hudState.visible === 'boolean') { this.hud.setMode(hudState.visible ? 'on' : 'off'); this._updateHudButtonState(); } const scopeState = state.scope || {}; if (typeof scopeState.enabled === 'boolean') { setScopeMaskEnabled(scopeState.enabled); this._scopeBtn?.classList.toggle('active', scopeState.enabled); this._scopeBtn?.setAttribute('aria-pressed', String(scopeState.enabled)); } if (typeof scopeState.featherPct === 'number' && this._scopeFeatherSlider) { const pct = Math.max(0, Math.min(100, Math.round(scopeState.featherPct))); this._scopeFeatherSlider.value = String(pct); if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; setScopeMaskFeather(pct / 100); } const detectionState = state.detection || {}; if (typeof detectionState.density === 'number' && this._detectionDensitySlider) { const pct = canonicalizeDensity(detectionState.density); this._detectionDensitySlider.value = String(pct); if (this._detectionDensityValue) this._detectionDensityValue.textContent = `${pct}%`; this._applyDetectionDensityFromUi(); } if (detectionState.allocation) { this._setDetectionAllocation(detectionState.allocation, { syncShare: false }); } if (typeof detectionState.fadePct === 'number' && this._detectionFadeSlider) { this._detectionFadeSlider.value = String(detectionState.fadePct); } if (typeof detectionState.outsideOpacityPct === 'number' && this._detectionOpacitySlider) { this._detectionOpacitySlider.value = String(detectionState.outsideOpacityPct); } this._applyDetectionFadeFromUi(); if (detectionState.mode) { this._setDetectionMode(detectionState.mode); } if (state.mapStack) { // The stack switch is itself a MUTATION, not merely a suspension point, // so it needs a gate on BOTH sides of the await. if (superseded()) return false; const stackBefore = this.mapStackController?.getActiveId?.() ?? null; const genBefore = this.mapStackController?.getSwitchGeneration?.() ?? null; await this._setMapStack(state.mapStack, { syncShare: false }); if (superseded()) { // Superseded DURING the switch, which the pre-check above cannot catch // and which has already moved the globe. The controller only // invalidates a switch when another setStack() arrives, and a winning // state that omits `mapStack` never issues one — every normalized scene // shot omits it — so this stale globe would simply stand. Put back what // the winner inherited. const genAfter = this.mapStackController?.getSwitchGeneration?.() ?? null; // _setMapStack issues exactly one setStack(), which advances the // generation once, or not at all when the stack was unavailable and // nothing was mutated. Anything past that is a NEWER switch whose // caller owns the globe now, and reverting would stomp a live intent. const globeIsStillOurs = genBefore !== null && genAfter !== null && genAfter <= genBefore + 1; const landed = this.mapStackController?.getActiveId?.() ?? null; if (globeIsStillOurs && stackBefore && landed !== stackBefore) { await this._setMapStack(stackBefore, { syncShare: false }); } return false; } // Everything below is the uniform commit, already past its own gate. } if (state.styleParams && typeof state.styleParams === 'object') { for (const [styleName, params] of Object.entries(state.styleParams)) { const stage = this.stages[styleName]; if (!stage || !params) continue; for (const [uniformName, uniformValue] of Object.entries(params)) { if (stage.uniforms[uniformName] === undefined) continue; stage.uniforms[uniformName] = uniformValue; } } this._updateSliderPanel(this.activeStyle); } this._syncShareState(); return true; } /** * Resets the safe-frame overlay to its inactive state on init. * @returns {void} */ _initRecordingOverlay() { if (!this._safeFrameOverlay || !this._safeFrameBox) return; this._safeFrameOverlay.classList.remove('active', 'ratio-9-16', 'ratio-16-9'); } /** * Applies recording-friendly post-processing and shader uniform overrides. * @param {object} preset */ applyCinematicPreset(preset = {}) { const bloomInput = typeof preset.bloom === 'object' ? preset.bloom : { intensity: preset.bloom }; let decodedBloomIntensity = null; if (typeof bloomInput.intensity === 'number') { decodedBloomIntensity = decodeBloomIntensity( bloomInput.intensity, bloomInput.version ?? preset.bloomVersion ?? BLOOM_SCALE_VERSION ); this._setBloomIntensity(decodedBloomIntensity, { syncShare: false }); } if (typeof bloomInput.enabled === 'boolean') { this._setBloomEnabled(bloomInput.enabled); } else if (typeof bloomInput.intensity === 'number') { this._setBloomEnabled((decodedBloomIntensity ?? this._getBloomIntensity()) > 0); } const sharpenInput = typeof preset.sharpen === 'object' ? preset.sharpen : { enabled: preset.sharpen }; if (typeof sharpenInput.intensity === 'number' && this._sharpenSlider) { const sharpenPct = Math.max(0, Math.min(100, Math.round(sharpenInput.intensity))); this._sharpenSlider.value = String(sharpenPct); this._sharpenSliderValue.textContent = `${sharpenPct}%`; this._applySharpenIntensity(sharpenPct / 100); } if (typeof sharpenInput.enabled === 'boolean') { this._setSharpenEnabled(sharpenInput.enabled); } else if (typeof sharpenInput.intensity === 'number') { this._setSharpenEnabled(sharpenInput.intensity > 0); } if (preset.hudVariant) { this._setHudVariant(preset.hudVariant); } if (preset.detectionMode) { this._setDetectionMode(preset.detectionMode); } if (typeof preset.detectionDensity === 'number' && this._detectionDensitySlider) { const density = canonicalizeDensity(preset.detectionDensity); this._detectionDensitySlider.value = String(density); this._detectionDensityValue.textContent = `${density}%`; this._applyDetectionDensityFromUi(); } if (preset.detectionAllocation) { this._setDetectionAllocation(preset.detectionAllocation, { syncShare: false }); } if (preset.styleParams && typeof preset.styleParams === 'object') { for (const [styleName, params] of Object.entries(preset.styleParams)) { const stage = this.stages[styleName]; if (!stage || !params || typeof params !== 'object') continue; for (const [uniformName, uniformValue] of Object.entries(params)) { if (stage.uniforms[uniformName] === undefined) continue; stage.uniforms[uniformName] = uniformValue; } } // Keep slider panel values in sync when updating the active style. this._updateSliderPanel(this.activeStyle); } this._syncShareState(); } /** * Enters or exits recording mode. When active, hides UI chrome via a body class, * displays a safe-frame composition overlay (16:9 or 9:16), and switches * the HUD to the specified mode. Exiting restores the HUD mode and layout * variant that were active before recording started. * @param {boolean} enabled - Whether to enable recording mode. * @param {object} [options] * @param {boolean} [options.hidePanels=true] - Hide all panel chrome. * @param {string} [options.hudMode='minimal'] - HUD mode while recording ('off'|'minimal'|'full'|'auto'). * @param {string} [options.safeFrame='16:9'] - Aspect ratio for the safe-frame overlay. * @returns {void} */ setRecordingMode(enabled, options = {}) { const { hidePanels = true, hudMode = 'minimal', safeFrame = '16:9' } = options; this._recordingMode = !!enabled; this._recordingConfig = { hidePanels, hudMode, safeFrame }; document.body.classList.toggle('recording-mode', this._recordingMode && hidePanels); if (this._safeFrameOverlay) { this._safeFrameOverlay.classList.remove('ratio-9-16', 'ratio-16-9'); this._safeFrameOverlay.classList.toggle('active', this._recordingMode); this._safeFrameOverlay.classList.add(safeFrame === '9:16' ? 'ratio-9-16' : 'ratio-16-9'); } if (this._recordingMode) { // Snapshot the user's HUD state once per recording session so exit can // restore it (re-entrant calls must not capture mid-recording state). if (!this._preRecordingHudState) { this._preRecordingHudState = { mode: this.hud.getMode(), variant: this.hud.getVariant(), }; } if (hudMode === 'off') { this.hud.setMode('off'); } else if (hudMode === 'full' || hudMode === 'minimal') { this.hud.setMode('on'); this.hud.setVariant(hudMode === 'minimal' ? 'minimal' : 'tactical'); if (this._hudLayoutSelect) this._hudLayoutSelect.value = this.hud.getVariant(); } else { this.hud.setMode('auto'); } } else { const saved = this._preRecordingHudState; this._preRecordingHudState = null; if (saved) { this.hud.setVariant(saved.variant); if (this._hudLayoutSelect) this._hudLayoutSelect.value = this.hud.getVariant(); } this.hud.setMode(saved ? saved.mode : 'auto'); if (this._safeFrameOverlay) { this._safeFrameOverlay.classList.remove('active', 'ratio-9-16', 'ratio-16-9'); } } this._hudBtn.classList.toggle('active', this.hud.visible); this._syncShareState(); } // ── 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 } = {}) { this._styleParameters ||= createStyleParameters({ container: this._sliderContainer }); this._styleParameters.clear(); const shader = STYLES[styleName]; if (!shader || !shader.uniforms || styleName === 'normal') { this._sliderPanel.classList.remove('active'); this._scheduleRightPanelLayout(); return; } this._styleParameters.render({ uniforms: shader.uniforms, readValue: (uName) => this.stages[styleName].uniforms[uName], writeValue: (uName, val) => { this.shareLinkManager?.claimRestoreLane?.('visual'); this.stages[styleName].uniforms[uName] = val; }, onChange: () => { // Uniform writes need an explicit render under the idle governor. governorRequestRender('style-param-slider'); this._syncShareState(); }, }); this._sliderPanel.classList.add('active'); this._scheduleRightPanelLayout(); if (reveal) this._revealStyleParameters(); } /** Reveal the map-only parameter surface in the standard Display scroll owner. */ _revealStyleParameters() { if (!this._sliderPanel?.classList.contains('active')) return; if (this._cockpitDisplayPortalActive) return; this._sliderPanel.classList.remove('collapsed'); this._syncPanelCollapseButton(this._sliderPanel); this.setPanelCollapsed('pp-toggles', false, { explicit: true }); requestAnimationFrame(() => requestAnimationFrame(() => { const scrollOwner = this._ppToggles; if (!scrollOwner) return; const ownerRect = scrollOwner.getBoundingClientRect(); const panelRect = this._sliderPanel.getBoundingClientRect(); scrollOwner.scrollTop += panelRect.top - ownerRect.top - 8; })); } // ── 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'|'snow'). * @param {object} [options] * @param {boolean} [options.applyPreset=true] - Whether to apply STYLE_PRESET_DEFAULTS for the new style. * @returns {void} */ setStyle(styleName, { applyPreset = true, revealParameters = applyPreset, restore = false, } = {}) { if (!restore) this.shareLinkManager?.claimRestoreLane?.('visual'); if (styleName === this.activeStyle) { if (revealParameters && styleName !== 'normal') this._revealStyleParameters(); return; } const previousStyle = this.activeStyle; this.activeStyle = styleName; document.documentElement.dataset.gevStyle = styleName; // The celestial optics treatment belongs to the unfiltered globe only. // Leaving Normal turns it off; returning merely re-enables the control. this.setCelestialRingEnabled(false, { syncShare: false, focus: false }); // Transition out the previous shader style if (previousStyle !== 'normal' && this.stages[previousStyle]) { this._startTransition(previousStyle, this.stages[previousStyle].uniforms.intensity, 0.0); } // Transition in the new shader style if (styleName !== 'normal' && this.stages[styleName]) { this._startTransition(styleName, this.stages[styleName].uniforms.intensity, 1.0); } if (applyPreset) { this._applyStylePresetDefaults(styleName); } // Update button UI document.querySelectorAll('.style-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.style === styleName); }); // Update style indicator const displayNames = { surveillance: 'NVG', thermal: 'FLIR', retro: 'CRT' }; this._styleIndicator.textContent = displayNames[styleName] || styleName.toUpperCase(); this._updateStyleMiniStatus(styleName); // Update parameter sliders this._updateSliderPanel(styleName, { reveal: revealParameters }); // Notify HUD (color adaptation + auto show/hide) this.hud.onStyleChange(styleName); this._updateHudButtonState(); // Sync detection overlay tone to active post-process style setDetectionStyle(styleName); this._syncIrBoost(); window.dispatchEvent(new CustomEvent('gev:style-change', { detail: { style: styleName }, })); this._syncCockpitInheritedStyle(); // Notify share link manager this.shareLinkManager.onStyleChange(styleName); this._syncShareState(); } // ── Shader transitions ──────────────────────── /** * Enqueues a smooth intensity transition for a shader stage. The animation * loop interpolates from `fromValue` to `toValue` over TRANSITION_DURATION_MS. * @param {string} styleName - Name of the shader stage to transition. * @param {number} fromValue - Starting intensity (typically current value). * @param {number} toValue - Target intensity (0.0 to fade out, 1.0 to fade in). * @returns {void} */ _startTransition(styleName, fromValue, toValue) { this._visualEffects.startTransition(styleName, fromValue, toValue); } /** * Sample the manager's layer set and paint the global loading chip. * Driven by manager events AND by a ticker, because the underlying state * machine is TIME-driven (reveal delay, long-load threshold, terminal * dwell) — see _armLoadingFeedbackTicker. * @param {number} [now] - performance.now() sample. * @returns {void} */ _updateGlobalLoadingFeedback(now = performance.now()) { if (!this._globalLoadingStatus) return; const summary = aggregateLayerLoading(this._dataManager?.getAll?.() || []); this._loadingFeedbackState = reduceLoadingFeedback( this._loadingFeedbackState, summary, now, this._loadingFeedbackEvent, ); this._loadingFeedbackEvent = null; const presentation = presentGlobalLoadingStatus( this._globalStatusNotice, this._loadingFeedbackState, summary, now, ); if (this._globalStatusNotice?.persistent !== true && Number.isFinite(this._globalStatusNotice?.hideAt) && now >= this._globalStatusNotice.hideAt) { this._globalStatusNotice = null; } // Loading phases and universal notices both have time-driven transitions. // Compute this after arbitration: a queued finite notice starts its dwell // only on its first visible frame, then keeps the ticker alive to expiry. const noticeNeedsTicker = Number.isFinite(this._globalStatusNotice?.hideAt); if (this._loadingFeedbackState?.phase !== 'idle' || noticeNeedsTicker) { this._armLoadingFeedbackTicker(); } this._globalLoadingStatus.hidden = !presentation; if (!presentation) { delete this._globalLoadingStatus.dataset.state; return; } this._globalLoadingStatus.dataset.state = presentation.state; // Split-flap the LABEL only ("LOADING LIVE DATA" -> "LOAD COMPLETE"). // setSplitFlapText is a no-op when the text is unchanged, which matters // here: this runs on every 60 ms and 500 ms tick. The detail line is the // live layer roster inside an ellipsised, width-capped span — flapping a // list that churns as layers join would be noise, not delight. setSplitFlapText(this._globalLoadingLabel, presentation.label); this._globalLoadingDetail.textContent = presentation.detail; } /** Show a message in the universal top-center status banner. */ _showGlobalStatusNotice(message, options = {}) { const now = performance.now(); this._globalStatusNotice = createGlobalStatusNotice(message, now, options); this._updateGlobalLoadingFeedback(now); } /** * 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(); } /** * 500 ms DOM ticker for the traffic sync chip (was per-frame). It also * polls the loading chip as a safety net: a camera-driven layer can flip * its own `stats.loading` without emitting a manager event, and that is * the one loading start the event path cannot see. */ _startTrafficChipTicker() { if (this._trafficChipTicker) return; this._trafficChipTicker = setInterval(() => { if (document.hidden) return; this._updateTrafficSyncChip(); this._updateGlobalLoadingFeedback(); }, 500); } /** * Self-stopping 60 ms ticker for the global loading chip. * * The chip used to ride the style rAF loop, which perf wave 2 made * self-stopping — leaving the chip frozen mid-state whenever no crossfade * or animated shader was running (it would never reveal, never cross the * long-load threshold, and never dwell out). Its reducer * (src/loadingFeedback.js) is time-driven, so it needs real ticks; it is * also pure DOM, so it takes NO governor hold and requests no render. * Armed by _updateGlobalLoadingFeedback whenever loading leaves idle or a * universal notice begins, and stops once both have settled. * (rebase 2026-08-16: main's loading chip vs wave 2's stopped loop) * @returns {void} */ _armLoadingFeedbackTicker() { // Never arm behind a hidden tab: the reducer cannot usefully advance a // chip nobody can see, and the old `return` INSIDE the interval left the // 60ms timer scheduled for the entire hidden period (a batch completing // while hidden could never clear it — the idle check sat behind the // hidden guard). visibilitychange resamples and re-arms on return. if (this._loadingFeedbackTicker || document.hidden) return; this._loadingFeedbackTicker = setInterval(() => { if (document.hidden) { this._stopLoadingFeedbackTicker(); return; } const now = performance.now(); this._lastLoadingFeedbackUpdateAt = now; this._updateGlobalLoadingFeedback(now); const noticeNeedsTicker = Number.isFinite(this._globalStatusNotice?.hideAt); if (this._loadingFeedbackState?.phase === 'idle' && !noticeNeedsTicker) { this._stopLoadingFeedbackTicker(); } }, 60); } /** Stop the loading-chip ticker if it is running. Idempotent. */ _stopLoadingFeedbackTicker() { if (!this._loadingFeedbackTicker) return; clearInterval(this._loadingFeedbackTicker); this._loadingFeedbackTicker = null; } // ── Location Bar ───────────────────────────── /** * Initializes the location bar: renders city pills from CITY_POIS, sets up * QWERTY keyboard navigation for POI selection, wires the search toggle * and geocoding search input. * @returns {void} */ _initLocationBar() { this._locationControls?.destroy(); this._locationLookup?.destroy(); this._locationLookup = new LocationSearch({ input: this._locationSearch, begin: () => this._beginDeferredNavigation('location'), isCurrent: (generation) => !this._disposed && generation === this._navigationGeneration, beforeFly: (generation) => this._reassertNavigationHandoff(generation), search: (query, options) => searchAndFlyTo(this.viewer, query, { placeSearch: this.placeSearch, ...options, }), onStart: (generation) => { this._activeLocationSearchGeneration = generation; }, onResult: (destination, query) => { this._searchedLocationLabel = destination.label || query; this._setActiveLocation(null); this._currentPoi = null; this._collapsePOIRow(); this._updateLocationMiniStatus(); }, onMissing: () => this._showToast('Location not found'), onError: (error) => { console.error('[Search] Geocoding failed:', error); this._showToast('Search failed'); }, onSettled: (generation) => this._settleLocationSearchUi(generation), }); this._locationControls = new LocationControls({ elements: { pills: this._locationPills, poiRow: this._poiRow, divider: this._locationBarDivider, search: this._locationSearch, searchToggle: this._searchToggle, resetButtons: [this._resetGlobeBtn, this._cockpitResetGlobeBtn], statusCity: this._locationMiniCity, statusPoi: this._locationMiniPoi, }, cities: CITY_POIS, getExpandedCity: () => this._expandedCityId, onCity: (id) => this._onCityPillClick(id), onPoi: (id, index) => this._onPoiClick(id, index), onSearch: (query) => this._locationLookup.run(query), onReset: () => this.resetToGlobeView(), }); } /** * Signals the start of an inter-city world jump: notifies the traffic layer * to pause tile fetching and suspends detection overlays to prevent stale * rendering during the flight. * @returns {void} */ _beginWorldJumpTransition() { clearTimeout(this._trafficTransitionTimer); trafficLayer.beginWorldJump?.(); suspendDetection('intercity'); } /** * Signals the end of an inter-city world jump: resumes traffic tile fetching, * resumes detection overlays, and forces a traffic sync chip update. * @returns {void} */ _endWorldJumpTransition() { clearTimeout(this._trafficTransitionTimer); trafficLayer.endWorldJump?.(); resumeDetection(); this._updateTrafficSyncChip(true); } /** * Wraps a fly-to action with world-jump transition hooks when the target * city differs from the current one. Applies begin/end transition signals * with a 5.2s safety timeout to guarantee cleanup if the flight callback * never fires onComplete. * @param {boolean} cityChanged - Whether the destination is in a different city. * @param {function} flyAction - Callback receiving `{onStart, onComplete}` hooks; should return a result with targetPosition. * @returns {*} Return value from flyAction. */ _flyWithTransition(cityChanged, flyAction) { return this._runExplicitNavigation('location', () => { if (!cityChanged) return flyAction({}); let completed = false; const finalize = () => { if (completed) return; completed = true; this._endWorldJumpTransition(); }; const result = flyAction({ onStart: () => this._beginWorldJumpTransition(), onComplete: finalize, }); this._trafficTransitionTimer = window.setTimeout(finalize, 5200); return result; }); } /** * 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 }); } /** * Handles a city pill click: toggles POI row collapse if same city, * otherwise expands the POI row, flies to the city's first POI, and * tracks the target position for orbit mode. * @param {string} cityId - Identifier of the clicked city. * @returns {void} */ _onCityPillClick(cityId) { if (this._expandedCityId === cityId) { // Same city clicked again — toggle collapse this._collapsePOIRow(); return; } const isCityChanged = this._activeLocationId && this._activeLocationId !== cityId; const result = this._flyWithTransition(!!isCityChanged, (hooks) => flyToPresetLocation(this.viewer, cityId, hooks)); if (result === false) return; this._expandPOIRow(cityId); this._setActiveLocation(cityId); this._activePoiIndex = 0; this._updatePoiHighlight(); // Track current target + POI for orbit if (result) { this._currentTarget = result.targetPosition; this._currentPoi = CITY_POIS[cityId].pois[0]; } this._updateLocationMiniStatus(); } /** * Handles a POI pill click: stops orbit, flies to the POI, highlights it, * and saves the target position for future orbit activation. * @param {string} cityId - Parent city identifier. * @param {number} poiIndex - Index of the POI within the city's pois array. * @returns {void} */ _onPoiClick(cityId, poiIndex) { const isCityChanged = this._activeLocationId && this._activeLocationId !== cityId; const result = this._flyWithTransition(!!isCityChanged, (hooks) => flyToPOI(this.viewer, cityId, poiIndex, hooks)); if (result === false) return; this._setActiveLocation(cityId); this._activePoiIndex = poiIndex; this._updatePoiHighlight(); // Track current target + POI for orbit if (result) { this._currentTarget = result.targetPosition; this._currentPoi = CITY_POIS[cityId].pois[poiIndex]; } this._updateLocationMiniStatus(); } /** * Builds and shows the POI pill row for a city. Each pill displays a * QWERTY keyboard shortcut key and the POI name. * @param {string} cityId - City whose POIs to render. * @returns {void} */ _expandPOIRow(cityId) { if (!CITY_POIS[cityId]) return; this._expandedCityId = cityId; this._locationControls.showPois(cityId); } /** * Hides the POI pill row and clears the expanded city state. * @returns {void} */ _collapsePOIRow() { this._expandedCityId = null; this._activePoiIndex = null; this._locationControls.hidePois(); } /** * Highlights the active POI pill and removes highlight from all others. * @returns {void} */ _updatePoiHighlight() { this._locationControls.highlightPoi(this._activePoiIndex); } /** * Forget the last free-text search destination and repaint the LOCATION * readout. Public so camera owners that fly on their own — scene playback * most of all — can invalidate it without reaching into private state. * @returns {void} */ clearSearchedLocation() { if (this._searchedLocationLabel === null) return; this._searchedLocationLabel = null; this._updateLocationMiniStatus(); } /** * Sets the active city location, highlights its pill, and updates the mini-status readout. * @param {string|null} locationId - City identifier, or null to clear. * @returns {void} */ _setActiveLocation(locationId) { this._activeLocationId = locationId; // A preset city is now what the camera is framed on, so any earlier // free-text destination has been superseded. Clearing only on a real id // leaves the search path's own _setActiveLocation(null) untouched. if (locationId) this._searchedLocationLabel = null; this._locationControls?.highlightCity(locationId); this._updateLocationMiniStatus(); } /** * Updates the collapsed mini-status readout with the current destination: * a preset city + POI/landmark, or the last free-text geocode search. * @returns {void} */ _updateLocationMiniStatus() { this._locationControls?.renderStatus({ city: this._activeLocationId ? CITY_POIS[this._activeLocationId] : null, currentPoi: this._currentPoi, searchedLabel: this._searchedLocationLabel, }); } /** * Updates the collapsed mini-status readout with the active style label. * @param {string} [styleName=this.activeStyle] - Style name to display. * @returns {void} */ _updateStyleMiniStatus(styleName = this.activeStyle) { if (!this._styleMiniValue) return; this._styleMiniValue.textContent = STYLE_STATUS_LABELS[styleName] || String(styleName || 'normal').toUpperCase(); } // ── Orbit Mode ────────────────────────────── /** * Creates the orbit mode indicator DOM element and appends it to the body. * @returns {void} */ _initOrbit() { this._orbitIndicator = this._locationControls.createOrbitIndicator(); } /** * Toggles the orbit controller around the current POI target. Shows a toast * if no target position has been set (user must fly to a POI first). * @returns {void} */ _toggleOrbit() { if (!this._currentTarget) { this._showToast('Fly to a POI first'); return; } const isActive = this.orbitController.toggle(this._currentTarget, { radius: this._currentPoi?.alt || 500, pitch: this._currentPoi?.pitch || -30, }); this._orbitIndicator.classList.toggle('active', isActive); } /** * Stops orbit mode if active and hides the orbit indicator. * @returns {void} */ _stopOrbit() { if (this.orbitController.active) { this.orbitController.stop(); this._orbitIndicator.classList.remove('active'); } } /** 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()); } /** * Clear every selected data layer without resetting visual, map, HUD, or * camera state. A layer may still release camera work it owns as part of its * established disable lifecycle. * @returns {Promise} Aggregate manager lifecycle truth for the batch. */ clearSelectedLayers(...args) { return this._contextControls?.clearSelectedLayers(...args); } /** * Release every camera owner and return to the canonical full-globe frame. * Repeated requests adopt the in-flight reset rather than cancelling it. * @returns {Promise} Canonical reset result shared with voice. */ resetToGlobeView() { if (this._globeResetPromise) return this._globeResetPromise; this._stampNavigation(); interruptCameraMotion('reset-globe'); this._stopOrbit(); this.cockpitView?.exit({ restoreTracking: false }); try { militaryAwarenessLayer.releaseCameraOwnership?.({ origin: 'tool' }); } catch { // Keep reset available if Context has not initialized completely. try { flightsLayer.stopTracking?.({ origin: 'tool' }); } catch { /* best-effort release */ } try { militaryFlightsLayer.stopTracking?.({ origin: 'tool' }); } catch { /* best-effort release */ } try { aisLiveVesselsLayer.clearSelection?.(); } catch { /* best-effort release */ } } try { satellitesLayer.stopTracking?.({ origin: 'tool' }); } catch { /* best-effort release */ } try { rocketLaunchesLayer.releaseCameraOwnership?.(); } catch { /* best-effort release */ } this.viewer.trackedEntity = undefined; this.viewer.camera.cancelFlight(); this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); this._beginWorldJumpTransition(); let resolveReset; const resetPromise = new Promise((resolve) => { resolveReset = resolve; }); this._globeResetPromise = resetPromise; let settled = false; let timer = null; const finish = (cancelled = false) => { if (settled) return; settled = true; clearTimeout(timer); this._endWorldJumpTransition(); const carto = this.viewer.camera.positionCartographic; const result = { ok: !cancelled, action: 'zoom_to_globe', cancelled, heightKm: Math.round(GLOBE_VIEW.heightM / 1000), centeredOn: { latitude: Number(Cesium.Math.toDegrees(carto.latitude).toFixed(2)), longitude: Number(Cesium.Math.toDegrees(carto.longitude).toFixed(2)), }, }; this._resetGlobeBtn?.setAttribute('aria-label', 'Reset to full globe view'); this._cockpitResetGlobeBtn?.setAttribute('aria-label', 'Reset cockpit to full globe view'); this._globeResetPromise = null; resolveReset(result); }; timer = window.setTimeout(() => { const height = this.viewer.camera.positionCartographic?.height; finish(!Number.isFinite(height) || Math.abs(height - GLOBE_VIEW.heightM) > 1000); }, 4200); this._resetGlobeBtn?.setAttribute('aria-label', 'Resetting to full globe view'); this._cockpitResetGlobeBtn?.setAttribute('aria-label', 'Resetting cockpit to full globe view'); const target = flyToGlobeView(this.viewer, { onComplete: () => finish(false), onCancel: () => finish(true), }); if (!target) finish(true); return resetPromise; } // ── Share Button ───────────────────────────── /** * Wires the share button click to copy the current share link to the clipboard. * @returns {void} */ _initShareButton() { this._shareBtn.addEventListener('click', async () => { const success = await this.shareLinkManager.copyLink(); this._showToast(success ? 'Link copied!' : 'Copy failed'); }); } /** * Displays a temporary toast notification for 2 seconds. * @param {string} message - Text to show in the toast. * @returns {void} */ _showToast(message) { this._toast.textContent = message; this._toast.classList.add('visible'); clearTimeout(this._toastTimer); this._toastTimer = setTimeout(() => { this._toast.classList.remove('visible'); }, 2000); } // ── 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} */ /** One 3D toggle drives BOTH aircraft layers (commercial + military) so all planes flip together. */ _setModels3dParams(params, { origin = 'user' } = {}) { this._dataManager?.setLayerParams('flights', params, { origin }); this._dataManager?.setLayerParams('military', params, { origin }); } _syncModels3dFromLayerState(state) { const options = state?.options?.flights; if (!options) return; this._models3dEnabled = options.models3d === true; this._models3dMode = options.models3dMode === 'all' ? 'all' : 'proximity'; this._syncModels3dButtonState(); this._models3dModeRow?.classList.toggle('visible', this._models3dEnabled); for (const button of this._models3dModeBtns || []) { if (!button) continue; const active = button.dataset.mode === this._models3dMode; button.classList.toggle('active', active); button.setAttribute('aria-checked', String(active)); } this._layoutRightPanels(); } _syncModels3dModeRow() { if (this._models3dModeRow) this._models3dModeRow.classList.toggle('visible', this._models3dEnabled); this._layoutRightPanels(); } _initModels3dToggle() { if (!this._models3dBtn) return; this._syncModels3dButtonState(); this._syncModels3dModeRow(); } _setModels3dEnabled(enabled) { this._models3dEnabled = !!enabled; this._setModels3dParams({ models3d: this._models3dEnabled }); this._syncModels3dButtonState(); } _setModels3dMode(mode) { const normalized = mode === 'all' ? 'all' : 'proximity'; this._models3dMode = normalized; this._setModels3dParams({ models3dMode: normalized }); for (const button of this._models3dModeBtns) { if (!button) continue; const active = button.dataset.mode === normalized; button.classList.toggle('active', active); button.setAttribute('aria-checked', String(active)); } this._syncModels3dButtonState(); } _syncModels3dButtonState() { this._models3dBtn?.classList.toggle('active', this._models3dEnabled); // The lit/dark state is a colour to a sighted operator and nothing at all to // a screen reader without this. It matters more now that the button ships // ACTIVE from markup (default-on, 2026-08-22): the very first thing assistive // tech reported was an unpressed-looking control over an armed layer. // Mirrors #scope-toggle, which has always carried aria-pressed. this._models3dBtn?.setAttribute('aria-pressed', String(this._models3dEnabled)); } _initHUDToggle() { if (this._hudLayoutSelect) { this._hudLayoutSelect.value = 'tactical'; } this._setHudVariant('tactical'); this.hud.setMode('on'); this._updateHudButtonState(); this._cockpitDisplayToggleBtn?.addEventListener('click', () => { const open = this._cockpitDisplayToggleBtn.getAttribute('aria-expanded') === 'true'; this._setCockpitDisclosure?.('display', !open); }); this._initCockpitDisplayPortal(); } /** * Reuses the production Display controls inside Cockpit without cloning * stateful inputs or event listeners. Comment anchors preserve each group's * exact home in the standard Display panel for exit and teardown. * @returns {void} */ _initCockpitDisplayPortal() { this._cockpitDisplayPortal?.destroy(); this._cockpitDisplayPortal = new CockpitDisplayPortal({ standardPanel: this._ppToggles, cockpitPanel: this._cockpitDisplayPanel, groups: [ ['hud', this._hudBtn?.closest('.pp-toggle-group')], ['detection', this._detectionBtn?.closest('.pp-toggle-group')], ['parameters', this._sliderPanel], ['models3d', this._models3dBtn?.closest('.pp-toggle-group')], ], layout: () => { this._layoutRightPanels(); this.cockpitView?.scheduleContextLayout(); }, }); } /** * Moves the shared HUD, Detection, Parameters, and 3D controls into or out * of Cockpit. * @param {boolean} active Whether Cockpit owns the Display control groups. * @returns {void} */ _setCockpitDisplayPortalActive(active) { this._cockpitDisplayPortal?.setActive(active); } get _cockpitDisplayPortalActive() { return this._cockpitDisplayPortal?.active ?? false; } get _displayPortalScrollRestoreOwner() { return this._cockpitDisplayPortal?.restoreOwner ?? null; } get _standardDisplayScrollTop() { return this._cockpitDisplayPortal?.standardScrollTop ?? 0; } /** * Syncs the HUD toggle button active class and HUD layout row visibility * with the current HUD visible state. * @returns {void} */ _updateHudButtonState() { this._hudBtn.classList.toggle('active', this.hud.visible); if (this._hudLayoutRow) { this._hudLayoutRow.classList.toggle('visible', this.hud.visible); } this._scheduleAdaptivePanelLayout({ settle: true }); } /** * Updates the detection toggle button label and CSS classes to reflect * the current density-derived profile. Also toggles the density and * allocation controls together. * @param {string} modeLabel - Current detection mode label. * @returns {void} */ _updateDetectionButton(modeLabel) { const btn = this._detectionBtn; const enabled = modeLabel !== 'OFF'; btn.setAttribute('aria-pressed', String(enabled)); btn.setAttribute('aria-label', enabled ? `Detection overlay: ${String(modeLabel).toLowerCase()}` : 'Detection overlay: off'); btn.classList.remove('active', 'god', 'panoptic'); if (modeLabel === 'SPARSE') { btn.querySelector('.pp-label').textContent = 'SPARSE'; btn.classList.add('active'); } else if (modeLabel === 'BALANCED') { btn.querySelector('.pp-label').textContent = 'BALANCED'; btn.classList.add('active'); } else if (modeLabel === 'DENSE') { btn.querySelector('.pp-label').textContent = 'DENSE'; btn.classList.add('active', 'panoptic'); } else { btn.querySelector('.pp-label').textContent = 'DETECT'; } if (this._detectionSliderRow) { this._detectionSliderRow.classList.toggle('visible', modeLabel !== 'OFF'); } if (this._detectionAllocationRow) { this._detectionAllocationRow.classList.toggle('visible', modeLabel !== 'OFF'); } if (this._detectionFadeRow) { this._detectionFadeRow.classList.toggle('visible', modeLabel !== 'OFF'); } if (this._detectionOpacityRow) { this._detectionOpacityRow.classList.toggle('visible', modeLabel !== 'OFF'); } this._layoutRightPanels(); } /** * Positions the parameter slider panel directly below the right-rail toggle * panel, right-aligned to it. Clamps to viewport bounds to prevent overflow. * Runs inside a rAF to batch with other layout reads. * @returns {void} */ _layoutRightPanels() { this._scheduleRightPanelLayout(); } /** * 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'); requestAnimationFrame(() => { 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`; } }); } /** Whether a share link was used to load the page */ get hasShareState() { return !!this._hasShareState; } /** Terminal result for the complete initial share restoration. */ get initialRestorePromise() { return this._initialShareRestorePromise || Promise.resolve({ status: 'not-requested' }); } _settleInitialShareRestore(result) { if (!this._resolveInitialShareRestore) return; const resolve = this._resolveInitialShareRestore; this._resolveInitialShareRestore = null; resolve(result); window.dispatchEvent(new CustomEvent('gev:initial-share-restore-settled', { detail: result })); } /** * 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} Resolves after focused-session state restoration. */ async dispose() { if (this._disposed) return; this._shareTrackingNoticeGeneration += 1; this._shareTrackingAcquiringKey = null; this._globalStatusNotice = null; if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true; this._disposed = true; this._applicationShortcuts?.destroy(); this._displayControls?.destroy(); this._mapSourceControls?.destroy(); this._clearLayersControl?.destroy(); this._locationControls?.destroy(); this._cctvControls?.destroy(); this._radioControls?.destroy(); this._visualEffects.stop(); this._styleParameters?.destroy(); for (const control of this._panelDisclosureControls || []) control.destroy(); this._panelDisclosureControls = []; this._hoverPanelControls?.forEach((control) => control.destroy()); this._hoverPanelControls?.clear(); this._locationLookup?.destroy(); this._cancelMapSourceFocus?.(); // Revoke persistence/hash authority before teardown can emit manager changes. this._layerStateCoordinator?.destroy(); this._layerStateCoordinator = null; this._layerStateRestorePromise = null; clearTimeout(this._initialShareRestoreTimeout); this._initialShareRestoreTimeout = null; this._settleInitialShareRestore({ status: 'destroyed', share: null, layers: [] }); if (this._initialShareGestureHandler) { this.viewer?.canvas?.removeEventListener('pointerdown', this._initialShareGestureHandler); this.viewer?.canvas?.removeEventListener('wheel', this._initialShareGestureHandler); this._initialShareGestureHandler = null; } this.shareLinkManager?.destroy(); if (this._awarenessSelectedHandler) { window.removeEventListener('gev:awareness-subject-selected', this._awarenessSelectedHandler); this._awarenessSelectedHandler = null; } if (this._awarenessClearedHandler) { window.removeEventListener('gev:awareness-subject-cleared', this._awarenessClearedHandler); this._awarenessClearedHandler = null; } // 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._stampNavigation(); // 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. this._removeCctvRequestFocusListener?.(); this._removeCctvRequestFocusListener = null; this._cctvRequestFocusHandler = null; this._removeWorldRequestFocusListener?.(); this._removeWorldRequestFocusListener = null; this._worldRequestFocusHandler = null; this._navigationOwnerChangedRemover?.(); this._navigationOwnerChangedRemover = null; this._removeNavigationAuthorityListener?.(); this._removeNavigationAuthorityListener = null; 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). if (this._irBoostActive) { if (this._irFogWasEnabled != null && this.viewer?.scene?.fog) { this.viewer.scene.fog.enabled = this._irFogWasEnabled; } this._dataManager?.setLayerParams('flights', { irBoost: false }); this._dataManager?.setLayerParams('military', { irBoost: false }); this._irBoostActive = false; this._irFogWasEnabled = null; } this.cockpitView?.dispose(); this._cockpitDisplayPortal?.destroy(); this._cockpitDisplayPortal = null; this._contextControls.disconnect(); this._dataManagerUnsubscribe?.(); this._dataManagerUnsubscribe = null; this._commandDockTrayObserver?.disconnect?.(); this._commandDockTrayObserver = null; this._draggableResizeObserver?.disconnect(); this._draggableResizeObserver = null; if (this._windowResizeHandler) { window.removeEventListener('resize', this._windowResizeHandler); this._windowResizeHandler = null; } if (this._loadingVisibilityHandler) { document.removeEventListener('visibilitychange', this._loadingVisibilityHandler); this._loadingVisibilityHandler = null; } this._stopLoadingFeedbackTicker(); // Stop the independently owned traffic and loading feedback tickers. if (this._trafficChipTicker) { clearInterval(this._trafficChipTicker); this._trafficChipTicker = null; } if (this._loadingFeedbackTicker) { clearInterval(this._loadingFeedbackTicker); this._loadingFeedbackTicker = null; } if (this._leftStackLayoutFrame !== null) { cancelAnimationFrame(this._leftStackLayoutFrame); this._leftStackLayoutFrame = null; } if (this._rightStackLayoutFrame !== null) { cancelAnimationFrame(this._rightStackLayoutFrame); this._rightStackLayoutFrame = null; } clearTimeout(this._adaptivePanelSettleTimer); this._adaptivePanelSettleTimer = null; this._leftStackResizeObserver?.disconnect(); this._leftStackResizeObserver = null; this._leftStackMutationObserver?.disconnect(); this._leftStackMutationObserver = null; this._rightStackResizeObserver?.disconnect(); this._rightStackResizeObserver = null; this._rightStackMutationObserver?.disconnect(); this._rightStackMutationObserver = null; if (this._leftStackHudTransitionHandler) { document.getElementById('intel-hud')?.removeEventListener( 'transitionend', this._leftStackHudTransitionHandler, ); this._leftStackHudTransitionHandler = null; } if (this._rightStackHudTransitionHandler) { document.getElementById('intel-hud')?.removeEventListener( 'transitionend', this._rightStackHudTransitionHandler, ); this._rightStackHudTransitionHandler = null; } if (this._leftStackCockpitModeHandler) { window.removeEventListener('gev:cockpit-mode-changed', this._leftStackCockpitModeHandler); this._leftStackCockpitModeHandler = null; } destroyTrackedReadout(); destroyDetection(); destroyWorldOverlay(); this._visualEffects.destroy(); } }