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_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION, clampBloomIntensity, decodeBloomIntensity, } from './bloom.js'; import { LOCATIONS, 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, { buildRadioTunerTicks, radioTunerCommitSlot, radioTunerPointerPosition, radioTunerSlot, } 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, presentGlobalStatusNotice, presentLoadingFeedback, reduceLoadingFeedback, reduceTrafficSyncFeedback, } from './loadingFeedback.js'; import { setSplitFlapText } from './splitFlap.js'; import { cockpitEntryAllowed, contextAllowedLayerIds, contextLayerEnableBlockReason, contextRestoreLayerIds, contextSnapshotLayerIds, isExplicitUserIntentOrigin, mergeContextTransitionErrors, recordContextRestoreExplicitChange, recordContextSessionUserChange, runWithContextModeChanging, settleContextModeChange, settleContextIntentReplay, settleUserFacingContextAction, shouldCaptureContextSession, shouldDeferContextEntryDuringClear, shouldExitContextForLayerChange, spaceMissionEntryCancellationDisposition, contextModeWord, } from './contextModePolicy.js'; import { shouldExpandGlobalContextPanel, } from './rightRailPolicy.js'; import { resolveCockpitUtilityAnchor, resolveCockpitUtilityLayout, } from './cockpitUtilityLayout.js'; import { applyCockpitVisionStageIntensities, captureCockpitVisionBaseline, COCKPIT_VISION_MODES, 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'; import { altitudeRulerCurveInset, altitudeRulerTicks, bearingBetweenCoordinates, cockpitAnchorCorrectionStep, cockpitAltitudeDisplayFt, cockpitGroundSafeHeight, cockpitSurfaceWaitExpired, cockpitUiUpdateDue, compassDivisions, formatAltitudeRulerTick, formatCockpitContextScope, formatCompassDivision, formatSpeedRulerTick, normalizeHeading, relativeBearing, resolveCockpitContextReadout, resolveHudRailLayout, resolveTrackedAircraftInfo, slewHeading, speedRulerTicks, } from './cockpitMath.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_HEADING_SLEW_DPS = 28; const COCKPIT_FORWARD_OFFSET_M = 7; const COCKPIT_UP_OFFSET_M = 2.6; const COCKPIT_MIN_GROUND_CLEARANCE_M = 12; const COCKPIT_VIEW_PITCH_DEG = -4; const COCKPIT_CAMERA_UPDATE_MS = 50; const COCKPIT_HUD_UPDATE_MS = 100; const COCKPIT_CONTEXT_UPDATE_MS = 250; const COCKPIT_LAYOUT_SETTLE_MS = 240; // Cockpit's Display/Radio strip hangs off the REC readout it shares the right // margin with, and never touches the briefing card underneath it. const COCKPIT_UTILITY_REC_GAP_PX = 12; const COCKPIT_UTILITY_SIGNAL_GAP_PX = 8; const COCKPIT_UTILITY_MIN_TOP_PX = 96; const COCKPIT_UTILITY_MIN_TOP_RATIO = 0.12; const COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX = 50; const COCKPIT_GROUND_PROBE_MS = 500; const COCKPIT_GROUND_WAIT_TIMEOUT_MS = 5000; const COCKPIT_BRIEF_ROTATE_MS = 9000; const COCKPIT_BRIEF_CYCLE_OFF_HELP = 'Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.'; const COCKPIT_BRIEF_CYCLE_ON_HELP = 'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.'; const COCKPIT_REGIONAL_REFRESH_MS = 5 * 60_000; const COCKPIT_REGIONAL_REFRESH_DISTANCE_M = 25_000; const COCKPIT_BRIEF_PAGES = [ { id: 'signals', kicker: 'LIVE SIGNALS', subtitle: 'OBSERVED / MAPPED PINGS', source: 'SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS', }, { id: 'news', kicker: 'REGIONAL NEWS', subtitle: 'LATEST LOCATION-MATCHED REPORTING', source: 'GOOGLE NEWS RSS · LOCATION QUERY · RECENT', }, { id: 'local', kicker: 'LOCAL INFO', subtitle: 'PLACE / CONDITIONS / POSITION', source: 'OPENSTREETMAP · OPEN-METEO · UTC', }, ]; /** * 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(', '); /** * Whether an element currently occupies screen space a layout must respect. * * A rect alone is not enough: `visibility: hidden` and `opacity: 0` (how the * Intel HUD retires as a whole) leave the rect intact, so anything anchoring to * a HUD readout must walk the ancestors as well. * * @param {Element|null|undefined} element - Candidate element. * @returns {boolean} True when the element is painted and has area. */ function isRenderedOnScreen(element) { if (!element) return false; for (let node = element; node instanceof Element; node = node.parentElement) { const style = getComputedStyle(node); if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) { return false; } } const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } /** * 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. */ /** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */ const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180; /** * Field definitions for the CCTV click-to-edit pose readout. Chips DISPLAY the * camera's EFFECTIVE pose (not raw offsets — "HDG 135.0°" instead of the old * "HEADING 0°" nonsense); typed values convert back to calibration offsets * against the frozen basePose. ΔN/ΔE stay offset-denominated (absolute lat/lon * typing is user-hostile). */ const CCTV_CAL_FIELDS = { heading: { label: 'HDG', unit: '°', decimals: 1, get: (cam) => cam.headingDeg, toPatch: (value, base) => ({ headingDeg: signedNormalizeDeg(value - base.headingDeg) }), }, pitch: { label: 'PITCH', unit: '°', decimals: 1, get: (cam) => cam.pitchDeg, toPatch: (value, base) => ({ pitchDeg: value - base.pitchDeg }), }, fov: { label: 'FOV', unit: '°', decimals: 0, get: (cam) => cam.fovDeg, toPatch: (value, base) => ({ fovDeg: value - base.fovDeg }), }, range: { label: 'RANGE', unit: 'm', decimals: 0, get: (cam) => cam.rangeM, toPatch: (value, base) => ({ rangeScale: base.rangeM > 0 ? value / base.rangeM : 1 }), }, height: { label: 'HGT', unit: 'm', decimals: 0, get: (cam) => cam.mountHeightM, toPatch: (value, base) => ({ heightM: value - base.mountHeightM }), }, north: { label: 'ΔN', unit: 'm', decimals: 1, get: (cam) => cam.calibration?.offsetNorthM || 0, toPatch: (value) => ({ offsetNorthM: value }), }, east: { label: 'ΔE', unit: 'm', decimals: 1, get: (cam) => cam.calibration?.offsetEastM || 0, toPatch: (value) => ({ offsetEastM: value }), }, }; function formatCockpitBriefAge(value) { const timestamp = Date.parse(value); if (!Number.isFinite(timestamp)) return 'TIME UNKNOWN'; const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000)); if (minutes < 60) return `${minutes}M AGO`; const hours = Math.round(minutes / 60); return hours < 48 ? `${hours}H AGO` : `${Math.round(hours / 24)}D AGO`; } function formatCockpitWindDirection(value) { if (!Number.isFinite(value)) return 'DIR UNKNOWN'; const labels = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; const normalized = ((value % 360) + 360) % 360; return `${labels[Math.round(normalized / 45) % labels.length]} · ${Math.round(normalized)}°`; } function setCockpitRollingValue(element, text, numericValue, { circularRange = null, immediate = false, } = {}) { if (!element) return; const nextText = String(text); const previousText = element.dataset.rollingText; const previousValue = Number(element.dataset.rollingValue); const nowMs = performance.now(); const lastRollMs = Number(element.dataset.rollingAt); if (!immediate && previousText !== undefined && previousText !== nextText && Number.isFinite(lastRollMs) && nowMs - lastRollMs < 220) { return; } element.dataset.rollingText = nextText; element.dataset.rollingAt = String(nowMs); if (Number.isFinite(numericValue)) element.dataset.rollingValue = String(numericValue); else delete element.dataset.rollingValue; element.setAttribute('aria-label', nextText); if (immediate || previousText === undefined || previousText === nextText) { if (previousText !== nextText || !element.querySelector('.cockpit-roll-token')) { element.replaceChildren(...Array.from(nextText, (character) => { const token = document.createElement('span'); token.className = 'cockpit-roll-token'; token.setAttribute('aria-hidden', 'true'); token.textContent = character; return token; })); } return; } let delta = Number.isFinite(numericValue) && Number.isFinite(previousValue) ? numericValue - previousValue : 0; if (Number.isFinite(circularRange) && circularRange > 0) { const halfRange = circularRange / 2; if (delta > halfRange) delta -= circularRange; else if (delta < -halfRange) delta += circularRange; } const direction = delta < 0 ? 'down' : 'up'; const width = Math.max(previousText.length, nextText.length); const from = previousText.padStart(width, ' '); const to = nextText.padStart(width, ' '); const fragment = document.createDocumentFragment(); for (let index = 0; index < width; index += 1) { const previousCharacter = from[index]; const nextCharacter = to[index]; const token = document.createElement('span'); token.className = 'cockpit-roll-token'; token.setAttribute('aria-hidden', 'true'); if (previousCharacter === nextCharacter || !/\d/.test(previousCharacter) || !/\d/.test(nextCharacter)) { token.textContent = nextCharacter === ' ' ? '\u00a0' : nextCharacter; fragment.append(token); continue; } token.classList.add('is-rolling', `roll-${direction}`); const track = document.createElement('span'); track.className = 'cockpit-roll-track'; const first = document.createElement('span'); const second = document.createElement('span'); first.textContent = direction === 'up' ? previousCharacter : nextCharacter; second.textContent = direction === 'up' ? nextCharacter : previousCharacter; track.append(first, second); token.append(track); fragment.append(token); } element.replaceChildren(fragment); } class CockpitViewController { constructor(viewer, { onVisionChange = null, onCameraTakeover = null, isEntryAllowed = null, onEntered = null, onExited = null, getInheritedVisionLabel = null, restoreTrackingFrame = null, } = {}) { this.viewer = viewer; this.active = false; this.trackedEntity = null; this.trackedEntityWasShown = true; this.heading = null; this.lastFrameMs = 0; this.lastCameraUpdateMs = 0; this.lastHudUpdateMs = 0; this.lastContextUpdateMs = 0; this.lastGroundProbeMs = 0; this.contextNavigationDeadlineMs = 0; this.surfaceWaitStartedMs = 0; this.surfaceAcquiring = false; this.surfaceFallback = false; this.lastCompassSignature = ''; this.entry = document.getElementById('cockpit-entry'); this.tr3bToggle = document.getElementById('tr3b-toggle'); this._tr3bSignature = null; this.mapViewButton = document.getElementById('map-view-switch'); this.resetGlobeButton = document.getElementById('cockpit-reset-globe'); this.hud = document.getElementById('cockpit-hud'); this.entryFocusOrigin = null; this.callsign = document.getElementById('cockpit-callsign'); this.speed = document.getElementById('cockpit-speed-value'); this.speedRim = document.getElementById('cockpit-speed-rim'); this.speedRimValue = document.getElementById('cockpit-speed-rim-value'); this.speedRimTicks = Array.from(document.querySelectorAll('[data-speed-rim-tick]')); this.altitude = document.getElementById('cockpit-altitude-value'); this.altitudeRim = document.getElementById('cockpit-altitude-rim'); this.altitudeRimValue = document.getElementById('cockpit-altitude-rim-value'); this.altitudeRimTicks = Array.from(document.querySelectorAll('[data-altitude-rim-tick]')); this.headingValue = document.getElementById('cockpit-heading-value'); this.compassTape = document.getElementById('cockpit-compass-tape'); this.clock = document.getElementById('cockpit-clock'); this.position = document.getElementById('cockpit-position'); this.aircraftMeta = document.getElementById('cockpit-aircraft-meta'); this.route = document.getElementById('cockpit-route'); this.routeFrom = document.getElementById('cockpit-route-from'); this.routeTo = document.getElementById('cockpit-route-to'); this.routeStatus = document.getElementById('cockpit-route-status'); this.routeDirection = document.getElementById('cockpit-route-direction'); this.routeDirectionLabel = document.getElementById('cockpit-route-direction-label'); this.visionPrevious = document.getElementById('cockpit-vision-previous'); this.visionCurrent = document.getElementById('cockpit-vision-current'); this.visionCurrentLabel = document.getElementById('cockpit-vision-current-label'); this.visionNext = document.getElementById('cockpit-vision-next'); this.visionMode = 'optical'; this.onVisionChange = onVisionChange; this.onCameraTakeover = onCameraTakeover; this.onEntered = onEntered; this.onExited = onExited; this.getInheritedVisionLabel = typeof getInheritedVisionLabel === 'function' ? getInheritedVisionLabel : () => 'NORMAL'; this.restoreTrackingFrame = typeof restoreTrackingFrame === 'function' ? restoreTrackingFrame : () => false; this.isEntryAllowed = typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true; this.context = document.getElementById('cockpit-context'); this.contextSubject = document.getElementById('cockpit-context-subject'); this.contextNearestLabel = document.getElementById('cockpit-context-nearest-label'); this.contextBearing = document.getElementById('cockpit-context-bearing'); this.contextDistance = document.getElementById('cockpit-context-distance'); this.contextDirection = document.getElementById('cockpit-context-direction'); this.contextUncertainty = document.getElementById('cockpit-context-uncertainty'); this.contextUpdated = document.getElementById('cockpit-context-updated'); this.contextCohorts = new Map(Array.from(document.querySelectorAll('[data-context-cohort]')) .map((element) => [element.dataset.contextCohort, element])); this.contextPrevious = document.getElementById('cockpit-context-previous'); this.contextNext = document.getElementById('cockpit-context-next'); this.contextToggle = document.getElementById('cockpit-context-toggle'); this.weatherToggle = document.getElementById('cockpit-weather-toggle'); this.weatherState = document.getElementById('cockpit-weather-state'); this.contextCollapsed = false; this.signalStream = document.getElementById('cockpit-signal-stream'); this.signalList = document.getElementById('cockpit-signal-list'); this.signalToggle = document.getElementById('cockpit-signal-toggle'); this.briefKicker = document.getElementById('cockpit-brief-kicker'); this.briefSubtitle = document.getElementById('cockpit-brief-subtitle'); this.briefPrevious = document.getElementById('cockpit-brief-previous'); this.briefNext = document.getElementById('cockpit-brief-next'); this.briefAutoToggle = document.getElementById('cockpit-brief-auto'); this.briefPosition = document.getElementById('cockpit-brief-position'); this.briefSource = document.getElementById('cockpit-brief-source'); this.briefPages = Array.from(document.querySelectorAll('[data-cockpit-brief-page]')); this.briefTabs = Array.from(document.querySelectorAll('[data-cockpit-brief-index]')); this.newsStatus = document.getElementById('cockpit-news-status'); this.newsList = document.getElementById('cockpit-news-list'); this.localPlace = document.getElementById('cockpit-local-place'); this.localCoordinates = document.getElementById('cockpit-local-coordinates'); this.localTemperature = document.getElementById('cockpit-local-temperature'); this.localWind = document.getElementById('cockpit-local-wind'); this.localWindDirection = document.getElementById('cockpit-local-wind-direction'); this.localCondition = document.getElementById('cockpit-local-condition'); this.localCloud = document.getElementById('cockpit-local-cloud'); this.localPrecipitation = document.getElementById('cockpit-local-precipitation'); this.signalCollapsed = false; this.signalUserCollapsed = false; this.signalItems = []; this.signalSignatures = new Map(); this.briefPageIndex = 0; this.briefAutoRotateEnabled = false; this.briefTimer = null; this.lastAircraftInfo = null; this.regionalBrief = null; this.regionalBriefAnchor = null; this.regionalBriefFetchedAt = 0; this.regionalBriefAbort = null; this.regionalBriefRequestToken = 0; this.regionalBriefSubjectId = null; this.contextLayoutFrame = null; this.contextLayoutStamp = null; this.scratchTarget = new Cesium.Cartesian3(); this.cockpitAnchor = new Cesium.Cartesian3(); this.cockpitAnchorValid = false; this.scratchCamera = new Cesium.Cartesian3(); this.scratchAdvance = new Cesium.Cartesian3(); this.scratchCorrection = new Cesium.Cartesian3(); this.scratchForward = new Cesium.Cartesian3(); this.scratchHorizontal = new Cesium.Cartesian3(); this.scratchUp = new Cesium.Cartesian3(); this.scratchLocal = new Cesium.Cartesian3(); this.scratchEnu = new Cesium.Matrix4(); this.scratchAnchorCartographic = new Cesium.Cartographic(); this.scratchCameraCartographic = new Cesium.Cartographic(); this.scratchTargetCartographic = new Cesium.Cartographic(); this._listenerRemovers = []; // Camera mutations belong before scene update/culling. Changing the camera // from preRender makes 3D Tiles discover a new view after traversal and can // create a self-sustaining refinement loop under a moving cockpit camera. this._listenerRemovers.push( viewer.scene.preUpdate.addEventListener(() => this.update()), viewer.trackedEntityChanged.addEventListener(() => { if (this.active) this._adoptTrackedEntity(performance.now()); else this.syncEntry(); }), ); this._listen(this.entry, 'click', () => this.enter()); this._listen(this.tr3bToggle, 'click', () => this.toggleTrackedTr3b()); this._listen(this.mapViewButton, 'click', () => this.exit()); this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1)); this._listen(this.visionCurrent, 'click', () => this.cycleVisionMode(1)); this._listen(this.visionNext, 'click', () => this.cycleVisionMode(1)); this._listen(this.contextPrevious, 'click', () => this.navigateContext(-1, { origin: 'user' })); this._listen(this.contextNext, 'click', () => this.navigateContext(1, { origin: 'user' })); this._listen(this.contextToggle, 'click', () => this.setContextCollapsed(!this.contextCollapsed)); this._listen(this.weatherToggle, 'click', () => { const enabled = this.weatherToggle.getAttribute('aria-pressed') !== 'true'; this.syncWeatherToggle(enabled); window.dispatchEvent(new CustomEvent('gev:cockpit-weather-toggle', { detail: { enabled }, })); }); this._listen(window, 'gev:cockpit-weather-state', (event) => { this.syncWeatherToggle(event?.detail?.enabled !== false); }); this._listen(this.signalToggle, 'click', () => this.setSignalCollapsed( !this.signalCollapsed, { user: true }, )); this._listen(this.signalList, 'click', (event) => { const target = event.target.closest('button[data-signal-layer][data-signal-id]'); if (!target) return; event.preventDefault(); militaryAwarenessLayer.focusTarget?.( target.dataset.signalLayer, target.dataset.signalId, { origin: 'user' }, ); }); this._listen(this.briefPrevious, 'click', () => this.showBriefPage(this.briefPageIndex - 1, { manual: true })); this._listen(this.briefNext, 'click', () => this.showBriefPage(this.briefPageIndex + 1, { manual: true })); this._listen(this.briefAutoToggle, 'click', () => { this.setBriefAutoRotate(!this.briefAutoRotateEnabled); }); this.briefTabs.forEach((button) => this._listen(button, 'click', () => { this.showBriefPage(Number(button.dataset.cockpitBriefIndex), { manual: true }); })); this._listen(document, 'visibilitychange', () => { if (document.hidden) this.stopBriefRotation(); else if (this.briefAutoRotateEnabled) this.startBriefRotation(); }); this._listen(window, 'resize', () => this.scheduleContextLayout()); this._listen(document, 'keydown', (event) => this.onKeyDown(event), true); } _listen(target, type, handler, options) { if (!target?.addEventListener) return; target.addEventListener(type, handler, options); this._listenerRemovers.push(() => target.removeEventListener(type, handler, options)); } syncWeatherToggle(enabled) { if (!this.weatherToggle) return; const active = !!enabled; this.weatherToggle.setAttribute('aria-pressed', String(active)); this.weatherToggle.setAttribute( 'aria-label', `${active ? 'Disable' : 'Enable'} cockpit weather effects`, ); this.weatherToggle.title = `${active ? 'Disable' : 'Enable'} cockpit weather effects`; if (this.weatherState) this.weatherState.textContent = active ? 'ON' : 'OFF'; } readAircraftInfo() { // In cockpit mode the controller takes the entity off `viewer.trackedEntity` // (see update()), so the cockpit's own handle is the tracked identity there. const trackedEntity = this.viewer?.trackedEntity || this.trackedEntity; return resolveTrackedAircraftInfo({ civilian: flightsLayer.getTrackedInfo?.() || null, military: militaryFlightsLayer.getTrackedInfo?.() || null, trackedId: trackedEntity?.gevTrackedId || '', }); } dispatchCockpitModeChanged(active, info = null) { const subjectId = active ? String(info?.icao24 || '').trim().toLowerCase() || null : null; const layerId = active && ['flights', 'military'].includes(info?.layerId) ? info.layerId : null; window.dispatchEvent(new CustomEvent('gev:cockpit-mode-changed', { detail: { active: active === true, subjectId, layerId }, })); } /** * TR-3B Easter egg: flip the tracked contact between its real silhouette and * the black triangle. The registry owns the session state; the owning layer * re-derives what renders. * @returns {boolean} True when a tracked contact was converted/restored. */ toggleTrackedTr3b() { const info = this.readAircraftInfo(); const icao24 = String(info?.icao24 || '').trim(); if (!icao24) return false; toggleTr3b(icao24); const layer = info.layerId === 'military' ? militaryFlightsLayer : flightsLayer; layer.refreshTr3b?.(icao24); this._tr3bSignature = null; // force the chip to repaint on the next sync this.syncTr3bToggle(info); return true; } /** * Show the 🛸 chip whenever a contact is tracked (its own gate — converting * does not depend on the cockpit entry policy) and mirror the conversion * state. Change-only DOM writes: this runs on the preUpdate cadence. * @param {object|null} info Tracked-aircraft descriptor, or null. */ syncTr3bToggle(info) { if (!this.tr3bToggle) return; const icao24 = String(info?.icao24 || '').trim(); const converted = !!icao24 && isTr3b(icao24); const signature = icao24 ? `${icao24}:${converted ? 1 : 0}` : ''; if (this._tr3bSignature === signature) return; this._tr3bSignature = signature; this.tr3bToggle.hidden = !icao24; this.tr3bToggle.setAttribute('aria-pressed', converted ? 'true' : 'false'); this.tr3bToggle.title = converted ? 'Restore real aircraft' : 'Reclassify as TR-3B'; } syncEntry() { if (this.active) return; const info = this.readAircraftInfo(); const trackedContact = !!(info && this.viewer.trackedEntity?.position); this.syncTr3bToggle(trackedContact ? info : null); const available = !!(this.isEntryAllowed() && trackedContact); // Change-only DOM writes: this runs on a preUpdate cadence, and // unconditional `hidden` assignments invalidate style/layout every frame // even when nothing changed. (perf item 9) if (this._entryAvailable === available) return; this._entryAvailable = available; if (this.entry) this.entry.hidden = !available; if (this.mapViewButton) this.mapViewButton.hidden = true; if (this.resetGlobeButton) this.resetGlobeButton.hidden = true; } /** * Navigate Contacts through one Cockpit-owned funnel. A short grace window * covers feed-refresh handoffs where the old source releases before the new * source publishes its entity; trackedEntityChanged adopts synchronously as * soon as the replacement exists, so the normal path does not wait. */ navigateContext(direction, options = {}) { const method = direction < 0 ? 'navigatePrevious' : 'navigateNext'; const wasActive = this.active; if (wasActive) this.contextNavigationDeadlineMs = performance.now() + 1500; const navigationOptions = wasActive ? { ...options, aircraftOnly: true } : options; const changed = Boolean(militaryAwarenessLayer?.[method]?.(navigationOptions)); if (!changed) { this.contextNavigationDeadlineMs = 0; return false; } if (wasActive) this._adoptTrackedEntity(performance.now()); return true; } /** Adopt a newly selected aircraft without ever leaving Cockpit. */ _adoptTrackedEntity(nowMs, suppliedInfo = null) { const nextEntity = this.viewer.trackedEntity; if (!this.active || !nextEntity?.position || nextEntity === this.trackedEntity) return false; const info = suppliedInfo || this.readAircraftInfo(); if (!info) return false; if (this.trackedEntity && this.viewer.entities.contains(this.trackedEntity)) { this.trackedEntity.show = this.trackedEntityWasShown; } this.trackedEntity = nextEntity; this.trackedEntityWasShown = nextEntity.show; nextEntity.show = false; this.viewer.trackedEntity = undefined; this.cockpitAnchorValid = false; this.heading = normalizeHeading(info.track ?? 0); this.lastFrameMs = nowMs; this.lastHudUpdateMs = 0; this.lastContextUpdateMs = 0; this.lastCameraUpdateMs = 0; this.contextNavigationDeadlineMs = 0; this.dispatchCockpitModeChanged(true, info); return true; } setVisionMode(mode, { revealParameters = false } = {}) { const next = normalizeCockpitVisionMode(mode); this.visionMode = next; const inherited = String(this.getInheritedVisionLabel?.() || 'NORMAL').toUpperCase(); const labels = { optical: inherited, crt: 'CRT', nvg: 'NVG', thermal: 'FLIR', noir: 'NOIR' }; const names = { optical: inherited, crt: 'CRT', nvg: 'Night vision', thermal: 'Thermal', noir: 'Noir' }; if (this.visionCurrent) { this.visionCurrent.dataset.cockpitVision = next; this.visionCurrent.setAttribute('aria-label', `Current cockpit vision style: ${names[next]}. Activate for next style.`); this.visionCurrent.title = `Current style: ${names[next]} — click for next`; } if (this.visionCurrentLabel) this.visionCurrentLabel.textContent = labels[next]; this.onVisionChange?.(next, this.active, { revealParameters }); } cycleVisionMode(direction = 1) { const modes = COCKPIT_VISION_MODES; const currentIndex = Math.max(0, modes.indexOf(this.visionMode)); const step = direction < 0 ? -1 : 1; const nextIndex = (currentIndex + step + modes.length) % modes.length; this.setVisionMode(modes[nextIndex], { revealParameters: true }); } clearPredictiveRoute() { if (this.routeDirection) this.routeDirection.hidden = true; } onKeyDown(event) { if (event.repeat || event.isComposing) return; if (event.key === 'Escape' && this.active) { // The credit lightbox owns Escape while its Close control or links hold // focus. Its target handler closes the overlay and restores attribution // focus; Cockpit must stay active behind it. if (event.target?.closest?.('.cesium-credit-lightbox')) return; if (document.getElementById('context-radio-dock')?.classList.contains('disclosure-open')) return; if (document.querySelector('#cockpit-utility-controls [aria-expanded="true"]')) return; if (this.context?.contains(event.target) && !this.contextCollapsed) { event.preventDefault(); event.stopImmediatePropagation(); this.setContextCollapsed(true); if (event.target === this.contextToggle || this.contextToggle?.contains?.(event.target)) { this.contextToggle?.blur?.(); } else { this.contextToggle?.focus({ preventScroll: true }); } return; } if (this.signalStream?.contains(event.target) && !this.signalCollapsed) { event.preventDefault(); event.stopImmediatePropagation(); this.setSignalCollapsed(true, { user: true }); if (event.target === this.signalToggle || this.signalToggle?.contains?.(event.target)) { this.signalToggle?.blur?.(); } else { this.signalToggle?.focus({ preventScroll: true }); } return; } event.preventDefault(); event.stopImmediatePropagation(); this.exit(); return; } if (event.target?.closest?.('input, textarea, select, [contenteditable]')) return; const key = event.key?.toLowerCase(); if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) { if (!this.active) { const cockpitAttempt = !!(this.readAircraftInfo() && this.viewer.trackedEntity?.position); if (!cockpitAttempt) return; } event.preventDefault(); event.stopImmediatePropagation(); if (!this.active && !this.isEntryAllowed()) return; const changed = this.active ? this.exit() : this.enter(); return; } } enter() { if (this.active) return false; if (!this.isEntryAllowed()) return false; const info = this.readAircraftInfo(); const entity = this.viewer.trackedEntity; if (!info || !entity?.position) return false; this.entryFocusOrigin = document.activeElement instanceof HTMLElement ? document.activeElement : null; // Retire deferred navigation before cancelFlight can run its callbacks. this.onCameraTakeover?.(); this.viewer.camera.cancelFlight(); this.trackedEntity = entity; this.trackedEntityWasShown = entity.show; entity.show = false; this.heading = normalizeHeading(info.track ?? 0); this.lastFrameMs = performance.now(); this.lastCameraUpdateMs = 0; this.lastHudUpdateMs = 0; this.lastContextUpdateMs = 0; this.contextNavigationDeadlineMs = 0; this.lastGroundProbeMs = 0; this.surfaceWaitStartedMs = performance.now(); this.surfaceAcquiring = false; this.surfaceFallback = false; this.lastCompassSignature = ''; this.cockpitAnchorValid = false; this.lastCameraUpdateMs = 0; this.active = true; // Cockpit animates the camera from preUpdate every frame — preUpdate only // runs on rendered frames, so idle mode would freeze the cockpit solid. // (perf wave 2) holdContinuousRender('cockpit'); this.viewer.trackedEntity = undefined; this.viewer.scene.screenSpaceCameraController.enableInputs = false; document.body.classList.add('cockpit-mode'); // Activation writes entry/quick/map visibility directly, bypassing // syncEntry's change-only cache — invalidate it so the exit-path // syncEntry re-applies every write (notably re-hiding mapViewButton). this._entryAvailable = undefined; if (this.entry) this.entry.hidden = true; if (this.tr3bToggle) { this.tr3bToggle.hidden = true; this._tr3bSignature = null; } if (this.mapViewButton) this.mapViewButton.hidden = false; if (this.resetGlobeButton) this.resetGlobeButton.hidden = false; if (this.hud) this.hud.hidden = false; if (this.signalStream) this.signalStream.hidden = false; this.hud?.classList.add('signals-active'); this.signalItems = []; this.signalSignatures.clear(); this.showBriefPage(0); this.startBriefRotation(); const trackLabel = info.callsign || info.registration || info.icao24 || 'AIRCRAFT'; const trackHeading = String(Math.round(normalizeHeading(info.track ?? 0))).padStart(3, '0'); this.pushCockpitSignal( 'track', 'track', 'TRACK ACQUIRED', `${trackLabel} · COURSE ${trackHeading}°`, ); this.updateHud(info, performance.now(), true); this.setVisionMode(this.visionMode); this.scheduleContextLayout(); this.mapViewButton?.focus({ preventScroll: true }); this.onEntered?.(); this.dispatchCockpitModeChanged(true, info); return true; } exit({ restoreTracking = true } = {}) { if (!this.active) return false; const entity = this.trackedEntity; this.active = false; releaseContinuousRender('cockpit'); this.trackedEntity = null; this.heading = null; this.cockpitAnchorValid = false; this.surfaceWaitStartedMs = 0; this.surfaceAcquiring = false; this.surfaceFallback = false; this.lastHudUpdateMs = 0; this.lastContextUpdateMs = 0; this.contextNavigationDeadlineMs = 0; this.lastCompassSignature = ''; this.stopBriefRotation(); this.regionalBriefAbort?.abort(); this.regionalBriefAbort = null; this.regionalBriefRequestToken += 1; this.regionalBriefSubjectId = null; document.body.classList.remove('cockpit-mode'); this.onExited?.(); this.hud?.style.removeProperty('--cockpit-utility-top'); this.hud?.style.removeProperty('--cockpit-utility-max-height'); if (this.hud) this.hud.hidden = true; if (this.route) this.route.hidden = true; this.clearPredictiveRoute(); this.setVisionMode('optical'); if (this.signalStream) this.signalStream.hidden = true; this.hud?.classList.remove('signals-active'); this.viewer.scene.screenSpaceCameraController.enableInputs = true; if (entity && this.viewer.entities.contains(entity)) entity.show = this.trackedEntityWasShown; this.trackedEntityWasShown = true; this.dispatchCockpitModeChanged(false); if (restoreTracking && entity && this.viewer.entities.contains(entity)) { this.viewer.trackedEntity = entity; this.restoreTrackingFrame(entity); } this.syncEntry(); const restoreTarget = this.entryFocusOrigin === this.entry ? this.entry : (this.entry || this.entryFocusOrigin); this.entryFocusOrigin = null; if (restoreTarget?.isConnected && !restoreTarget.hidden) { restoreTarget.focus({ preventScroll: true }); } return true; } update() { if (!this.active) { // Entry availability changes on a human timescale (tracking start/stop, // info arriving after a poll) — polling it every rendered frame ran // readAircraftInfo() + DOM pokes at display rate in plain map mode. // 250 ms keeps the chip imperceptibly fresh; trackedEntityChanged still // fires syncEntry immediately on the events that matter. (perf item 9) const nowMs = performance.now(); if (nowMs - (this._lastEntrySyncMs || 0) >= 250) { this._lastEntrySyncMs = nowMs; this.syncEntry(); } return; } const nowMs = performance.now(); const info = this.readAircraftInfo(); // Adopt a newly selected track before the camera cadence gate. A context // NEXT/PREV selection can otherwise spend one frame driving the old // aircraft with the new aircraft's metadata. this._adoptTrackedEntity(nowMs, info); if (!info || !this.trackedEntity || !this.viewer.entities.contains(this.trackedEntity)) { if (nowMs < this.contextNavigationDeadlineMs) return; this.exit({ restoreTracking: false }); return; } if (!cockpitUiUpdateDue(nowMs, this.lastCameraUpdateMs, COCKPIT_CAMERA_UPDATE_MS)) return; this.lastCameraUpdateMs = nowMs; const target = this.trackedEntity.position.getValue(this.viewer.clock.currentTime, this.scratchTarget); if (!target) return; const dtSec = Math.min(0.1, Math.max(0, (nowMs - this.lastFrameMs) / 1000)); this.lastFrameMs = nowMs; if (Number.isFinite(info.track)) { this.heading = slewHeading( this.heading ?? info.track, info.track, COCKPIT_HEADING_SLEW_DPS * dtSec, ); } if (!this.cockpitAnchorValid) { Cesium.Cartesian3.clone(target, this.cockpitAnchor); this.cockpitAnchorValid = true; } // First-person motion cannot use the delayed feed correction as a raw // camera destination: a harmless icon re-anchor becomes a whole-world // surge/reversal in cockpit view. Advance the camera anchor inertially // from the reported course/speed and converge on the authoritative layer // display position at a bounded rate. This preserves the layer's required // 15/30-second interpolation and per-frame cache without exposing its // sample-boundary corrections to the camera. const headingRad = Cesium.Math.toRadians(this.heading ?? 0); const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG); const speedMps = Number.isFinite(info.velocityMps) ? Math.max(0, info.velocityMps) : 0; if (info.stale) { // A feed backoff has no authoritative velocity epoch to advance from. // Hold the cockpit on the exact layer-rendered position so the camera // cannot coast away while the icon correctly remains fixed. Cesium.Cartesian3.clone(target, this.cockpitAnchor); } else { Cesium.Transforms.eastNorthUpToFixedFrame( this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchEnu, ); this.scratchLocal.x = Math.sin(headingRad); this.scratchLocal.y = Math.cos(headingRad); this.scratchLocal.z = 0; Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchHorizontal); Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal); Cesium.Cartesian3.multiplyByScalar( this.scratchHorizontal, speedMps * dtSec, this.scratchAdvance, ); Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchAdvance, this.cockpitAnchor); Cesium.Cartesian3.subtract(target, this.cockpitAnchor, this.scratchCorrection); const correctionDistanceM = Cesium.Cartesian3.magnitude(this.scratchCorrection); const correctionStepM = cockpitAnchorCorrectionStep(correctionDistanceM, speedMps, dtSec); if (correctionStepM > 0 && correctionDistanceM > 0) { Cesium.Cartesian3.multiplyByScalar( this.scratchCorrection, correctionStepM / correctionDistanceM, this.scratchCorrection, ); Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchCorrection, this.cockpitAnchor); } } // The inertial anchor is independent of the layer's render-floor clamp and // can otherwise coast into a photoreal mesh while a landing contact is // between fixes. Clamp it against the same mesh-first shared floor used by // aircraft rendering. For a slow contact whose floor cell is still cold, // its already-clamped render position is a conservative temporary floor. const anchorCartographic = Cesium.Cartographic.fromCartesian( this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchAnchorCartographic, ); const targetCartographic = Cesium.Cartographic.fromCartesian( target, Cesium.Ellipsoid.WGS84, this.scratchTargetCartographic, ); let cockpitFloorM = cachedGroundFloor(info.latitude, info.longitude); if (info.onGround === true) { const groundPoint = [{ lat: info.latitude, lon: info.longitude }]; warmGroundFloor(groundPoint); const meshFloorM = cachedMeshFloor(info.latitude, info.longitude); if (meshFloorPreferred() && !Number.isFinite(meshFloorM)) { if (cockpitUiUpdateDue(nowMs, this.lastGroundProbeMs, COCKPIT_GROUND_PROBE_MS)) { this.lastGroundProbeMs = nowMs; const viewerCartographic = this.viewer.camera.positionCartographic; sampleMeshFloorCells(this.viewer.scene, groundPoint, { excludeObjects: [this.trackedEntity], viewerLat: Cesium.Math.toDegrees(viewerCartographic.latitude), viewerLon: Cesium.Math.toDegrees(viewerCartographic.longitude), }); } cockpitFloorM = cachedMeshFloor(info.latitude, info.longitude); if (!Number.isFinite(cockpitFloorM)) { if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs; if (!cockpitSurfaceWaitExpired(nowMs, this.surfaceWaitStartedMs, COCKPIT_GROUND_WAIT_TIMEOUT_MS)) { // Keep the already-safe map camera in place while the photoreal // surface under a parked aircraft is acquired. The bounded wait // prevents a permanently cold mesh cell from freezing cockpit. this.surfaceAcquiring = true; this.surfaceFallback = false; if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) { this.lastHudUpdateMs = nowMs; this.updateHud(info, nowMs); } return; } this.surfaceAcquiring = false; this.surfaceFallback = true; } else { this.surfaceWaitStartedMs = 0; this.surfaceAcquiring = false; this.surfaceFallback = false; } } } else { this.surfaceWaitStartedMs = 0; this.surfaceAcquiring = false; this.surfaceFallback = false; } if (!Number.isFinite(cockpitFloorM) && speedMps < 90 && Number.isFinite(targetCartographic?.height)) { cockpitFloorM = targetCartographic.height - GROUND_FLOOR_LIFT_M; } if (anchorCartographic && Number.isFinite(cockpitFloorM)) { const minimumAnchorHeightM = cockpitGroundSafeHeight( anchorCartographic.height, cockpitFloorM, COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M, ); if (minimumAnchorHeightM !== anchorCartographic.height) { anchorCartographic.height = minimumAnchorHeightM; Cesium.Ellipsoid.WGS84.cartographicToCartesian(anchorCartographic, this.cockpitAnchor); } } // Rebuild the local frame at the stabilized anchor after advancing it. Cesium.Transforms.eastNorthUpToFixedFrame( this.cockpitAnchor, Cesium.Ellipsoid.WGS84, this.scratchEnu, ); this.scratchLocal.x = Math.sin(headingRad); this.scratchLocal.y = Math.cos(headingRad); this.scratchLocal.z = 0; Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchHorizontal); Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal); this.scratchLocal.x = Math.sin(headingRad) * Math.cos(pitchRad); this.scratchLocal.y = Math.cos(headingRad) * Math.cos(pitchRad); this.scratchLocal.z = Math.sin(pitchRad); Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchForward); Cesium.Cartesian3.normalize(this.scratchForward, this.scratchForward); this.scratchLocal.x = -Math.sin(headingRad) * Math.sin(pitchRad); this.scratchLocal.y = -Math.cos(headingRad) * Math.sin(pitchRad); this.scratchLocal.z = Math.cos(pitchRad); Cesium.Matrix4.multiplyByPointAsVector(this.scratchEnu, this.scratchLocal, this.scratchUp); Cesium.Cartesian3.normalize(this.scratchUp, this.scratchUp); Cesium.Cartesian3.multiplyByScalar( this.scratchHorizontal, COCKPIT_FORWARD_OFFSET_M, this.scratchCamera, ); Cesium.Cartesian3.add(this.cockpitAnchor, this.scratchCamera, this.scratchCamera); Cesium.Matrix4.getTranslation(this.scratchEnu, this.scratchTarget); Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget); Cesium.Cartesian3.multiplyByScalar(this.scratchTarget, COCKPIT_UP_OFFSET_M, this.scratchTarget); Cesium.Cartesian3.add(this.scratchCamera, this.scratchTarget, this.scratchCamera); // Recheck at the final forward-offset camera coordinate because a taxiing // aircraft can cross into an adjacent coarse floor cell between updates. const cameraCartographic = Cesium.Cartographic.fromCartesian( this.scratchCamera, Cesium.Ellipsoid.WGS84, this.scratchCameraCartographic, ); if (cameraCartographic) { const cameraLat = Cesium.Math.toDegrees(cameraCartographic.latitude); const cameraLon = Cesium.Math.toDegrees(cameraCartographic.longitude); const cameraFloorM = cachedGroundFloor(cameraLat, cameraLon); if (Number.isFinite(cameraFloorM)) { cockpitFloorM = Math.max(cockpitFloorM ?? Number.NEGATIVE_INFINITY, cameraFloorM); } const safeHeightM = cockpitGroundSafeHeight( cameraCartographic.height, cockpitFloorM, COCKPIT_MIN_GROUND_CLEARANCE_M, ); if (safeHeightM !== cameraCartographic.height) { cameraCartographic.height = safeHeightM; Cesium.Ellipsoid.WGS84.cartographicToCartesian(cameraCartographic, this.scratchCamera); } } this.viewer.camera.setView({ destination: this.scratchCamera, orientation: { direction: this.scratchForward, up: this.scratchUp }, }); if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) { this.lastHudUpdateMs = nowMs; this.updateHud(info, nowMs); } } updateHud(info, nowMs = performance.now(), forceContext = false) { this.lastAircraftInfo = info; const heading = normalizeHeading(this.heading ?? info.track ?? 0); if (this.callsign) { this.callsign.textContent = info.callsign || info.registration || info.icao24 || 'AIRCRAFT'; } const speedKt = Number.isFinite(info.velocityMps) ? info.velocityMps * 1.94384 : null; setCockpitRollingValue( this.speed, formatSpeedRulerTick(speedKt), speedKt, { immediate: forceContext }, ); if (this.speedRim) this.speedRim.classList.toggle('unavailable', speedKt === null); if (this.speedRimValue) this.speedRimValue.textContent = formatSpeedRulerTick(speedKt); const speedTicks = speedRulerTicks(speedKt, this.speedRimTicks.length); this.speedRimTicks.forEach((element, index) => { const tick = speedTicks[index]; element.hidden = !tick; if (!tick) return; element.style.setProperty('--slot', tick.slot.toFixed(4)); element.style.setProperty('--depth', tick.depth.toFixed(4)); element.style.setProperty('--curve', altitudeRulerCurveInset(tick.slot).toFixed(5)); element.classList.toggle('major', tick.major); const label = element.querySelector('b'); if (label) label.textContent = formatSpeedRulerTick(tick.valueKt); }); const altitudeFt = cockpitAltitudeDisplayFt(info.altitudeM, info.onGround); if (this.altitude) { const displayedAltitudeFt = Number.isFinite(altitudeFt) ? Math.round(altitudeFt) : null; setCockpitRollingValue( this.altitude, displayedAltitudeFt !== null ? displayedAltitudeFt.toLocaleString('en-US') : '-----', displayedAltitudeFt, { immediate: forceContext }, ); } if (this.altitudeRim) this.altitudeRim.classList.toggle('unavailable', altitudeFt === null); if (this.altitudeRimValue) { this.altitudeRimValue.textContent = formatAltitudeRulerTick(altitudeFt); } const altitudeTicks = altitudeRulerTicks(altitudeFt, this.altitudeRimTicks.length); this.altitudeRimTicks.forEach((element, index) => { const tick = altitudeTicks[index]; element.hidden = !tick; if (!tick) return; element.style.setProperty('--slot', tick.slot.toFixed(4)); element.style.setProperty('--depth', tick.depth.toFixed(4)); element.style.setProperty('--curve', altitudeRulerCurveInset(tick.slot).toFixed(5)); element.classList.toggle('major', tick.major); const label = element.querySelector('b'); if (label) label.textContent = formatAltitudeRulerTick(tick.valueFt); }); setCockpitRollingValue( this.headingValue, String(Math.round(heading) % 360).padStart(3, '0'), heading, { circularRange: 360, immediate: forceContext }, ); if (this.compassTape) { const divisions = compassDivisions(heading); const signature = divisions.join(','); if (signature !== this.lastCompassSignature) { this.lastCompassSignature = signature; this.compassTape.innerHTML = divisions .map((division, index) => { const slot = index - 3; return `${formatCompassDivision(division)}`; }) .join(''); } } if (this.clock) this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z'; if (this.position) { const lat = Number.isFinite(info.latitude) ? `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}` : '--'; const lon = Number.isFinite(info.longitude) ? `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}` : '--'; this.position.textContent = `${lat} · ${lon}`; } if (this.aircraftMeta) { const feedState = this.surfaceAcquiring ? 'ACQUIRING SURFACE' : (this.surfaceFallback ? 'SURFACE FALLBACK' : (info.stale ? 'STALE FEED' : 'LIVE TRACK')); this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`; } this.updateRoute(info); if (forceContext || cockpitUiUpdateDue(nowMs, this.lastContextUpdateMs, COCKPIT_CONTEXT_UPDATE_MS)) { this.lastContextUpdateMs = nowMs; this.updateLocalPosition(info); this.maybeRefreshRegionalBrief(info); this.updateContext(info, heading); } if (this.hud) this.hud.dataset.layer = info.layerId || 'flights'; } updateRoute(info) { const origin = info?.route?.origin; const destination = info?.route?.destination; const validDestination = Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon); const routeLabel = (airport) => [airport?.code, airport?.name].filter(Boolean).join(' · ') || 'UNKNOWN'; if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin); if (this.routeTo) this.routeTo.textContent = routeLabel(destination); if (this.routeStatus) { this.routeStatus.textContent = validDestination ? 'ARROW · ESTIMATED DIRECTION' : 'ROUTE DATA UNAVAILABLE'; } if (this.route) this.route.hidden = !origin && !destination; if (!validDestination || !Number.isFinite(info?.longitude) || !Number.isFinite(info?.latitude)) { this.clearPredictiveRoute(); return; } const destinationBearing = bearingBetweenCoordinates( info.latitude, info.longitude, destination.lat, destination.lon, ); const relative = relativeBearing(destinationBearing, this.heading ?? info.track ?? 0); if (!Number.isFinite(destinationBearing) || !Number.isFinite(relative)) { this.clearPredictiveRoute(); return; } if (this.routeDirection) { const displayedRelative = Math.max(-120, Math.min(120, relative)); this.routeDirection.hidden = false; this.routeDirection.style.setProperty('--route-angle', `${displayedRelative.toFixed(2)}deg`); } if (this.routeDirectionLabel) { this.routeDirectionLabel.textContent = `DEST ${String(Math.round(destinationBearing)).padStart(3, '0')}°`; } } updateContext(info, heading) { if (!this.context) return; const snapshot = militaryAwarenessLayer.getContextSnapshot?.() || null; const trackedId = info.icao24 || info.id; const readout = resolveCockpitContextReadout({ snapshot, info }); if (!readout.visible) { this.context.hidden = true; this.hud?.classList.remove('context-active'); this.contextLayoutStamp = null; this.pushCockpitSignal( 'context-status', 'info', 'CONTEXT STANDBY', 'ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS', ); return; } this.context.hidden = false; this.hud?.classList.add('context-active'); if (this.contextSubject) { const installationCoverage = snapshot.cohorts .find((cohort) => cohort.id === 'military-installations')?.coverage; this.contextSubject.textContent = formatCockpitContextScope( snapshot.subject.label || trackedId, snapshot.radiusM, installationCoverage, ); } // Navigation stays wired in every state — the operator must always be able // to step off the current contact from the panel that hosts the controls. if (this.contextPrevious) this.contextPrevious.disabled = !snapshot.navigation?.canPrevious; if (this.contextNext) this.contextNext.disabled = !snapshot.navigation?.canNext; if (readout.contactLost) { // The subject left its source. Every number below is measured against a // position that stopped updating, so hold the last rendered readout and // say so instead of re-deriving stale geometry as if it were live. const enteringLost = this.context.dataset.state !== 'lost'; this.context.dataset.state = 'lost'; if (this.contextUncertainty) { this.contextUncertainty.textContent = 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR'; } // The cue changes the footer's height; re-run layout once on the way in // rather than every frame the contact stays lost. if (enteringLost) this.scheduleContextLayout(); this.pushCockpitSignal( 'context-status', 'warning', `CONTACT LOST · ${snapshot.subject.label || snapshot.subject.id || 'SUBJECT'}`, 'SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN', ); return; } let unknownCount = 0; const nearest = []; for (const cohort of snapshot.cohorts) { const element = this.contextCohorts.get(cohort.id); const value = element?.querySelector('strong'); if (value) value.textContent = cohort.count === null ? '?' : String(cohort.count); element?.classList.toggle('unknown', cohort.relationship === 'UNKNOWN'); if (cohort.count === null) unknownCount += 1; for (const item of cohort.nearest) nearest.push({ ...item, cohort }); } nearest.sort((a, b) => (a.distanceM ?? Infinity) - (b.distanceM ?? Infinity)); const closest = nearest[0] || null; const closestLabel = formatAwarenessLabel(closest); if (this.contextNearestLabel) { this.contextNearestLabel.textContent = closest ? `${closest.cohort.label.toUpperCase()} · ${closestLabel}` : 'NO AVAILABLE EXAMPLE'; this.contextNearestLabel.setAttribute( 'aria-label', closest && closestLabel === '—' ? `${closest.cohort.label}, Unavailable` : this.contextNearestLabel.textContent, ); } if (this.contextDistance) { const distanceM = closest?.distanceM; this.contextDistance.textContent = Number.isFinite(distanceM) ? `${distanceM < 10000 ? (distanceM / 1000).toFixed(1) : Math.round(distanceM / 1000)} KM` : '—'; this.contextDistance.setAttribute( 'aria-label', Number.isFinite(distanceM) ? this.contextDistance.textContent : 'Unavailable', ); } // The arrow and BRG are nose-relative to the tracked aircraft. When the // subject is a vessel, an installation, or another aircraft, the rest of // this row is measured from that subject — so the aircraft-frame half is // dashed rather than presented alongside subject-frame distances as if the // two shared an origin. let relative = null; if (readout.aircraftRelative && closest?.position && Number.isFinite(info.latitude) && Number.isFinite(info.longitude)) { const cartographic = Cesium.Cartographic.fromCartesian(closest.position); const bearing = cartographic ? bearingBetweenCoordinates( info.latitude, info.longitude, Cesium.Math.toDegrees(cartographic.latitude), Cesium.Math.toDegrees(cartographic.longitude), ) : null; relative = relativeBearing(bearing, heading); } if (this.contextDirection) { this.contextDirection.style.transform = `rotate(${relative ?? 0}deg)`; this.contextDirection.classList.toggle('unknown', relative === null); } if (this.contextBearing) { if (relative === null) this.contextBearing.textContent = 'BRG —'; else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD'; else this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`; } if (this.contextUncertainty) { this.contextUncertainty.textContent = unknownCount ? `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN · NOT AN ALL-CLEAR` : 'AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR'; } if (this.contextUpdated) { this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt) ? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z' : '--:--:--Z'; } this.context.dataset.state = unknownCount ? 'uncertain' : 'current'; this.updateCockpitSignals(snapshot, unknownCount); if (this.contextLayoutStamp !== snapshot.evaluatedAt) { this.contextLayoutStamp = snapshot.evaluatedAt; this.scheduleContextLayout(); } } scheduleContextLayout() { const contextVisible = this.context && !this.context.hidden; const signalVisible = this.signalStream && !this.signalStream.hidden; if ((!contextVisible && !signalVisible) || this.contextLayoutFrame !== null) return; this.contextLayoutFrame = requestAnimationFrame(() => { this.contextLayoutFrame = null; this.syncContextLayout(); this.syncSignalLayout(); }); } showBriefPage(index, { manual = false } = {}) { const count = COCKPIT_BRIEF_PAGES.length; this.briefPageIndex = ((Number(index) % count) + count) % count; const page = COCKPIT_BRIEF_PAGES[this.briefPageIndex]; this.briefPages.forEach((element) => { element.hidden = element.dataset.cockpitBriefPage !== page.id; }); this.briefTabs.forEach((button) => { const current = Number(button.dataset.cockpitBriefIndex) === this.briefPageIndex; button.setAttribute('aria-current', current ? 'true' : 'false'); }); if (this.briefKicker) { const indicator = this.briefKicker.querySelector('i'); this.briefKicker.replaceChildren(...[indicator, document.createTextNode(` ${page.kicker}`)].filter(Boolean)); } if (this.briefSubtitle) this.briefSubtitle.textContent = page.subtitle; if (this.briefPosition) this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`; if (this.briefSource) this.briefSource.textContent = page.source; if (this.signalStream) this.signalStream.dataset.briefPage = page.id; if (manual && this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true }); this.scheduleContextLayout(); } setBriefAutoRotate(enabled) { this.briefAutoRotateEnabled = Boolean(enabled); if (this.briefAutoToggle) { this.briefAutoToggle.setAttribute('aria-pressed', String(this.briefAutoRotateEnabled)); const label = this.briefAutoRotateEnabled ? 'CYCLE ON' : 'CYCLE OFF'; this.briefAutoToggle.textContent = label; const help = this.briefAutoRotateEnabled ? COCKPIT_BRIEF_CYCLE_ON_HELP : COCKPIT_BRIEF_CYCLE_OFF_HELP; this.briefAutoToggle.setAttribute('aria-label', label); this.briefAutoToggle.title = help; } if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true }); else this.stopBriefRotation(); } startBriefRotation({ reset = false } = {}) { if (reset) this.stopBriefRotation(); if (!this.briefAutoRotateEnabled || this.briefTimer || !this.active || this.signalCollapsed || document.hidden) return; this.briefTimer = window.setTimeout(() => { this.briefTimer = null; const hasPointer = this.signalStream?.matches(':hover') === true; const hasFocus = this.signalStream?.contains(document.activeElement) === true; const isInteracting = hasPointer || hasFocus; if (!isInteracting) { this.showBriefPage(this.briefPageIndex + 1); } this.startBriefRotation(); }, COCKPIT_BRIEF_ROTATE_MS); } stopBriefRotation() { if (this.briefTimer) window.clearTimeout(this.briefTimer); this.briefTimer = null; } updateLocalPosition(info) { if (!this.localCoordinates) return; if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) { this.localCoordinates.textContent = 'POSITION UNAVAILABLE'; return; } const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`; const lon = `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`; this.localCoordinates.textContent = `${lat} · ${lon}`; } maybeRefreshRegionalBrief(info) { if (!this.active || !Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) return; const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.registration || info.callsign || 'unknown'}`; if (subjectId !== this.regionalBriefSubjectId) { this.regionalBriefAbort?.abort(); this.regionalBriefAbort = null; this.regionalBriefRequestToken += 1; this.regionalBriefSubjectId = subjectId; this.regionalBrief = null; this.regionalBriefAnchor = null; this.regionalBriefFetchedAt = 0; } const point = { latitude: info.latitude, longitude: info.longitude }; const ageMs = Date.now() - this.regionalBriefFetchedAt; const distanceM = regionalDistanceM(this.regionalBriefAnchor, point); if (this.regionalBriefAbort || (ageMs < COCKPIT_REGIONAL_REFRESH_MS && distanceM < COCKPIT_REGIONAL_REFRESH_DISTANCE_M)) return; this.regionalBriefAnchor = point; this.regionalBriefFetchedAt = Date.now(); const controller = new AbortController(); const requestToken = ++this.regionalBriefRequestToken; this.regionalBriefAbort = controller; if (!this.regionalBrief) this.renderRegionalBriefStatus('loading', info); fetchRegionalBrief(point.latitude, point.longitude, { signal: controller.signal }) .then((payload) => { if (!this.active || requestToken !== this.regionalBriefRequestToken || subjectId !== this.regionalBriefSubjectId) return; this.regionalBrief = payload; this.renderRegionalBrief(payload, info); }) .catch((error) => { if (error?.name !== 'AbortError' && this.active && requestToken === this.regionalBriefRequestToken && subjectId === this.regionalBriefSubjectId) { this.renderRegionalBriefStatus('unavailable', info); } }) .finally(() => { if (this.regionalBriefAbort === controller) this.regionalBriefAbort = null; }); } renderRegionalBriefStatus(status, info) { if (this.newsStatus) { this.newsStatus.hidden = false; this.newsStatus.dataset.state = status; this.newsStatus.textContent = status === 'loading' ? 'ACQUIRING REGIONAL NEWS' : 'REGIONAL NEWS UNAVAILABLE'; } if (status === 'unavailable') this.newsList?.replaceChildren(); if (this.localPlace && status === 'loading') this.localPlace.textContent = 'RESOLVING REGION'; if (this.localPlace && status === 'unavailable') this.localPlace.textContent = 'REGION UNAVAILABLE'; this.updateLocalPosition(info); } renderRegionalBrief(payload, info) { const articles = Array.isArray(payload?.articles) ? payload.articles : []; if (this.newsStatus) { this.newsStatus.hidden = articles.length > 0; this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable'; this.newsStatus.textContent = payload?.newsStatus === 'empty' ? 'NO RECENT LOCATION MATCHES' : 'REGIONAL NEWS UNAVAILABLE'; } if (this.newsList) { this.newsList.replaceChildren(...articles.slice(0, 4).map((article) => { const entry = document.createElement('li'); const link = document.createElement('a'); link.href = article.url; link.target = '_blank'; link.rel = 'noopener noreferrer'; const title = document.createElement('strong'); title.textContent = article.title; const metadata = document.createElement('span'); metadata.textContent = `${article.domain || 'SOURCE'} · ${formatCockpitBriefAge(article.publishedAt)}`; link.append(title, metadata); entry.append(link); return entry; })); } const placeLabel = payload?.place?.label || payload?.place?.country || 'REGION UNAVAILABLE'; if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase(); this.updateLocalPosition(info); const weather = payload?.weather; if (this.localTemperature) { this.localTemperature.textContent = Number.isFinite(weather?.temperatureC) ? `${Math.round(weather.temperatureC)}°C` : '—'; } if (this.localWind) { this.localWind.textContent = Number.isFinite(weather?.windKph) ? `${Math.round(weather.windKph)} KM/H` : '—'; } if (this.localWindDirection) { this.localWindDirection.textContent = formatCockpitWindDirection(weather?.windDirectionDeg); } if (this.localCondition) this.localCondition.textContent = weatherCodeLabel(weather?.weatherCode); if (this.localCloud) { this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct) ? `CLOUD ${Math.round(weather.cloudCoverPct)}%` : 'CLOUD UNKNOWN'; } if (this.localPrecipitation) { this.localPrecipitation.textContent = Number.isFinite(weather?.precipitationMm) ? weather.precipitationMm.toFixed(1) : '—'; } if (this.signalStream) this.signalStream.dataset.regionalStatus = payload?.status || 'partial'; if (this.briefPageIndex === 1 && this.briefSource) { this.briefSource.textContent = `${String(payload?.newsSource || 'REGIONAL NEWS').toUpperCase()} · LOCATION QUERY`; } this.scheduleContextLayout(); } renderCockpitSignals() { if (!this.signalList) return; const existing = [...this.signalList.children]; const focusedEntry = existing.find((entry) => entry.contains(document.activeElement)); const entriesByKey = new Map(); for (const entry of existing) { const key = entry.dataset.signalKey; if (!entriesByKey.has(key)) entriesByKey.set(key, []); entriesByKey.get(key).push(entry); } const setText = (element, value) => { if (element.textContent !== value) element.textContent = value; }; const entries = this.signalItems.map((item) => { // Identity includes the action target: a reused status key must never // silently turn a focused flight button into a different selection. const key = JSON.stringify([ item.key, Boolean(item.target), item.target?.layerId || '', String(item.target?.id ?? ''), ]); let entry = entriesByKey.get(key)?.shift(); if (!entry) { entry = document.createElement('li'); entry.dataset.signalKey = key; const time = document.createElement('time'); const body = document.createElement('div'); const heading = document.createElement(item.target ? 'button' : 'strong'); if (item.target) { heading.type = 'button'; heading.className = 'cockpit-signal-target'; const label = document.createElement('span'); label.className = 'cockpit-signal-target-label'; const rule = document.createElement('span'); rule.className = 'cockpit-signal-target-rule'; rule.setAttribute('aria-hidden', 'true'); const chevron = document.createElement('span'); chevron.className = 'material-symbols-outlined cockpit-signal-target-chevron'; chevron.setAttribute('aria-hidden', 'true'); chevron.textContent = 'chevron_right'; heading.append(label, rule, chevron); } body.append(heading, document.createElement('span')); entry.append(time, body); } const [time, body] = entry.children; const [heading, copy] = body.children; const className = item.target ? `${item.tone} actionable` : item.tone; if (entry.className !== className) entry.className = className; setText(time, new Date(item.timestamp).toISOString().slice(11, 19) + 'Z'); if (item.target) { heading.dataset.signalLayer = item.target.layerId; heading.dataset.signalId = item.target.id; const label = `Select flight ${item.title}`; if (heading.getAttribute('aria-label') !== label) heading.setAttribute('aria-label', label); setText(heading.children[0], item.title); } else { setText(heading, item.title); } setText(copy, item.detail); return entry; }); const retainedFocus = entries.includes(focusedEntry) ? focusedEntry : null; if (focusedEntry && !retainedFocus) { // A departed contact cannot remain selectable. Continue from the stable // briefing footer instead of dropping keyboard traversal to the page top. (this.briefTabs?.[this.briefPageIndex] || this.signalToggle)?.focus({ preventScroll: true }); } for (const entry of existing) if (!entries.includes(entry)) entry.remove(); // Ordinary insertBefore moves disconnect an element and lose its focus. // Reorder the other rows around the focused row, which stays connected. const focusIndex = retainedFocus ? entries.indexOf(retainedFocus) : -1; if (retainedFocus) { let anchor = retainedFocus; for (let index = focusIndex - 1; index >= 0; index -= 1) { const entry = entries[index]; if (entry.nextElementSibling !== anchor) this.signalList.insertBefore(entry, anchor); anchor = entry; } } let anchor = retainedFocus ? retainedFocus.nextElementSibling : this.signalList.firstElementChild; for (let index = focusIndex + 1; index < entries.length; index += 1) { const entry = entries[index]; if (entry === anchor) anchor = anchor.nextElementSibling; else this.signalList.insertBefore(entry, anchor); } this.scheduleContextLayout(); } pushCockpitSignal(key, tone, title, detail, target = null) { if (!this.signalList || !title || !detail) return; const signature = `${title}|${detail}|${target?.layerId || ''}|${target?.id || ''}`; if (this.signalSignatures.get(key) === signature) return; this.signalSignatures.set(key, signature); this.signalItems.unshift({ key, tone, title, detail, target, timestamp: Date.now() }); this.signalItems = this.signalItems.slice(0, 5); this.renderCockpitSignals(); } updateCockpitSignals(snapshot, unknownCount) { const previous = new Map(this.signalItems.map((item) => [item.key, item])); const contacts = []; const subject = snapshot.subject; if (['flights', 'military'].includes(subject?.layerId) && subject?.id) { contacts.push({ key: `flight:${subject.layerId}:${subject.id}`, tone: 'track', title: subject.label || subject.id, detail: `${subject.layerId === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · CURRENT`, target: { layerId: subject.layerId, id: String(subject.id) }, distanceM: -1, }); } for (const cohort of snapshot.cohorts) { if (!['flights', 'military'].includes(cohort.id)) continue; for (const item of cohort.nearest) { const id = item.icao24 || item.id; if (!id) continue; contacts.push({ key: `flight:${cohort.id}:${id}`, tone: cohort.id === 'military' ? 'nearby military' : 'nearby', // `id` above is IDENTITY (the ICAO hex). Display must not reuse it: // the row's own `id` already carries the layer's label convention // (callsign → registration → hex), so a callsign-less enriched // contact reads as its registration here too. Same helper the // Context panel's nearest list uses. title: formatAwarenessLabel(item), detail: `${cohort.id === 'military' ? 'MILITARY FLIGHT' : 'COMMERCIAL FLIGHT'} · ${ Number.isFinite(item.distanceM) ? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM` : 'DISTANCE UNKNOWN' }`, target: { layerId: cohort.id, id: String(id) }, distanceM: item.distanceM ?? Infinity, }); } } contacts.sort((a, b) => a.distanceM - b.distanceM); const nextItems = contacts.slice(0, 5).map((item) => ({ ...item, timestamp: previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(), })); if (unknownCount) { const sources = snapshot.cohorts .filter((cohort) => cohort.count === null) .map((cohort) => cohort.source) .join(' · '); nextItems.splice(4, Math.max(0, nextItems.length - 4), { key: 'input-status', tone: 'warning', title: `${unknownCount} INPUT${unknownCount === 1 ? '' : 'S'} UNKNOWN`, detail: sources || 'SOURCE STATUS UNAVAILABLE', target: null, timestamp: previous.get('input-status')?.timestamp || snapshot.evaluatedAt || Date.now(), }); } this.signalItems = nextItems; this.signalSignatures.clear(); this.renderCockpitSignals(); } setContextCollapsed(collapsed) { const wasCollapsed = this.contextCollapsed; this.contextCollapsed = Boolean(collapsed); if (this.context) this.context.dataset.collapsed = String(this.contextCollapsed); if (this.contextToggle) { const expanded = !this.contextCollapsed; this.contextToggle.setAttribute('aria-expanded', String(expanded)); this.contextToggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} Contact panel`); this.contextToggle.title = `${expanded ? 'Collapse' : 'Expand'} contact panel`; const icon = this.contextToggle.querySelector('.material-symbols-outlined'); if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right'; } if (this.active && wasCollapsed && !this.contextCollapsed) { window.dispatchEvent(new CustomEvent('gev:cockpit-context-expanded')); } this.scheduleContextLayout(); } setSignalCollapsed(collapsed, { user = false } = {}) { const wasCollapsed = this.signalCollapsed; this.signalCollapsed = Boolean(collapsed); if (user) this.signalUserCollapsed = this.signalCollapsed; if (this.signalStream) this.signalStream.dataset.collapsed = String(this.signalCollapsed); if (this.signalToggle) { const expanded = !this.signalCollapsed; this.signalToggle.setAttribute('aria-expanded', String(expanded)); this.signalToggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} cockpit briefing panel`); this.signalToggle.title = `${expanded ? 'Collapse' : 'Expand'} briefing panel`; const icon = this.signalToggle.querySelector('.material-symbols-outlined'); if (icon) icon.textContent = expanded ? 'right_panel_close' : 'right_panel_open'; } if (this.signalCollapsed) this.stopBriefRotation(); else this.startBriefRotation({ reset: true }); if (this.active && wasCollapsed && !this.signalCollapsed) { window.dispatchEvent(new CustomEvent('gev:cockpit-signal-expanded')); } this.scheduleContextLayout(); } syncContextLayout() { if (!this.context || this.context.hidden) return; if (window.matchMedia('(max-width: 760px)').matches) { this.context.dataset.layoutMode = 'compact-bottom'; this.context.style.removeProperty('--cockpit-context-left'); this.context.style.removeProperty('--cockpit-context-top'); this.context.style.removeProperty('--cockpit-context-max-height'); return; } const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04)); this.context.dataset.layoutMode = 'bottom-left'; this.context.style.setProperty('--cockpit-context-left', `${desktopInset.toFixed(1)}px`); this.context.style.removeProperty('--cockpit-context-top'); this.context.style.removeProperty('--cockpit-context-max-height'); } syncSignalLayout() { if (!this.signalStream || this.signalStream.hidden) return; const utilityControls = document.getElementById('cockpit-utility-controls'); if (window.matchMedia('(max-width: 760px)').matches) { this.signalStream.dataset.layoutMode = 'compact-top'; utilityControls?.classList.remove('layout-primary-only'); utilityControls?.querySelectorAll('.cockpit-utility-control').forEach((control) => { const hiddenSibling = Boolean( utilityControls.querySelector('.cockpit-utility-control.is-expanded') && !control.classList.contains('is-expanded') ); control.setAttribute('aria-hidden', String(hiddenSibling)); }); this.hud?.style.removeProperty('--cockpit-utility-top'); this.hud?.style.removeProperty('--cockpit-utility-max-height'); this.hud?.style.removeProperty('--cockpit-utility-expanded-max-height'); this.signalStream.style.removeProperty('--cockpit-signal-right'); this.signalStream.style.removeProperty('--cockpit-signal-top'); this.signalStream.style.removeProperty('--cockpit-signal-max-height'); return; } const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04)); this.signalStream.dataset.layoutMode = 'bottom-right'; this.signalStream.style.setProperty('--cockpit-signal-right', `${desktopInset.toFixed(1)}px`); this.signalStream.style.removeProperty('--cockpit-signal-top'); this.signalStream.style.removeProperty('--cockpit-signal-max-height'); const signalBounds = this.signalStream.getBoundingClientRect(); const utilityBounds = utilityControls?.getBoundingClientRect(); if (utilityBounds) { const expandedControl = utilityControls?.querySelector('.cockpit-utility-control.is-expanded'); const collapsedControl = utilityControls?.querySelector( '.cockpit-utility-control:not(.is-expanded)', ); const collapsedLauncher = collapsedControl?.querySelector('.cockpit-utility-launcher'); const expandedHeight = expandedControl ? Math.max(expandedControl.scrollHeight, expandedControl.getBoundingClientRect().height) : 0; const collapsedHeight = collapsedLauncher ? Math.max(COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX, collapsedLauncher.scrollHeight) : 0; // Cockpit owns this anchor outright. The strip used to inherit the left // accordion's committed top, which is solved against left-lane obstacles // and dropped the strip straight through the briefing card below it. // The readout only anchors the strip while it is genuinely on screen: // the Minimal variant drops it with `display:none`, but HUD Off hides the // whole Intel HUD with `visibility`/`opacity`, which keeps its rect. const recReadout = document.querySelector('#intel-hud .hud-top-right'); const recBounds = isRenderedOnScreen(recReadout) ? recReadout.getBoundingClientRect() : null; const utilityAnchor = resolveCockpitUtilityAnchor({ recBottom: recBounds ? recBounds.bottom : 0, signalTop: signalBounds.top, stripHeight: utilityBounds.height, viewportHeight: window.innerHeight, collapsedHeight, recGap: COCKPIT_UTILITY_REC_GAP_PX, signalGap: COCKPIT_UTILITY_SIGNAL_GAP_PX, minTopFloor: COCKPIT_UTILITY_MIN_TOP_PX, minTopRatio: COCKPIT_UTILITY_MIN_TOP_RATIO, }); const availableHeight = utilityAnchor.maxHeight; this.hud?.style.setProperty('--cockpit-utility-top', `${utilityAnchor.top.toFixed(1)}px`); this.hud?.style.setProperty('--cockpit-utility-max-height', `${availableHeight.toFixed(2)}px`); const utilityLayout = expandedControl && collapsedControl ? resolveCockpitUtilityLayout({ availableHeight, expandedHeight, collapsedHeight }) : { primaryOnly: false, expandedMaxHeight: availableHeight }; utilityControls?.classList.toggle('layout-primary-only', utilityLayout.primaryOnly); this.hud?.style.setProperty( '--cockpit-utility-expanded-max-height', `${utilityLayout.expandedMaxHeight.toFixed(2)}px`, ); utilityControls?.querySelectorAll('.cockpit-utility-control').forEach((control) => { const hiddenSibling = utilityLayout.primaryOnly && control === collapsedControl; control.setAttribute('aria-hidden', String(hiddenSibling)); if (hiddenSibling && control.contains(document.activeElement)) { expandedControl?.querySelector('.cockpit-utility-glyph')?.focus({ preventScroll: true }); } }); } } dispose() { this.exit({ restoreTracking: false }); this.regionalBriefAbort?.abort(); this.regionalBriefAbort = null; this.regionalBriefRequestToken += 1; this.stopBriefRotation(); if (this.contextLayoutFrame !== null) cancelAnimationFrame(this.contextLayoutFrame); this.contextLayoutFrame = null; for (const removeListener of this._listenerRemovers.splice(0)) removeListener?.(); } } 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._clearSelectedLayersPromise = null; this._clearSelectedLayersManagerPromise = null; this._dataManager = null; this._cctvUnsubscribe = null; this._radioUnsubscribe = null; this._radioState = null; this._radioCategorySignature = ''; this._radioTunerStations = []; this._radioTunerPool = []; this._radioTunerDragging = false; this._radioTunerDragStartSlot = 0; this._radioTunerDragSnapshot = null; this._radioTunerLastSlot = 0; this._radioTunerDragDirection = 0; this._radioTunerCoordinate = 0; this._radioTunerPointerId = null; this._radioTunerKeyboardKey = null; this._radioTunerAbort = null; this._radioTunerBandSignature = ''; this._radioTunerSelectedId = null; this._radioTunerBandPinnedForNavigation = false; this._radioTunerCameraRemove = null; this._refreshRadioTunerBand = null; this._cctvState = null; this._cctvSummaryTypingTimer = null; this._lastCctvSummaryText = ''; // Auto-expand guard: last active camera id seen while the layer was // enabled; routine state notifications with the same id never re-expand. this._lastSeenCctvActiveId = null; this._cctvChipHideTimer = null; this._cctvChipWasBusy = false; 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._cockpitDisplayPortalRecords = []; this._cockpitDisplayPortalActive = false; 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._contextMode = null; this._contextModeChanging = false; this._contextModeGeneration = 0; this._contextModeEntering = null; this._contextModeEntryIntent = null; this._contextModeReplacementIntent = null; this._contextModeDeferredEntryIntent = null; this._contextSessionSnapshot = null; this._contextRestoreState = null; this._contextLayerReactionPromises = new Set(); this._preservePanelStateDuringLayerClear = false; this._userFacingContextNotificationTokens = new Set(); this._dataManagerBeforeDestroyUnsubscribe = null; this._dataManagerVisibilityGuardUnsubscribe = null; this._dataManagerVisibilityRequestUnsubscribe = null; 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._cctvFrameRequestToken = 0; this._cctvFramePreloader = null; 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, { 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); } /** * Updates the CCTV loading chip (same pattern as the traffic sync chip) * with staggered initial-load progress, e.g. "LOADING FRAMES 12/36". * Shows a brief "camera grid ready" confirmation, then hides. * @param {{active: boolean, loaded: number, total: number}|null|undefined} loading * Loading progress from the CCTV layer UI state. * @param {boolean} enabled - Whether the CCTV layer is currently enabled. * @returns {void} */ _updateCctvSyncChip(loading, enabled) { if (!this._cctvSyncChip || !this._cctvSyncLabel || !this._cctvSyncProgress) return; const total = Number(loading?.total) || 0; const loaded = Math.max(0, Math.min(Number(loading?.loaded) || 0, total)); const busy = !!enabled && !!loading?.active && total > 0; if (busy) { clearTimeout(this._cctvChipHideTimer); this._cctvChipHideTimer = null; this._cctvChipWasBusy = true; setSplitFlapText(this._cctvSyncLabel, 'loading frames'); // The counter is left plain on purpose: it ticks every few frames // during a grid load, and flapping it would read as a slot machine. this._cctvSyncProgress.textContent = `${loaded}/${total}`; this._cctvSyncChip.classList.add('visible'); return; } if (this._cctvChipWasBusy && enabled && total > 0) { // Load just completed — flash the final count, then auto-hide. this._cctvChipWasBusy = false; setSplitFlapText(this._cctvSyncLabel, 'camera grid ready'); this._cctvSyncProgress.textContent = `${total}/${total}`; this._cctvSyncChip.classList.add('visible'); clearTimeout(this._cctvChipHideTimer); this._cctvChipHideTimer = window.setTimeout(() => { this._cctvChipHideTimer = null; this._cctvSyncChip.classList.remove('visible'); }, 1500); return; } if (!this._cctvChipHideTimer) { this._cctvChipWasBusy = false; this._cctvSyncChip.classList.remove('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) { if (this._dataManagerBeforeDestroyUnsubscribe) { this._dataManagerBeforeDestroyUnsubscribe(); this._dataManagerBeforeDestroyUnsubscribe = null; } if (this._dataManagerVisibilityGuardUnsubscribe) { this._dataManagerVisibilityGuardUnsubscribe(); this._dataManagerVisibilityGuardUnsubscribe = null; } if (this._dataManagerVisibilityRequestUnsubscribe) { this._dataManagerVisibilityRequestUnsubscribe(); this._dataManagerVisibilityRequestUnsubscribe = null; } this._dataManager = dataManager || null; this.hud.attachDataManager(this._dataManager); this._updateTrafficSyncChip(); if (this._dataManagerUnsubscribe) { this._dataManagerUnsubscribe(); this._dataManagerUnsubscribe = null; } if (typeof this._dataManager?.subscribe === 'function') { this._dataManagerUnsubscribe = this._dataManager.subscribe((change) => { if (String(change?.type || '').startsWith('visibility')) { this._handleContextLayerChange(change); } this._loadingFeedbackEvent = change; this._updateGlobalLoadingFeedback(performance.now()); }); } this._updateGlobalLoadingFeedback(performance.now()); if (typeof this._dataManager?.subscribeVisibilityRequests === 'function') { this._dataManagerVisibilityRequestUnsubscribe = this._dataManager.subscribeVisibilityRequests((change) => { if (shouldCaptureContextSession(change)) { // This event is synchronous with intent publication, before an // awaited guard or Clear All can alter the rest of the layer set. // Manager effective visibility already includes both the new entry // intent and Clear's reserved OFF baseline. this._captureContextSessionSnapshot({ excludeLayerIds: [change.layerId] }); if (shouldDeferContextEntryDuringClear({ change, clearInFlight: Boolean(this._clearSelectedLayersPromise), })) { this._contextModeDeferredEntryIntent = { layerId: change.layerId, intentEpoch: change.intentEpoch, origin: change.origin, }; this._contextModeEntering = 'space-missions'; this._syncContextModeButtons(); } } else if ( change?.layerId === 'rocket-launches' && change.enabled === false && isExplicitUserIntentOrigin(change.origin, change.layerId) ) { this._contextModeDeferredEntryIntent = null; if (this._clearSelectedLayersPromise) { this._contextSessionSnapshot = null; this._contextModeEntering = null; this._syncContextModeButtons(); } } }); } if (typeof this._dataManager?.addVisibilityGuard === 'function') { this._dataManagerVisibilityGuardUnsubscribe = this._dataManager.addVisibilityGuard(async (change) => { const layerName = this._dataManager?.layers?.get(change.layerId)?.module?.name || change.layerId; const reason = contextLayerEnableBlockReason({ contextMode: this._contextModeEntering || this._contextMode, change, layerName, }); if (reason) return reason; if ( change.enabled && ['military-awareness', 'rocket-launches'].includes(change.layerId) && shouldCaptureContextSession(change) && ( !this._contextModeChanging || ( change.layerId === 'rocket-launches' && this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch ) ) ) { const entryMode = change.layerId === 'rocket-launches' ? 'space-missions' : null; const deferredClearEntry = this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch; // A deferred entry owns the state after Clear settles. Restoring // Clear's transient busy flag here would leave Context stuck. const priorChanging = deferredClearEntry ? false : this._contextModeChanging; const notificationToken = change.notificationToken || Symbol('direct-context-shell-entry'); const ownsNotificationToken = !change.notificationToken; if (ownsNotificationToken) { this._userFacingContextNotificationTokens.add(notificationToken); } this._contextModeEntering = entryMode; this._contextModeChanging = true; try { if (deferredClearEntry) { await this._clearSelectedLayersManagerPromise; if (this._contextModeDeferredEntryIntent?.intentEpoch !== change.intentEpoch) return false; this._contextModeDeferredEntryIntent = null; } await this._clearLayersOutsideContextMode(entryMode, { notificationToken }); } catch (error) { this._contextModeEntering = null; console.warn(`[Context] ${change.layerId} isolation failed`, error); try { await this._restoreContextSession({ excludeLayerIds: [change.layerId], notificationToken, }); } catch (restoreError) { console.warn(`[Context] ${change.layerId} rollback failed`, restoreError); } return `${entryMode === 'space-missions' ? 'Space Missions' : 'Context'} could not start because another layer did not stop cleanly`; } finally { if (ownsNotificationToken) { this._userFacingContextNotificationTokens.delete(notificationToken); } settleContextModeChange(this, priorChanging); } } return null; }); } if (typeof this._dataManager?.subscribeBeforeDestroy === 'function') { this._dataManagerBeforeDestroyUnsubscribe = this._dataManager.subscribeBeforeDestroy(async ({ layerId } = {}) => { if (!this._contextSessionSnapshot) return; await runWithContextModeChanging(this, async () => { this._contextMode = null; this.cockpitView?.exit({ restoreTracking: false }); this._syncContextModeButtons(); await this._restoreContextSession({ excludeLayerIds: layerId ? [layerId] : [] }); }); }); } this._syncContextModeButtons(); if (this._cctvUnsubscribe) { this._cctvUnsubscribe(); this._cctvUnsubscribe = null; } if (typeof cctvLayer.subscribe === 'function') { this._cctvUnsubscribe = cctvLayer.subscribe((state) => { this._renderCctvState(state); }); } if (typeof cctvLayer.getUIState === 'function') { this._renderCctvState(cctvLayer.getUIState()); } if (this._radioUnsubscribe) { this._radioUnsubscribe(); this._radioUnsubscribe = null; } if (typeof radioLayer.subscribe === 'function') { this._radioUnsubscribe = radioLayer.subscribe((state) => { this._renderRadioState(state); }); } 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(); } _initGlobalContextPanel() { const contextTabs = [this._globalContextFlightsBtn, this._globalContextMissionsBtn].filter(Boolean); contextTabs.forEach((tab, index) => tab.addEventListener('keydown', (event) => { let nextIndex = null; if (event.key === 'ArrowRight') nextIndex = (index + 1) % contextTabs.length; else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + contextTabs.length) % contextTabs.length; else if (event.key === 'Home') nextIndex = 0; else if (event.key === 'End') nextIndex = contextTabs.length - 1; if (nextIndex === null) return; event.preventDefault(); contextTabs[nextIndex].focus({ preventScroll: true }); contextTabs[nextIndex].click(); })); this._globalContextFlightsBtn?.addEventListener('click', () => { if (this._contextModeChanging || this._clearSelectedLayersPromise) return; const nextMode = this._contextMode === 'flights' ? null : 'flights'; this._claimContextVisualAuthority(); void this._runUserFacingContextAction( (notificationToken) => this._selectContextMode( nextMode, { notificationToken }, ), 'Contacts could not complete the requested transition; try again', ).then((succeeded) => { if (nextMode && shouldExpandGlobalContextPanel({ action: 'contacts', explicitUserAction: true, succeeded: succeeded === true, })) this.setPanelCollapsed('global-context-panel', false, { explicit: true }); }); }); this._globalContextMissionsBtn?.addEventListener('click', () => { if (this._contextModeChanging || this._clearSelectedLayersPromise) return; const nextMode = this._contextMode === 'space-missions' ? null : 'space-missions'; this._claimContextVisualAuthority(); void this._runUserFacingContextAction( (notificationToken) => this._selectContextMode( nextMode, { notificationToken }, ), 'Space Missions could not complete the requested transition; try again', ).then((succeeded) => { if (nextMode && shouldExpandGlobalContextPanel({ action: 'space-missions', explicitUserAction: true, succeeded: succeeded === true, })) this.setPanelCollapsed('global-context-panel', false, { explicit: true }); }); }); this._installationsSearchBtn?.addEventListener('click', () => { if (!this._dataManager?.layers?.has('military-installations')) return; const button = this._installationsSearchBtn; if (button.getAttribute('aria-busy') === 'true') return; button.setAttribute('aria-disabled', 'true'); button.setAttribute('aria-busy', 'true'); void this._runUserFacingContextAction(async (notificationToken) => { const enabled = await this._dataManager.setEnabled('military-installations', true, { origin: 'user', notificationToken, }); if (enabled === false || !this._dataManager.isEnabled('military-installations')) return false; const searched = await militaryInstallationsLayer.searchNearby?.(); if (searched === false) return false; const stats = militaryInstallationsLayer.getStats?.(); this._showToast(stats?.statusMessage || (stats?.status === 'zoom-in' ? 'Zoom in to search mapped installations' : 'Nearby installations refreshed')); return true; }, 'Nearby installations could not be refreshed; try again').finally(() => { button.setAttribute('aria-disabled', 'false'); button.setAttribute('aria-busy', 'false'); }); }); } async _runUserFacingContextAction( operation, message = 'Context could not restore every layer; try again', { falseIsFailure = true } = {}, ) { const notificationToken = Symbol('user-facing-context-action'); this._userFacingContextNotificationTokens.add(notificationToken); try { return await settleUserFacingContextAction({ operation: () => operation(notificationToken), falseIsFailure, onFailure: (error) => { console.warn('[Context] user-facing transition failed', error); this._showToast(message); }, }); } finally { this._userFacingContextNotificationTokens.delete(notificationToken); } } _trackContextLayerReaction(promise) { const tracked = Promise.resolve(promise); this._contextLayerReactionPromises.add(tracked); void tracked.finally(() => this._contextLayerReactionPromises.delete(tracked)); return tracked; } async _waitForContextLayerSettlement() { while (this._contextLayerReactionPromises.size > 0) { await Promise.allSettled([...this._contextLayerReactionPromises]); } } _captureContextSessionSnapshot({ excludeLayerIds = [] } = {}) { if (!this._dataManager || this._contextSessionSnapshot) return; const params = {}; for (const layerId of ['military-awareness', 'satellites']) { const value = this._dataManager.getLayerParams(layerId); if (value) params[layerId] = value; } this._contextSessionSnapshot = { enabledLayerIds: contextSnapshotLayerIds( this._dataManager.getEnabledLayerIds(), this._contextRestoreState?.enabledLayerIds, excludeLayerIds, ), userAdded: new Set(), userRemoved: new Set(), params, }; } async _restoreContextSession({ excludeLayerIds = [], notificationToken = null, signal = null, } = {}) { const snapshot = this._contextSessionSnapshot; if (!snapshot || !this._dataManager) return; // Clear the stored session before emitting restore notifications so none // of those transitions can be mistaken for a fresh Context entry. this._contextSessionSnapshot = null; const restoreState = { enabledLayerIds: contextRestoreLayerIds(snapshot), explicitLayerStates: new Map(), }; this._contextRestoreState = restoreState; for (const [layerId, params] of Object.entries(snapshot.params)) { this._dataManager.setLayerParams(layerId, params); } let restoreError = null; const restoreSnapshot = async (restoreSignal = null) => { // Contacts owns the dependency intents it starts. Settle that // coordinator before restoring the remaining snapshot, otherwise its // dependency releases can supersede the restore's same-target requests // and make a valid Contacts-to-Missions handoff look like a failure. const contactsCoordinatorId = 'military-awareness'; const settleContactsCoordinator = !restoreState.enabledLayerIds.has(contactsCoordinatorId) && this._dataManager.isEffectivelyEnabled(contactsCoordinatorId); if (settleContactsCoordinator) { const coordinatorSettled = await this._dataManager.setEnabled( contactsCoordinatorId, false, { origin: 'context-restore', ...(notificationToken ? { notificationToken } : {}), ...(restoreSignal ? { signal: restoreSignal } : {}), }, ); if (coordinatorSettled === false) { const error = new Error('Failed to settle Contacts before restoring Context'); error.failedLayerIds = [contactsCoordinatorId]; throw error; } } await this._dataManager.restoreEnabledLayerIds(restoreState.enabledLayerIds, { origin: 'context-restore', excludeLayerIds: settleContactsCoordinator ? [...excludeLayerIds, contactsCoordinatorId] : excludeLayerIds, notificationToken, ...(restoreSignal ? { signal: restoreSignal } : {}), }); }; try { await restoreSnapshot(signal); } catch (error) { restoreError = error; // A caller abort can arrive after only part of the exact restore has // settled. Finish that same target without the stale caller signal while // this restoreState still records newer explicit intents; replay below // then gives those newer intents final authority. if (signal?.aborted && !restoreState.cancelled) { try { await restoreSnapshot(null); restoreError = null; } catch (compensationError) { restoreError = mergeContextTransitionErrors(restoreError, compensationError); } } } finally { if (this._contextRestoreState === restoreState) this._contextRestoreState = null; } // Clear Selected Layers owns a newer global OFF intent. A restore that was // already awaiting lifecycle work must not replay its captured companion // intent or recreate the discarded session after Clear invalidates it. if (restoreState.cancelled) return; // A direct Radio command may finish after restore has already copied its // target and queued the opposite state. Replay that newer intent only // after the stale queue drains; the replay origin cannot recurse here. const replaySignal = signal?.aborted ? null : signal; const replayError = await settleContextIntentReplay({ restoreState, setEnabled: (layerId, enabled, options = {}) => this._dataManager.setEnabled( layerId, enabled, { ...options, ...(replaySignal ? { signal: replaySignal } : {}) }, ), notificationToken, }); restoreError = mergeContextTransitionErrors(restoreError, replayError); if (restoreError && !this._contextSessionSnapshot) { // Keep the exact still-pending target so a later exit/teardown can retry // instead of silently losing the user's pre-Context layer state. this._contextSessionSnapshot = { enabledLayerIds: new Set(restoreState.enabledLayerIds), userAdded: new Set(), userRemoved: new Set(), params: snapshot.params, }; } if (restoreError) throw restoreError; } /* * Cross-mode cancellation and failure deliberately settle on Context OFF. * * A reinstatement transaction lived here for three review rounds and was * removed on purpose. Restoring the prior mode is genuinely racy: the prior * mode has to be read before the teardown, but a second request arriving * while the first reinstatement is mid-activation reads `_contextMode` as * null and inherits nothing, so two overlapping cancellations still land on * OFF — and the only fix is a cross-transaction "logical prior mode" chain, * which is new shared mutable state read while an earlier transaction is * still awaiting. That trades a rare wrong resting state for a permanent * interleaving hazard. * * The defect that started this was the LIE, not the OFF: the transition * claimed to have cancelled cleanly while silently leaving Context off. So * the resting state stays OFF and is REPORTED as such, with the failed layer * ids preserved. A restore feature can be rebuilt post-launch on the * generation discipline the surrounding transaction already follows. */ async _restoreContextSessionAfterLayerSettles(layerId, { notificationToken = null } = {}) { await this._dataManager?.waitForLayerSettled?.(layerId); return this._restoreContextSession({ notificationToken }); } /** * Claim the visual restore lane for an explicit Context transition. * * Contacts OWNS detection while active (forced Dense @ 75%), so entering or * leaving it is a visual-lane gesture exactly like the HUD or detection * controls. Without this claim, the shared-view restore that lands 1.5 s into * startup re-applied the link's `dm`/`dd` over the forced preset and Contacts * lost its own overlay mid-session. * * Deliberately does NOT set `_detectionUserOverridden`: that flag means the * OPERATOR hand-edited detection and suppresses the military-style * auto-enable for the rest of the session. Context entry is not that, and * conflating them would silently disable a separate landed behavior. * * Call only for VALIDATED explicit transitions — never for programmatic or * restore-driven ones, which must stay eligible for the shared visual state. */ _claimContextVisualAuthority() { this.shareLinkManager?.claimRestoreLane?.('visual'); } async _selectContextMode(mode, { notificationToken = null, signal = null } = {}) { if (!this._dataManager) return false; if (this._clearSelectedLayersPromise) return false; this._contextTransitionFailedLayerIds = []; const generation = ++this._contextModeGeneration; const isCurrent = () => generation === this._contextModeGeneration; this._contextModeEntryIntent = null; this._contextModeReplacementIntent = null; this._contextModeChanging = true; this._syncContextModeButtons(); try { if (mode !== 'flights' && this.cockpitView?.active) { this.cockpitView.exit({ restoreTracking: false }); } if (!mode) { this._contextMode = null; this._syncContextModeButtons(); await this._restoreContextSession({ notificationToken, signal }); return isCurrent(); } // A cross-mode switch dismantles the prior mode BEFORE the new one is // committed. If the caller aborts in that window the switch never lands, // and the resting state is Context OFF — reported as such by // setContextMode rather than dressed up as a clean cancellation. See the // note above _restoreContextSessionAfterLayerSettles. const crossModeSwitch = Boolean(this._contextMode && this._contextMode !== mode); if (crossModeSwitch) { this._contextMode = null; await this._restoreContextSession({ notificationToken, signal }); if (!isCurrent()) return false; if (signal?.aborted) return false; } this._captureContextSessionSnapshot(); this._contextMode = mode; this._syncContextModeButtons(); // Replay isolation must settle before Space Missions starts. Contacts // keeps its non-dependency teardown in the background so slow source // shutdown does not delay cockpit entry. try { await this._clearLayersOutsideContextMode(mode, { notificationToken, signal }); } catch (error) { if (!isCurrent()) return false; let transitionError = error; console.warn(`[Context] ${mode} isolation failed`, error); this._contextMode = null; this._syncContextModeButtons(); try { await this._restoreContextSession({ notificationToken }); } catch (restoreError) { transitionError = mergeContextTransitionErrors(transitionError, restoreError); this._contextTransitionFailedLayerIds = [...(transitionError.failedLayerIds || [])]; throw transitionError; } this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])]; return false; } if (!isCurrent()) return false; // Entry is one transaction: isolation succeeded above, so a failed mode // activation must roll the cleared layers back instead of stranding the // user in a half-entered mode with an orphaned snapshot. const entryLayerId = mode === 'flights' ? 'military-awareness' : 'rocket-launches'; if (mode === 'flights') { this._dataManager.setLayerParams('military-awareness', { passive: false }); } let activated = false; let activationError = null; let activationIntent = null; let terminalIntentOutcome = null; try { activationIntent = this._dataManager._setEnabledWithIntent( entryLayerId, true, { notificationToken, ...(signal ? { signal } : {}) }, ); this._contextModeEntryIntent = { generation, layerId: entryLayerId, intentEpoch: activationIntent.intentEpoch, }; activated = await activationIntent.promise; terminalIntentOutcome = await this._dataManager._waitForVisibilityIntent?.( entryLayerId, activationIntent.intentEpoch, ); } catch (error) { activationError = error; } if (!isCurrent()) return false; let replacementIntent = mode === 'space-missions' && this._contextModeReplacementIntent?.generation === generation && this._contextModeReplacementIntent.layerId === entryLayerId ? this._contextModeReplacementIntent : null; while (replacementIntent) { const outcome = await this._dataManager._waitForVisibilityIntent?.( entryLayerId, replacementIntent.intentEpoch, ); terminalIntentOutcome = outcome; if (!isCurrent()) return false; const replacementOwnsMode = outcome?.intentEpoch === replacementIntent.intentEpoch && outcome.enabled === true && outcome.succeeded === true; if (replacementOwnsMode) { this._contextModeEntering = null; this._contextModeEntryIntent = null; this._contextModeReplacementIntent = null; this._syncContextModeButtons(); return true; } const successorEpoch = outcome?.cancellationReason === 'superseded' && outcome.successorEnabled === true && Number.isInteger(outcome.successorIntentEpoch) && outcome.successorIntentEpoch > replacementIntent.intentEpoch ? outcome.successorIntentEpoch : null; replacementIntent = successorEpoch === null ? null : { generation, layerId: entryLayerId, intentEpoch: successorEpoch, }; } if (activationError || activated === false || !this._dataManager.isEnabled(entryLayerId)) { const cancelledAndSettled = terminalIntentOutcome?.succeeded === false && ['caller-abort', 'resource-abort', 'superseded'].includes( terminalIntentOutcome.cancellationReason, ); let transitionError = null; if (!cancelledAndSettled) { transitionError = activationError instanceof Error ? activationError : new Error(`Context activation failed for: ${entryLayerId}`); transitionError.failedLayerIds = [...new Set([ ...(transitionError.failedLayerIds || []), entryLayerId, ])]; this._contextTransitionFailedLayerIds = [...transitionError.failedLayerIds]; console.warn(`[Context] ${mode} activation failed; restoring previous layers`, activationError || 'not enabled'); } this._contextMode = null; this._contextModeEntryIntent = null; this._contextModeReplacementIntent = null; this._syncContextModeButtons(); try { await this._restoreContextSession({ excludeLayerIds: [entryLayerId], notificationToken, }); } catch (restoreError) { transitionError = mergeContextTransitionErrors(transitionError, restoreError); this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])]; throw transitionError; } // `null` means the requested entry was cancelled and its exact rollback // completed. The action wrapper treats that as a silent non-commit, // while callers still require literal `true` before expanding Context. return cancelledAndSettled ? null : false; } this._contextModeEntryIntent = null; return true; } finally { if (isCurrent()) { this._contextModeChanging = false; this._syncContextModeButtons(); } } } async _deactivateContextForLayerChange({ notificationToken = null } = {}) { this._contextModeGeneration += 1; this._contextModeChanging = true; this._contextMode = null; this._contextModeEntryIntent = null; this._contextModeReplacementIntent = null; if (this.cockpitView?.active) this.cockpitView.exit({ restoreTracking: false }); this._syncContextModeButtons(); try { await this._restoreContextSession({ notificationToken }); } finally { this._contextModeChanging = false; this._syncContextModeButtons(); } } async _clearLayersOutsideContextMode( mode = null, { notificationToken = null, signal = null } = {}, ) { const allowed = contextAllowedLayerIds(mode); const pending = []; for (const [layerId] of this._dataManager.layers || []) { // Effective visibility: a disallowed layer still mid-ENABLING must be // isolated too, or it settles ON inside the exclusive mode. if (!allowed.has(layerId) && this._dataManager.isEffectivelyEnabled(layerId)) { pending.push({ layerId, transition: this._dataManager.setEnabled(layerId, false, { notificationToken, ...(signal ? { signal } : {}), }), }); } } const results = await Promise.all(pending.map(({ transition }) => transition)); const failed = pending .filter(({ layerId }, index) => results[index] === false || this._dataManager.isEnabled(layerId)) .map(({ layerId }) => layerId); if (failed.length > 0) { const error = new Error(`Context isolation failed for: ${failed.join(', ')}`); error.failedLayerIds = failed; throw error; } } _handleContextLayerChange(change) { if (change?.layerId === 'radio' && [ 'visibility-transition', 'visibility', 'visibility-cancelled', 'visibility-failed', ].includes(change.type)) { this._renderRadioState(radioLayer.getUIState()); } if (change?.type === 'visibility-transition') return; // The effective mode must be read BEFORE the entering flag is cleared: // the entry layer's own enable event is the one that clears it, and the // session bookkeeping below needs to know a mode was being entered. const effectiveContextMode = this._contextModeEntering || this._contextMode; if (change?.type === 'visibility-cancelled') { const cancellationDisposition = spaceMissionEntryCancellationDisposition({ change, }); if ( this._contextModeDeferredEntryIntent?.layerId === change.layerId && this._contextModeDeferredEntryIntent.intentEpoch === change.intentEpoch ) { if (cancellationDisposition !== 'replacement') { this._contextModeDeferredEntryIntent = null; } } if (cancellationDisposition === 'replacement') { this._contextModeEntering = 'space-missions'; const entryIntent = this._contextModeEntryIntent; if ( entryIntent?.generation === this._contextModeGeneration && entryIntent.layerId === change.layerId && entryIntent.intentEpoch === change.intentEpoch ) { this._contextModeReplacementIntent = { generation: entryIntent.generation, layerId: change.layerId, intentEpoch: change.successorIntentEpoch, }; } } else if (cancellationDisposition === 'restore') { this._contextModeEntering = null; this._contextModeEntryIntent = null; this._contextModeReplacementIntent = null; if (this._contextSessionSnapshot && !this._contextModeChanging) { this._contextMode = null; void this._trackContextLayerReaction(this._runUserFacingContextAction( async (notificationToken) => { await this._restoreContextSessionAfterLayerSettles( change.layerId, { notificationToken }, ); return true; }, 'Space Missions cancellation could not restore the previous layer state', )); } } this._syncContextModeButtons(); return; } if ( change?.layerId === 'rocket-launches' && ['visibility', 'visibility-blocked', 'visibility-failed'].includes(change.type) ) { this._contextModeEntering = null; } if (change?.type === 'visibility-blocked') { if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) { this._showToast(change.reason || 'That layer is unavailable in the current Context mode'); } this._syncContextModeButtons(); return; } if (change?.type === 'visibility-failed') { const failureMessage = `${change.layerId} could not ${change.enabled ? 'start' : 'stop'} cleanly`; // A failed direct Context-shell START has already had its siblings // cleared by the visibility guard. Wait outside the synchronous manager // notification for this queue to settle, then reconcile the complete // snapshot, including an uncertain failed shell. const needsDeferredShellRestore = ( ['military-awareness', 'rocket-launches'].includes(change.layerId) && change.enabled && this._contextSessionSnapshot && !this._contextModeChanging ); if (needsDeferredShellRestore) { this._contextMode = null; void this._trackContextLayerReaction(this._runUserFacingContextAction( async (notificationToken) => { await this._restoreContextSessionAfterLayerSettles( change.layerId, { notificationToken }, ); // The wrapper owns failure announcements. On a successful rollback // announce the original activation failure here so the same direct // action still produces exactly one accessible notification. this._showToast(failureMessage); return true; }, failureMessage, )); } else if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) { this._showToast(failureMessage); } this._syncContextModeButtons(); return; } if (change?.type === 'visibility-will-change') { // Explicit entry capture happens on the synchronous visibility-requested // boundary. Keeping this later branch side-effect free prevents an // awaited Clear/guard from replacing that authoritative pre-entry view. return; } // Session bookkeeping must run BEFORE any exit path below: the exit // handlers restore `snapshot ∪ userAdded`, so a stale entry here becomes // a layer resurrected against the user's explicit disable. recordContextSessionUserChange({ snapshot: this._contextSessionSnapshot, change, effectiveContextMode, }); recordContextRestoreExplicitChange({ restoreState: this._contextRestoreState, change, }); if (shouldExitContextForLayerChange({ contextMode: this._contextMode, globalContextEnabled: !!this._dataManager?.isEnabled('military-awareness'), change, })) { void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( this._deactivateContextForLayerChange({ notificationToken }) ))); return; } if (!this._contextModeChanging) { if (change.layerId === 'military-awareness') { // The coordinator remains manager-addressable for restoration and // programmatic routes, but Contacts is selected only from the // dedicated right-side Global Context chooser. this._contextMode = change.enabled ? null : (this._contextMode === 'flights' ? null : this._contextMode); if (change.enabled) { this._syncContextModeButtons(); } else if (this._contextSessionSnapshot) { void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( this._deactivateContextForLayerChange({ notificationToken }) ))); } } else if (change.layerId === 'rocket-launches') { const ownsContextEntry = isExplicitUserIntentOrigin(change.origin, change.layerId) || this._contextMode === 'space-missions' || effectiveContextMode === 'space-missions'; if (!ownsContextEntry) return; this._contextMode = change.enabled ? 'space-missions' : (this._contextMode === 'space-missions' ? null : this._contextMode); if (change.enabled) { this._syncContextModeButtons(); } else if (this._contextSessionSnapshot) { void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( this._deactivateContextForLayerChange({ notificationToken }) ))); } } else if ( this._contextMode === 'flights' && ['flights', 'military', 'ais-live-vessels', 'military-installations'].includes(change.layerId) && !change.enabled ) { void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( this._deactivateContextForLayerChange({ notificationToken }) ))); } } if (this.cockpitView?.active && !cockpitEntryAllowed({ contextMode: this._contextMode, contextModeChanging: this._contextModeChanging, flightsEnabled: !!this._dataManager?.isEnabled('flights'), militaryEnabled: !!this._dataManager?.isEnabled('military'), })) { this.cockpitView.exit({ restoreTracking: false }); } this._syncContextModeButtons(); } _syncContextModeButtons() { const flightsActive = this._contextMode === 'flights'; const missionsActive = this._contextMode === 'space-missions'; const panel = document.getElementById('global-context-panel'); panel?.classList.toggle('context-enabled', flightsActive || missionsActive); panel?.setAttribute('data-context-mode', this._contextMode || 'none'); this._globalContextFlightsBtn?.classList.toggle('active', flightsActive); this._globalContextFlightsBtn?.setAttribute('aria-selected', String(flightsActive)); this._globalContextMissionsBtn?.classList.toggle('active', missionsActive); this._globalContextMissionsBtn?.setAttribute('aria-selected', String(missionsActive)); const transitionBusy = Boolean(this._contextModeChanging); // Both Context choices stay in the ordinary Tab sequence. Arrow keys still // provide tablist navigation, but must not be the only way to reach Space // Missions from the keyboard. Semantic busy state keeps them perceivable // while synchronous click guards prevent a second transition. for (const button of [this._globalContextFlightsBtn, this._globalContextMissionsBtn]) { if (!button) continue; button.disabled = false; button.tabIndex = 0; button.setAttribute('aria-disabled', String(transitionBusy)); button.setAttribute('aria-busy', String(transitionBusy)); } if (this._contextModeStandby) this._contextModeStandby.hidden = flightsActive || missionsActive; if (this._contextFlightsView) this._contextFlightsView.hidden = !flightsActive; if (this._contextMissionsView) this._contextMissionsView.hidden = !missionsActive; this.cockpitView?.syncEntry(); // Every _contextMode mutation funnels through here; the sync no-ops until // the transaction settles, so this is the activation/deactivation edge. this._syncContactsDetection(); this._scheduleRightPanelLayout(); } /** Wire the independent Radio companion controls. */ _initRadioPanel() { if (!this._radioPanel) return; this._radioTunerAbort?.abort(); this._radioTunerAbort = new AbortController(); const tunerListenerOptions = { signal: this._radioTunerAbort.signal }; const setRadioDisclosure = (expanded, { returnFocus = false } = {}) => { const open = Boolean(expanded); this._contextRadioDock?.classList.toggle('disclosure-open', open); if (this._contextRadioMini) this._contextRadioMini.hidden = !open; this._syncContextRadioLauncherState(); if (!open && returnFocus) this._contextRadioToggleBtn?.focus({ preventScroll: true }); }; this._setRadioDisclosure = setRadioDisclosure; const setCockpitDisclosure = (kind, expanded, { returnFocus = false } = {}) => { const displayOpen = kind === 'display' && Boolean(expanded); const radioOpen = kind === 'radio' && Boolean(expanded); if (displayOpen || radioOpen) this.cockpitView?.setSignalCollapsed(true); if (this._cockpitDisplayPanel) this._cockpitDisplayPanel.hidden = !displayOpen; if (this._cockpitRadioPanel) this._cockpitRadioPanel.hidden = !radioOpen; this._cockpitDisplayToggleBtn?.closest('.cockpit-utility-control') ?.classList.toggle('is-expanded', displayOpen); this._cockpitRadioToggleBtn?.closest('.cockpit-utility-control') ?.classList.toggle('is-expanded', radioOpen); this._cockpitDisplayToggleBtn?.setAttribute('aria-expanded', String(displayOpen)); this._cockpitRadioToggleBtn?.setAttribute('aria-expanded', String(radioOpen)); if (displayOpen) this._revealCockpitStyleParameters(); if (this._cockpitDisplayToggleBtn) { const action = displayOpen ? 'Collapse' : 'Expand'; this._cockpitDisplayToggleBtn.textContent = displayOpen ? '▶' : '◀'; this._cockpitDisplayToggleBtn.setAttribute('aria-label', `${action} Cockpit display options`); this._cockpitDisplayToggleBtn.title = `${action} Cockpit display options`; } if (this._cockpitRadioToggleBtn) { const action = radioOpen ? 'Collapse' : 'Expand'; this._cockpitRadioToggleBtn.textContent = radioOpen ? '▶' : '◀'; this._cockpitRadioToggleBtn.setAttribute('aria-label', `${action} Cockpit Radio controls`); this._cockpitRadioToggleBtn.title = `${action} Cockpit Radio controls`; } if (!expanded && returnFocus) { (kind === 'display' ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn) ?.focus({ preventScroll: true }); } if (!displayOpen && !radioOpen && this.cockpitView?.active && !this.cockpitView.signalUserCollapsed) { this.cockpitView.setSignalCollapsed(false); } this.cockpitView?.scheduleContextLayout(); }; this._setCockpitDisclosure = setCockpitDisclosure; const syncTunerTape = (coordinate) => { const scale = this._radioTuner?.querySelector('.radio-tuner-scale'); const dial = this._radioTuner?.querySelector('.radio-tuner-dial'); if (!scale || !dial) return null; const model = buildRadioTunerTicks( coordinate, this._radioTunerStations.length, dial.getBoundingClientRect().width, ); while (scale.children.length < model.ticks.length) { const tick = document.createElement('span'); tick.className = 'radio-tuner-tick'; scale.append(tick); } while (scale.children.length > model.ticks.length) scale.lastElementChild?.remove(); model.ticks.forEach((entry, index) => { const tick = scale.children[index]; tick.style.left = `${entry.xPx}px`; tick.textContent = entry.label; tick.dataset.stationIndex = String(entry.stationIndex); tick.classList.toggle('is-current', entry.current); }); scale.style.setProperty('--radio-tuner-tick-pitch', `${model.pitchPx}px`); return model; }; const tunerPreview = ({ coordinate = this._radioTunerCoordinate, syncStatic = true, rotate = true } = {}) => { const slot = radioTunerSlot(this._radioTunerSlider?.value, this._radioTunerStations.length); const station = slot.locked ? this._radioTunerStations[slot.stationIndex] || null : null; const resolvedCoordinate = this._radioTunerStations.length <= 1 ? 0 : Math.min(this._radioTunerStations.length - 1, Math.max(0, Number(coordinate) || 0)); const ratio = this._radioTunerStations.length === 1 ? 0.5 : resolvedCoordinate / Math.max(1, this._radioTunerStations.length - 1); this._radioTunerCoordinate = resolvedCoordinate; this._radioTuner?.style.setProperty('--radio-tuner-ratio', String(ratio)); this._radioTuner?.classList.toggle('is-static', syncStatic ? false : Boolean(this._radioState?.tuningStatic)); syncTunerTape(resolvedCoordinate); if (this._radioTunerValue) { this._radioTunerValue.textContent = station ? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}` : 'NO STATIONS'; } if (this._radioTunerStation) this._radioTunerStation.textContent = station?.name || 'NO STATION AVAILABLE'; if (this._radioTunerSlider) { this._radioTunerSlider.setAttribute('aria-valuetext', station ? `${station.name}, station ${slot.stationIndex + 1} of ${this._radioTunerStations.length}` : 'No station available'); } if (syncStatic) radioLayer.previewTuningStation(station?.id || null, { rotate }); return station; }; const setTunerDirectory = (pool) => { this._radioTunerPool = [...pool]; this._radioTunerStations = [...pool]; this._radioTunerBandSignature = this._radioTunerStations.map((station) => station.id).join('|'); if (this._radioTunerSlider) { this._radioTunerSlider.min = '0'; this._radioTunerSlider.max = String(Math.max(0, this._radioTunerStations.length - 1)); this._radioTunerSlider.step = '1'; } }; const refreshTunerBand = ({ force = false } = {}) => { if (this._radioTunerDragging || this._radioTuner?.hidden || this._radioTunerSlider?.disabled) return false; const selectedId = this._radioState?.selected?.id || null; const pool = radioLayer.getTunerStations(750); const poolSignature = pool.map((station) => station.id).join('|'); const currentPoolSignature = this._radioTunerPool.map((station) => station.id).join('|'); if (!force && poolSignature === currentPoolSignature && selectedId === this._radioTunerSelectedId) return false; setTunerDirectory(pool); this._radioTunerSelectedId = selectedId; const selectedPoolIndex = pool.findIndex((station) => station.id === selectedId); const slot = radioTunerSlot(selectedPoolIndex >= 0 ? selectedPoolIndex : 0, this._radioTunerStations.length); this._radioTunerSlider.value = String(slot.slot); this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0; tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false }); return true; }; this._refreshRadioTunerBand = refreshTunerBand; const beginTuner = () => { if (this._radioTunerDragging || this._radioTunerSlider?.disabled) return false; refreshTunerBand(); if (!this._radioTunerStations.length || !radioLayer.beginTuning()) return false; // A tuner-owned camera preview must never replace the frozen directory. // Only an explicit globe pointer/wheel gesture releases camera pinning. this._radioTunerBandPinnedForNavigation = true; this._radioTunerDragging = true; this._radioTuner?.classList.add('is-dragging'); const selectedIndex = this._radioTunerStations.findIndex((station) => station.id === this._radioState?.selected?.id); const slot = radioTunerSlot(selectedIndex >= 0 ? selectedIndex : 0, this._radioTunerStations.length); this._radioTunerSlider.value = String(slot.slot); this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0; this._radioTunerDragStartSlot = slot.slot; this._radioTunerDragSnapshot = { stations: [...this._radioTunerStations], bandSignature: this._radioTunerBandSignature, selectedId: this._radioTunerSelectedId, slot: slot.slot, coordinate: this._radioTunerCoordinate, }; this._radioTunerLastSlot = slot.slot; this._radioTunerDragDirection = 0; tunerPreview({ coordinate: this._radioTunerCoordinate }); return true; }; const finishTuner = (commit) => { if (!this._radioTunerDragging) return; const dragSnapshot = this._radioTunerDragSnapshot; if (!commit && dragSnapshot) { this._radioTunerStations = [...dragSnapshot.stations]; this._radioTunerPool = [...dragSnapshot.stations]; this._radioTunerBandSignature = dragSnapshot.bandSignature; this._radioTunerSelectedId = dragSnapshot.selectedId; if (this._radioTunerSlider) { const startSlot = radioTunerSlot(dragSnapshot.slot, this._radioTunerStations.length); this._radioTunerSlider.max = String(startSlot.max); this._radioTunerSlider.value = String(startSlot.slot); } this._radioTunerCoordinate = Number.isFinite(dragSnapshot.coordinate) ? dragSnapshot.coordinate : dragSnapshot.slot; } else if (!commit && this._radioTunerSlider) { this._radioTunerSlider.value = String(this._radioTunerDragStartSlot); this._radioTunerCoordinate = this._radioTunerDragStartSlot; } let station = tunerPreview({ coordinate: this._radioTunerCoordinate, rotate: commit }); if (commit && !station && this._radioTunerSlider) { const snapped = radioTunerCommitSlot( this._radioTunerSlider.value, this._radioTunerStations.length, ); this._radioTunerSlider.value = String(snapped.slot); this._radioTunerCoordinate = snapped.stationIndex; station = tunerPreview({ coordinate: this._radioTunerCoordinate }); } let result = null; if (commit && station) { // Keep the exact band used by the drag so the selected channel cannot // jump to a refreshed catalog slot while its camera flight settles. this._radioTunerBandPinnedForNavigation = true; result = radioLayer.commitTuningStation(station.id, { origin: 'user' }); } else if (!commit) { radioLayer.cancelTuning(); } else { radioLayer.endTuning(); } // Radio emits selection/tuning state synchronously. Keep both the logical // drag and the no-transition class active until that state has settled, // then restore the exact selected slot before permitting CSS motion. this._radioTunerDragging = false; this._radioTunerPointerId = null; this._radioTunerKeyboardKey = null; if (commit && (!result || result.ok)) refreshTunerBand(); this._radioTunerDragSnapshot = null; // Flush the snapped position while transitions are still disabled so // removing the drag class cannot interpolate from the released gap. void this._radioTunerNeedle?.offsetLeft; this._radioTuner?.classList.remove('is-dragging'); if (result && !result.ok) { this._radioTunerBandPinnedForNavigation = false; if (result.reason === 'station-unavailable') { if (this._radioTunerValue) this._radioTunerValue.textContent = 'OFF AIR'; if (this._radioTunerStation) this._radioTunerStation.textContent = 'STATION UNAVAILABLE'; this._radioTunerSlider?.setAttribute( 'aria-valuetext', 'Station unavailable after directory refresh', ); } } }; const cycleRadio = (direction, { rotate = true } = {}) => { this._radioTunerBandPinnedForNavigation = true; const pool = this._radioTunerPool.length ? this._radioTunerPool : this._radioTunerStations; const cycled = radioLayer.cycleStation(direction, { rotate, stationIds: pool.map((station) => station.id), origin: 'user', }); if (!cycled) { this._radioTunerBandPinnedForNavigation = false; return; } }; const toggleRadio = async (trigger) => { if (!this._dataManager?.layers?.has('radio')) return; if (trigger.getAttribute('aria-busy') === 'true') return; const enabling = !this._dataManager.isEnabled('radio'); const revealAfterEnable = enabling && trigger === this._radioEnableBtn; trigger.setAttribute('aria-disabled', 'true'); trigger.setAttribute('aria-busy', 'true'); try { const toggled = await this._runUserFacingContextAction( (notificationToken) => this._dataManager.setEnabled('radio', enabling, { origin: 'user', notificationToken, }), `Radio could not ${enabling ? 'start' : 'stop'} cleanly`, ); if (toggled === false) return; if (enabling && trigger === this._radioEnableBtn && !document.getElementById('global-context-panel')?.classList.contains('collapsed')) { this.setPanelCollapsed('radio-panel', false, { explicit: true }); } if (revealAfterEnable) await this._revealRadioControlsAfterExplicitEnable(trigger); } finally { trigger.setAttribute('aria-disabled', 'false'); trigger.setAttribute('aria-busy', 'false'); if (revealAfterEnable && trigger.isConnected) trigger.focus({ preventScroll: true }); } }; this._radioEnableBtn?.addEventListener('click', () => void toggleRadio(this._radioEnableBtn)); this._contextRadioMiniEnableBtn?.addEventListener('click', () => void toggleRadio(this._contextRadioMiniEnableBtn)); this._cockpitRadioEnableBtn?.addEventListener('click', () => void toggleRadio(this._cockpitRadioEnableBtn)); this._contextRadioToggleBtn?.addEventListener('click', () => { const contextPanel = document.getElementById('global-context-panel'); if (contextPanel && !contextPanel.classList.contains('collapsed')) { setRadioDisclosure(false); this.setPanelCollapsed('radio-panel', false, { explicit: true }); void this._revealRadioPanelInsideContext({ focusTarget: this._radioPanel?.querySelector('[data-collapse-target="radio-panel"]'), }); return; } setRadioDisclosure(!this._contextRadioDock?.classList.contains('disclosure-open')); }); this._contextRadioMiniCloseBtn?.addEventListener('click', () => { setRadioDisclosure(false, { returnFocus: true }); }); this._contextRadioDetailsBtn?.addEventListener('click', () => { if (!this.cockpitView?.active) this.setPanelCollapsed('global-context-panel', false, { explicit: true }); this.setPanelCollapsed('radio-panel', false, { explicit: true }); setRadioDisclosure(false); this._radioEnableBtn?.focus({ preventScroll: true }); }); this._cockpitRadioToggleBtn?.addEventListener('click', () => { const open = this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true'; setCockpitDisclosure('radio', !open); }); document.addEventListener('pointerdown', (event) => { if (!this._contextRadioDock?.classList.contains('disclosure-open')) return; if (event.target?.closest?.('#context-radio-dock')) return; setRadioDisclosure(false); }, tunerListenerOptions); document.addEventListener('pointerdown', (event) => { if (!this._cockpitUtilityControls || event.target?.closest?.('#cockpit-utility-controls')) return; if (event.target?.closest?.('.cockpit-vision-controls')) return; if (event.target?.closest?.('#left-panel-stack, #cockpit-context')) return; setCockpitDisclosure('display', false); setCockpitDisclosure('radio', false); }, tunerListenerOptions); document.addEventListener('keydown', (event) => { if (event.key !== 'Escape' || !this._contextRadioDock?.classList.contains('disclosure-open')) return; event.preventDefault(); // Immediate: a plain stopPropagation() still lets every LATER listener on // this same document run, so closing the disclosure ALSO dismissed the // first-run launcher — one key, two actions. Matches the cockpit // disclosure handler directly below. event.stopImmediatePropagation(); const escapedFromDisclosure = event.target === this._contextRadioToggleBtn || this._contextRadioToggleBtn?.contains?.(event.target); setRadioDisclosure(false, { returnFocus: !escapedFromDisclosure }); if (escapedFromDisclosure) this._contextRadioToggleBtn?.blur?.(); }, { capture: true, signal: this._radioTunerAbort.signal }); document.addEventListener('keydown', (event) => { if (event.key !== 'Escape') return; const displayOpen = this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') === 'true'; const radioOpen = this._cockpitRadioToggleBtn?.getAttribute('aria-expanded') === 'true'; if (!displayOpen && !radioOpen) return; const nestedPanel = event.target?.closest?.('.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)'); if (nestedPanel) return; event.preventDefault(); event.stopImmediatePropagation(); const kind = displayOpen ? 'display' : 'radio'; const disclosure = displayOpen ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn; const escapedFromDisclosure = event.target === disclosure || disclosure?.contains?.(event.target); setCockpitDisclosure(kind, false, { returnFocus: !escapedFromDisclosure }); if (escapedFromDisclosure) disclosure?.blur?.(); }, { capture: true, signal: this._radioTunerAbort.signal }); window.addEventListener('gev:cockpit-mode-changed', (event) => { if (event?.detail?.active) return; setCockpitDisclosure('display', false); setCockpitDisclosure('radio', false); }, tunerListenerOptions); window.addEventListener('gev:cockpit-signal-expanded', () => { setCockpitDisclosure('display', false); }, tunerListenerOptions); window.addEventListener('gev:cockpit-context-expanded', () => { this.setPanelCollapsed('data-panel', true); }, tunerListenerOptions); this._radioFilter?.addEventListener('change', () => { const presentation = radioLayer.getUIState(); if (!presentation.presentationActive) { this._radioFilter.value = presentation.filter; return; } if (this._radioTunerDragging) finishTuner(false); if (!this._dataManager?.setLayerParams('radio', { filter: this._radioFilter.value, }, { origin: 'user' })) { this._radioFilter.value = radioLayer.getUIState().filter; return; } this._radioTunerBandPinnedForNavigation = false; this._radioTunerPool = []; refreshTunerBand({ force: true }); }); this._radioPrevBtn?.addEventListener('click', () => cycleRadio(-1)); this._radioNextBtn?.addEventListener('click', () => cycleRadio(1)); this._radioPlayBtn?.addEventListener('click', () => void radioLayer.togglePlayback({ origin: 'user' })); this._radioStopBtn?.addEventListener('click', () => radioLayer.stopPlayback({ origin: 'user' })); this._radioVolume?.addEventListener('input', () => { const value = Number(this._radioVolume.value); if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${value}%`; this._dataManager?.setLayerParams('radio', { volume: value / 100 }, { origin: 'user' }); }); this._contextRadioMiniPrevBtn?.addEventListener('click', () => cycleRadio(-1)); this._contextRadioMiniNextBtn?.addEventListener('click', () => cycleRadio(1)); this._contextRadioMiniPlayBtn?.addEventListener('click', () => void radioLayer.togglePlayback({ origin: 'user' })); // Cockpit owns the Cesium camera even though it intentionally clears // viewer.trackedEntity. Station changes must never start the map-view // rotation/fallback flights that would compete with its preUpdate pose. this._cockpitRadioPrevBtn?.addEventListener('click', () => cycleRadio(-1, { rotate: false })); this._cockpitRadioNextBtn?.addEventListener('click', () => cycleRadio(1, { rotate: false })); this._cockpitRadioPlayBtn?.addEventListener('click', () => void radioLayer.togglePlayback({ origin: 'user' })); this._contextRadioMiniVolume?.addEventListener('input', () => { const value = Number(this._contextRadioMiniVolume.value); if (this._contextRadioMiniVolumeValue) this._contextRadioMiniVolumeValue.textContent = `${value}%`; this._dataManager?.setLayerParams('radio', { volume: value / 100 }, { origin: 'user' }); }); this._cockpitRadioVolume?.addEventListener('input', () => { const value = Number(this._cockpitRadioVolume.value); if (this._cockpitRadioVolumeValue) this._cockpitRadioVolumeValue.textContent = `${value}%`; this._dataManager?.setLayerParams('radio', { volume: value / 100 }, { origin: 'user' }); }); const updateTunerFromPointer = (event) => { const rect = this._radioTunerSlider?.getBoundingClientRect(); if (!rect || !this._radioTunerStations.length) return false; const position = radioTunerPointerPosition( event.clientX, rect.left, rect.width, this._radioTunerStations.length, ); if (position.stationIndex > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; else if (position.stationIndex < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; this._radioTunerLastSlot = position.stationIndex; this._radioTunerCoordinate = position.coordinate; this._radioTunerSlider.value = String(position.stationIndex); tunerPreview({ coordinate: position.coordinate }); return true; }; this._radioTunerSlider?.addEventListener('pointerdown', (event) => { if (!beginTuner()) return; this._radioTunerPointerId = event.pointerId; this._radioTunerSlider.focus({ preventScroll: true }); try { this._radioTunerSlider.setPointerCapture(event.pointerId); } catch { /* capture is best effort */ } updateTunerFromPointer(event); event.preventDefault(); }, tunerListenerOptions); this._radioTunerSlider?.addEventListener('pointermove', (event) => { if (!this._radioTunerDragging || this._radioTunerPointerId !== event.pointerId) return; updateTunerFromPointer(event); event.preventDefault(); }, tunerListenerOptions); this._radioTunerSlider?.addEventListener('input', () => { if (this._radioTunerPointerId !== null || this._radioTunerKeyboardKey) return; if (!this._radioTunerDragging && !beginTuner()) return; const inputSlot = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length); if (inputSlot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; else if (inputSlot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; this._radioTunerLastSlot = inputSlot.slot; this._radioTunerCoordinate = inputSlot.stationIndex; tunerPreview({ coordinate: this._radioTunerCoordinate }); }, tunerListenerOptions); this._radioTunerSlider?.addEventListener('change', () => { if (this._radioTunerPointerId === null && !this._radioTunerKeyboardKey) finishTuner(true); }, tunerListenerOptions); this._radioTunerSlider?.addEventListener('pointerup', (event) => { if (this._radioTunerPointerId !== event.pointerId) return; updateTunerFromPointer(event); finishTuner(true); try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ } event.preventDefault(); }, tunerListenerOptions); this._radioTunerSlider?.addEventListener('pointercancel', (event) => { if (this._radioTunerPointerId !== null && this._radioTunerPointerId !== event.pointerId) return; try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ } finishTuner(false); }, tunerListenerOptions); this._radioTunerSlider?.addEventListener('lostpointercapture', (event) => { if (this._radioTunerDragging && this._radioTunerPointerId === event.pointerId) finishTuner(false); }, tunerListenerOptions); this._radioTunerSlider?.addEventListener('keydown', (event) => { if (event.key === 'Escape' && this._radioTunerDragging) { event.preventDefault(); finishTuner(false); return; } if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) return; if (!this._radioTunerDragging && !beginTuner()) return; event.preventDefault(); this._radioTunerKeyboardKey = event.key; const max = Math.max(0, this._radioTunerStations.length - 1); const current = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length).slot; const page = Math.max(1, Math.round(max / 10)); const next = event.key === 'Home' ? 0 : event.key === 'End' ? max : event.key === 'PageUp' ? current + page : event.key === 'PageDown' ? current - page : current + (event.key === 'ArrowRight' ? 1 : -1); const slot = radioTunerSlot(next, this._radioTunerStations.length); if (slot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; else if (slot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; this._radioTunerLastSlot = slot.slot; this._radioTunerCoordinate = slot.stationIndex; this._radioTunerSlider.value = String(slot.slot); tunerPreview({ coordinate: this._radioTunerCoordinate }); }, tunerListenerOptions); this._radioTunerSlider?.addEventListener('keyup', (event) => { if (!this._radioTunerKeyboardKey || event.key !== this._radioTunerKeyboardKey) return; event.preventDefault(); finishTuner(true); }, tunerListenerOptions); this._radioTunerSlider?.addEventListener('blur', () => finishTuner(true), tunerListenerOptions); const releaseNavigationBand = () => { this._radioTunerBandPinnedForNavigation = false; }; this.viewer?.canvas?.addEventListener('pointerdown', releaseNavigationBand, tunerListenerOptions); this.viewer?.canvas?.addEventListener('wheel', releaseNavigationBand, tunerListenerOptions); // The directory order is catalog/filter authority, not camera authority. // Globe motion therefore never rebuilds or re-ranks the frequency band. this._radioTunerCameraRemove?.(); this._radioTunerCameraRemove = null; this._radioSelectedHandler = () => this.setPanelCollapsed('radio-panel', false); document.addEventListener('gev:radio-selected', this._radioSelectedHandler); } /** * Reveal the newly enabled directory and transport inside Context without * moving focus, the page, or the globe. Only the expanded Enable path calls * this helper. * @param {HTMLElement} trigger Initiating Radio Enable button. * @returns {Promise} Whether the internal scroller moved. */ async _revealRadioControlsAfterExplicitEnable(trigger) { const contextPanel = document.getElementById('global-context-panel'); const scroller = contextPanel?.querySelector('.global-context-panel-inner'); const directory = this._radioPanel?.querySelector('.radio-directory-row'); const transport = this._radioPanel?.querySelector('.radio-transport'); if (!contextPanel || contextPanel.classList.contains('collapsed') || !scroller || !directory || !transport || !this._radioState?.enabled) return false; await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); if (!this._radioState?.enabled || !trigger?.isConnected) return false; const viewport = scroller.getBoundingClientRect(); const directoryRect = directory.getBoundingClientRect(); const transportRect = transport.getBoundingClientRect(); const margin = 10; const minimum = scroller.scrollTop + transportRect.bottom - (viewport.bottom - margin); const maximum = scroller.scrollTop + directoryRect.top - (viewport.top + margin); const desired = minimum <= maximum ? Math.min(Math.max(scroller.scrollTop, minimum), maximum) : minimum; const next = Math.min( Math.max(0, scroller.scrollHeight - scroller.clientHeight), Math.max(0, desired), ); if (Math.abs(next - scroller.scrollTop) < 1) return false; const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' }); return true; } /** * Bring the embedded Radio section into the expanded Context scroller. * This never changes Radio power, playback, selection, or Context mode. * @param {{focusTarget?: HTMLElement|null}} [options] * @returns {Promise} Whether the internal scroller moved. */ async _revealRadioPanelInsideContext({ focusTarget = null } = {}) { const contextPanel = document.getElementById('global-context-panel'); const scroller = contextPanel?.querySelector('.global-context-panel-inner'); if (!contextPanel || contextPanel.classList.contains('collapsed') || !scroller || !this._radioPanel || this._radioPanel.classList.contains('collapsed')) return false; await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); if (contextPanel.classList.contains('collapsed') || this._radioPanel.classList.contains('collapsed')) return false; const viewport = scroller.getBoundingClientRect(); const radioRect = this._radioPanel.getBoundingClientRect(); const desired = scroller.scrollTop + radioRect.top - viewport.top - 10; const next = Math.min( Math.max(0, scroller.scrollHeight - scroller.clientHeight), Math.max(0, desired), ); const moved = Math.abs(next - scroller.scrollTop) >= 1; if (moved) { const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' }); } focusTarget?.focus?.({ preventScroll: true }); return moved; } /** Keep the Context header Radio shortcut truthful for its current route. */ _syncContextRadioLauncherState() { if (!this._contextRadioToggleBtn) return; const contextPanel = document.getElementById('global-context-panel'); const contextExpanded = Boolean(contextPanel && !contextPanel.classList.contains('collapsed')); if (contextExpanded) { const radioExpanded = Boolean(this._radioPanel && !this._radioPanel.classList.contains('collapsed')); this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel'); this._contextRadioToggleBtn.setAttribute('aria-expanded', String(radioExpanded)); const label = radioExpanded ? 'Go to expanded Radio section' : 'Expand Radio section in Context'; this._contextRadioToggleBtn.setAttribute('aria-label', label); this._contextRadioToggleBtn.title = label; return; } const compactOpen = Boolean(this._contextRadioDock?.classList.contains('disclosure-open')); this._contextRadioToggleBtn.setAttribute('aria-controls', 'context-radio-mini'); this._contextRadioToggleBtn.setAttribute('aria-expanded', String(compactOpen)); const action = compactOpen ? 'Close' : 'Open'; this._contextRadioToggleBtn.setAttribute('aria-label', `${action} compact Radio controls`); this._contextRadioToggleBtn.title = `${action} compact Radio controls`; } /** Render Radio state without making playback or Context decisions. */ _renderRadioState(state) { if (!state || !this._radioPanel) return; const lifecycle = this._dataManager?.getLayerLifecycleState?.('radio') || null; const lifecycleState = lifecycle?.lifecycleState || (state.enabled ? 'enabled' : 'disabled'); state = { ...state, enabled: lifecycle ? lifecycle.enabled : state.enabled, lifecycleState, lifecycleUncertain: lifecycle?.uncertain || false, }; this._radioState = state; const enabled = Boolean(state.enabled); const transitioning = lifecycleState === 'enabling' || lifecycleState === 'disabling'; const uncertain = Boolean(state.lifecycleUncertain); const interactive = enabled && !transitioning && !uncertain; const selected = state.selected || null; const hasStations = state.filteredCount > 0; const activePlayback = ['playing', 'buffering'].includes(state.audioState); document.getElementById('title-bar')?.classList.toggle('radio-broadcasting', state.audioState === 'playing'); this._radioPanel.classList.toggle('radio-enabled', enabled); this._radioPanel.classList.toggle('lifecycle-uncertain', uncertain); this._contextRadioDock?.classList.toggle('active', enabled); if (this._contextRadioToggleBtn) { this._contextRadioToggleBtn.classList.toggle('active', enabled); } this._syncContextRadioLauncherState(); this._radioLayerState?.classList.toggle('active', enabled); if (this._radioLayerState) { this._radioLayerState.textContent = transitioning ? lifecycleState.toUpperCase() : (uncertain ? 'UNCERTAIN' : (state.loading ? 'SYNC' : (enabled ? `${state.filteredCount}/${state.stationCount}` : 'OFF'))); } if (this._radioEnableBtn) { this._radioEnableBtn.classList.toggle('active', enabled); this._radioEnableBtn.setAttribute('aria-pressed', String(enabled)); this._radioEnableBtn.textContent = transitioning ? lifecycleState.toUpperCase() : (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); this._radioEnableBtn.setAttribute( 'aria-label', uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, ); this._radioEnableBtn.disabled = false; this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning)); this._radioEnableBtn.setAttribute('aria-busy', String(transitioning)); } if (this._contextRadioMiniEnableBtn) { this._contextRadioMiniEnableBtn.classList.toggle('active', enabled); this._contextRadioMiniEnableBtn.setAttribute('aria-pressed', String(enabled)); this._contextRadioMiniEnableBtn.textContent = transitioning ? lifecycleState.toUpperCase() : (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); this._contextRadioMiniEnableBtn.setAttribute( 'aria-label', uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, ); this._contextRadioMiniEnableBtn.disabled = false; this._contextRadioMiniEnableBtn.setAttribute('aria-disabled', String(transitioning)); this._contextRadioMiniEnableBtn.setAttribute('aria-busy', String(transitioning)); } if (this._cockpitRadioEnableBtn) { this._cockpitRadioEnableBtn.classList.toggle('active', enabled); this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled)); this._cockpitRadioEnableBtn.textContent = transitioning ? lifecycleState.toUpperCase() : (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); this._cockpitRadioEnableBtn.setAttribute( 'aria-label', uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, ); this._cockpitRadioEnableBtn.disabled = false; this._cockpitRadioEnableBtn.setAttribute('aria-disabled', String(transitioning)); this._cockpitRadioEnableBtn.setAttribute('aria-busy', String(transitioning)); } if (this._radioFilter) { const prior = state.filter || 'all'; const categorySignature = state.categories .map((category) => `${category.id}:${category.count}:${category.color}`) .join('|'); if (categorySignature !== this._radioCategorySignature) { this._radioFilter.replaceChildren(...state.categories.map((category) => { const option = document.createElement('option'); option.value = category.id; option.textContent = `● ${category.label} (${category.count})`; option.dataset.radioColor = category.color; option.style.color = category.color; option.setAttribute('aria-label', `${category.label} (${category.count})`); return option; })); this._radioCategorySignature = categorySignature; } this._radioFilter.value = prior; const activeCategory = state.categories.find((category) => category.id === prior); this._radioFilter.style.color = activeCategory?.color || ''; this._radioFilter.disabled = !interactive || !state.stationCount; } const tunerAvailable = interactive && state.filteredCount > 0; if (this._radioTuner) this._radioTuner.hidden = !tunerAvailable; if (this._radioTunerSlider) this._radioTunerSlider.disabled = !tunerAvailable; if (this._radioTunerBandLabel) { const activeCategory = state.categories.find((category) => category.id === state.filter); this._radioTunerBandLabel.textContent = state.filter === 'all' ? 'DIRECTORY BAND' : `${String(activeCategory?.label || state.filter).toUpperCase()} BAND`; } this._radioTuner?.classList.toggle('is-static', Boolean(state.tuningStatic)); if (tunerAvailable) this._refreshRadioTunerBand?.(); if (!tunerAvailable && this._radioTunerDragging) { this._radioTunerDragging = false; this._radioTunerDragSnapshot = null; this._radioTunerStations = []; this._radioTuner?.classList.remove('is-static', 'is-dragging'); } if (!tunerAvailable) { this._radioTunerStations = []; this._radioTunerPool = []; this._radioTunerBandSignature = ''; this._radioTunerSelectedId = null; } if (this._radioStationName) this._radioStationName.textContent = selected?.name || 'NO STATION SELECTED'; if (this._radioStationMeta) { const place = selected ? [selected.state, selected.countryCode].filter(Boolean).join(' · ') : ''; const signal = selected ? [selected.codec, selected.bitrate ? `${selected.bitrate} kbps` : ''].filter(Boolean).join(' · ') : ''; this._radioStationMeta.textContent = selected ? [place, signal].filter(Boolean).join(' / ') || 'Directory metadata only' : (state.loading ? 'Loading station directory…' : 'Choose a globe marker or use next.'); } if (this._radioStationTags) { const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : []; this._radioStationTags.textContent = tags.length ? `TAGS · ${tags.join(' · ')}` : ''; } if (this._radioStationHomepage) { const homepage = selected?.homepage || ''; this._radioStationHomepage.hidden = !homepage; if (homepage) this._radioStationHomepage.href = homepage; else this._radioStationHomepage.removeAttribute('href'); } if (this._radioPrevBtn) this._radioPrevBtn.disabled = !interactive || !hasStations; if (this._radioNextBtn) this._radioNextBtn.disabled = !interactive || !hasStations; if (this._contextRadioMiniPrevBtn) this._contextRadioMiniPrevBtn.disabled = !interactive || !hasStations; if (this._contextRadioMiniNextBtn) this._contextRadioMiniNextBtn.disabled = !interactive || !hasStations; if (this._cockpitRadioPrevBtn) this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations; if (this._cockpitRadioNextBtn) this._cockpitRadioNextBtn.disabled = !interactive || !hasStations; if (this._radioPlayBtn) { const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); this._radioPlayBtn.disabled = !interactive || !hasStations; this._radioPlayBtn.classList.toggle('active', activePlayback); this._radioPlayBtn.textContent = action.toUpperCase(); this._radioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); } if (this._contextRadioMiniPlayBtn) { const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); this._contextRadioMiniPlayBtn.disabled = !interactive || !hasStations; this._contextRadioMiniPlayBtn.classList.toggle('active', activePlayback); this._contextRadioMiniPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶'; this._contextRadioMiniPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); this._contextRadioMiniPlayBtn.title = action; } if (this._cockpitRadioPlayBtn) { const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations; this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback); this._cockpitRadioPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶'; this._cockpitRadioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); this._cockpitRadioPlayBtn.title = action; } if (this._radioStopBtn) this._radioStopBtn.disabled = !interactive || state.audioState === 'stopped'; if (this._radioVolume) this._radioVolume.disabled = !interactive; if (this._radioVolume && document.activeElement !== this._radioVolume) { this._radioVolume.value = String(Math.round(state.volume * 100)); if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; } if (this._contextRadioMiniVolume && document.activeElement !== this._contextRadioMiniVolume) { this._contextRadioMiniVolume.value = String(Math.round(state.volume * 100)); } if (this._contextRadioMiniVolume) this._contextRadioMiniVolume.disabled = !interactive; if (this._contextRadioMiniVolumeValue) { this._contextRadioMiniVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; } if (this._cockpitRadioVolume && document.activeElement !== this._cockpitRadioVolume) { this._cockpitRadioVolume.value = String(Math.round(state.volume * 100)); } if (this._cockpitRadioVolume) this._cockpitRadioVolume.disabled = !interactive; if (this._cockpitRadioVolumeValue) { this._cockpitRadioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; } if (this._contextRadioMiniStation) { this._contextRadioMiniStation.textContent = uncertain ? 'RADIO STATE UNCERTAIN' : (selected?.name || (state.loading ? 'SYNCING DIRECTORY' : 'RADIO READY')); } if (this._cockpitRadioStation) { this._cockpitRadioStation.textContent = uncertain ? 'UNCERTAIN' : (selected?.name || (state.loading ? 'SYNCING' : 'READY')); } if (this._radioPlaybackState) { const catalogSuffix = state.degraded ? (state.stale ? ' · stale/degraded directory' : ' · degraded directory') : (state.stale ? ' · stale directory' : ''); const outsideFilter = selected && state.selectedIndex < 0 ? ' · outside current filter' : ''; const messages = { stopped: enabled ? 'Ready — playback starts only from your action' : 'Radio off', loading: 'Connecting directly to broadcaster…', buffering: 'Buffering broadcaster stream…', playing: `Playing ${selected?.name || 'station'}`, paused: `Paused ${selected?.name || 'station'}`, error: state.audioError || 'Broadcaster stream unavailable', }; const voiceSuffix = state.voiceDucked ? ' · muted during voice interaction' : (state.voiceRestoring ? ' · restoring volume after voice' : ''); const tuningSuffix = state.tuningAwaitingStationId ? (state.audioState === 'error' ? ' · static indicates no broadcaster audio' : ' · tuning static until broadcaster starts') : ''; const unavailable = state.tuningUnavailableStationId ? 'Station unavailable after directory refresh — choose another channel' : null; const lifecycleMessage = transitioning ? (lifecycleState === 'enabling' ? 'Radio is enabling…' : 'Radio is disabling…') : null; const uncertainMessage = uncertain ? 'Radio lifecycle is uncertain — use Enable or Disable to reconcile' : null; this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || 'Ready'}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`; this._radioPlaybackState.classList.toggle('error', Boolean(uncertainMessage || unavailable || state.error || state.audioState === 'error')); } if ( !enabled && !transitioning && !this._preservePanelStateDuringLayerClear && !this._radioPanel.classList.contains('collapsed') ) { this.setPanelCollapsed('radio-panel', true); } this._scheduleRightPanelLayout(); } /** * Activates an explicit CCTV target, then releases tracking before its camera * flight. Cockpit mode keeps tracking and suppresses only the flight. * @param {Function} activate CCTV target activation returning its camera ID. * @param {Function} focus CCTV camera flight receiving the activated ID. * @returns {*} Focus operation result. */ _runExplicitCctvFocus(activate, focus) { if (this._disposed) return false; const cameraId = activate(); if (!cameraId) return false; return this._runExplicitNavigation('camera', () => focus(cameraId)); } /** * Wires up all CCTV panel controls: enable/disable, nearest/prev/next camera, * camera select dropdown, focus, coverage, auto-hop, projection, * manual calibration sliders, and save/reset buttons. * @returns {void} */ _initCctvPanel() { if (!this._cctvPanel) return; this._cctvEnableBtn?.addEventListener('click', async () => { await this._toggleCctvEnabled(); }); this._cctvNearestBtn?.addEventListener('click', async () => { if (!await this._toggleCctvEnabled(true)) return; this._runExplicitCctvFocus( () => cctvLayer.focusNearest({ focus: false }), (cameraId) => cctvLayer.focusCamera(cameraId, 1.8), ); }); this._cctvPrevBtn?.addEventListener('click', async () => { if (!await this._toggleCctvEnabled(true)) return; this._runExplicitCctvFocus( () => cctvLayer.cycleCamera(-1), (cameraId) => cctvLayer.focusCamera(cameraId, 1.4), ); }); this._cctvNextBtn?.addEventListener('click', async () => { if (!await this._toggleCctvEnabled(true)) return; this._runExplicitCctvFocus( () => cctvLayer.cycleCamera(1), (cameraId) => cctvLayer.focusCamera(cameraId, 1.4), ); }); this._cctvSelect?.addEventListener('change', async () => { const cameraId = this._cctvSelect.value; if (!cameraId) return; if (!await this._toggleCctvEnabled(true)) return; // Picking a camera from the dropdown flies to it. The catalog spans // three metros, so a bare selection used to leave the view in the old // city with a camera active thousands of km away. this._runExplicitCctvFocus( () => (cctvLayer.selectCamera(cameraId) ? cameraId : null), (selectedId) => cctvLayer.focusCamera(selectedId, 2.2), ); this._dataManager?.setLayerParams('cctv', { selectedCameraId: cameraId }, { origin: 'user' }); }); this._cctvFocusBtn?.addEventListener('click', async () => { const selected = this._cctvState?.activeCameraId || this._cctvSelect?.value; if (!selected) return; if (!await this._toggleCctvEnabled(true)) return; this._runExplicitCctvFocus( () => selected, (cameraId) => cctvLayer.focusCamera(cameraId, 1.9), ); this._dataManager?.setLayerParams('cctv', { selectedCameraId: selected }, { origin: 'user' }); }); this._cctvCoverageBtn?.addEventListener('click', () => { const current = this._cctvState?.coverageMode || (this._cctvState?.showCoverage ? 'on' : 'off'); const next = current === 'off' ? 'on' : current === 'on' ? 'viewshed' : 'off'; this._dataManager?.setLayerParams('cctv', { coverageMode: next }, { origin: 'user' }); }); this._cctvAutoHopBtn?.addEventListener('click', () => { const current = !!this._cctvState?.autoHop; this._dataManager?.setLayerParams('cctv', { autoHop: !current }, { origin: 'user' }); }); this._cctvProjectionBtn?.addEventListener('click', () => { const current = this._cctvState?.showProjection !== false; this._dataManager?.setLayerParams('cctv', { showProjection: !current }, { origin: 'user' }); }); this._cctvAdjustBtn?.addEventListener('click', () => { const current = !!this._cctvState?.calibrationMode; this._dataManager?.setLayerParams('cctv', { calibrationMode: !current }, { origin: 'user' }); }); // Click-to-edit pose readout: each chip swaps to a number input; Enter or // blur commits (converted to a calibration offset against basePose), // Escape cancels. Delegated so re-renders never re-bind. this._cctvCalReadout?.addEventListener('click', (event) => { const chip = event.target.closest?.('.cctv-cal-value'); if (!chip || chip.disabled || chip.querySelector('input')) return; this._beginCctvCalValueEdit(chip); }); this._cctvCalibSaveBtn?.addEventListener('click', () => { const cameraId = this._activeCctvCameraId(); if (!cameraId || !this._dataManager) return; this._dataManager.setLayerParams('cctv', { selectedCameraId: cameraId, calibration: { cameraId, save: true }, }, { origin: 'user' }); this._showToast('CCTV calibration saved'); }); this._cctvCalibResetBtn?.addEventListener('click', () => { this._resetCctvCalibration(); }); this._renderCctvState(null); this._syncCctvPanelViewport(); } /** * Returns the currently active CCTV camera ID from state or the select dropdown. * @returns {string} Camera ID, or empty string if none. */ _activeCctvCameraId() { return this._cctvState?.activeCameraId || this._cctvSelect?.value || ''; } /** * Clears the preview and invalidates any in-flight preload. * @returns {void} */ _clearCctvFrame() { this._cctvFrameRequestToken += 1; this._cctvFramePreloader = null; if (this._cctvFrame) { this._cctvFrame.classList.remove('active'); this._cctvFrame.removeAttribute('src'); this._cctvFrame.dataset.cameraId = ''; this._cctvFrame.dataset.currentSrc = ''; this._cctvFrame.dataset.loading = ''; this._cctvFrame.dataset.error = ''; } this._cctvFrameWrap?.classList.remove('loading', 'has-frame'); } /** * Fetches a replacement frame OFF-DOM and assigns it to the live element * only once it has decoded. * * The live is never pointed at an unresolved URL. A completed * preload is already in the HTTP cache, so assigning `src` swaps in a * single paint — the browser's own atomic behavior. A slow or failed * fetch never reaches the element at all, so settled pixels survive. * * (A two-slot crossfade was tried and reverted: with no z-index the slots * paint in DOM order, so promotion was asymmetric and yanked the visible * layer once per refresh — a flicker on every feed cycle. Measured against * main, which never blanked on a successful refresh in the first place.) * * @param {string} src * @param {string} cameraId * @param {boolean} cameraChanged * @returns {void} */ _queueCctvFrame(src, cameraId, cameraChanged) { if (!this._cctvFrame || !src) return; if (cameraChanged) { // A different camera gets an honest acquisition state. Never retain // the prior camera's pixels under the newly selected metadata. this._cctvFrame.classList.remove('active'); this._cctvFrame.removeAttribute('src'); this._cctvFrameWrap?.classList.remove('has-frame'); } const token = ++this._cctvFrameRequestToken; this._cctvFrame.dataset.cameraId = cameraId; this._cctvFrame.dataset.currentSrc = src; this._cctvFrame.dataset.loading = 'true'; this._cctvFrame.dataset.error = ''; this._cctvFrameWrap?.classList.toggle( 'loading', !this._cctvFrameWrap?.classList.contains('has-frame') ); const preloader = new Image(); this._cctvFramePreloader = preloader; preloader.onload = () => this._settleCctvFrame(token, src, true); preloader.onerror = () => this._settleCctvFrame(token, src, false); preloader.src = src; } /** * Commits a decoded frame to the live element, or records the failure * without disturbing whatever is already on screen. * @param {number} token - Request token; a stale one is ignored. * @param {string} src * @param {boolean} ok * @returns {void} */ _settleCctvFrame(token, src, ok) { if (!this._cctvFrame || token !== this._cctvFrameRequestToken) return; this._cctvFramePreloader = null; this._cctvFrame.dataset.loading = ''; this._cctvFrameWrap?.classList.remove('loading'); const syncBadge = () => this._syncCctvSourceBadge( this._cctvState?.activeCamera, !!this._cctvState?.enabled && !!this._dataManager?.isEnabled('cctv') ); if (!ok) { // Leave the element untouched — a settled frame stays on screen. this._cctvFrame.dataset.error = 'true'; syncBadge(); return; } this._cctvFrame.dataset.error = ''; this._cctvFrame.src = src; this._cctvFrame.classList.add('active'); this._cctvFrameWrap?.classList.add('has-frame'); syncBadge(); } /** * Keeps the source badge truthful about the visible frame lifecycle. Health * may already be OK while the browser is still decoding the requested image. * @param {object|null} activeCamera * @param {boolean} enabled * @returns {void} */ _syncCctvSourceBadge(activeCamera, enabled) { if (!this._cctvSourceBadge) return; if (!enabled || !activeCamera) { this._cctvSourceBadge.textContent = 'SOURCE · UNKNOWN'; this._cctvSourceBadge.dataset.frameState = 'idle'; return; } const hasDisplayedFrame = this._cctvFrameWrap?.classList.contains('has-frame'); if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) { this._cctvSourceBadge.textContent = 'FRAME · LOADING'; this._cctvSourceBadge.dataset.frameState = 'loading'; return; } if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) { this._cctvSourceBadge.textContent = 'FRAME · UNAVAILABLE'; this._cctvSourceBadge.dataset.frameState = 'error'; return; } const kind = String(activeCamera.sourceKind || activeCamera.feedType || 'unknown').toUpperCase(); const status = String(activeCamera.sourceStatus || 'unknown').toUpperCase(); this._cctvSourceBadge.textContent = `${kind} · ${status}`; this._cctvSourceBadge.dataset.frameState = 'ready'; } /** * Resets calibration for the active CCTV camera to its server defaults. * @returns {void} */ _resetCctvCalibration() { const cameraId = this._activeCctvCameraId(); if (!cameraId || !this._dataManager) return; this._dataManager.setLayerParams('cctv', { selectedCameraId: cameraId, calibration: { cameraId, reset: true, }, }, { origin: 'user' }); this._showToast('CCTV calibration reset'); } /** * Swaps a readout chip's text for an inline number input. Enter/blur commits * (converted to a calibration offset patch), Escape cancels. The next state * re-render restores the chip text either way. * @param {HTMLButtonElement} chip - The clicked `.cctv-cal-value` element. * @returns {void} */ _beginCctvCalValueEdit(chip) { const field = CCTV_CAL_FIELDS[chip.dataset.calField]; const activeCamera = this._cctvState?.activeCamera; if (!field || !activeCamera?.basePose) return; const startValue = field.get(activeCamera); const input = document.createElement('input'); input.type = 'number'; input.step = field.decimals > 0 ? '0.1' : '1'; input.value = Number(startValue).toFixed(field.decimals); input.className = 'cctv-cal-input'; chip.textContent = `${field.label} `; chip.appendChild(input); input.focus(); input.select(); let finished = false; const finish = (commit) => { if (finished) return; finished = true; const typed = parseFloat(input.value); input.remove(); if (commit && Number.isFinite(typed)) { const cameraId = this._activeCctvCameraId(); if (cameraId && this._dataManager) { this._dataManager.setLayerParams('cctv', { selectedCameraId: cameraId, calibration: { cameraId, patch: field.toPatch(typed, activeCamera.basePose) }, }, { origin: 'user' }); return; // re-render restores the chip text from fresh state } } this._syncCctvCalReadout(!!this._cctvState?.enabled, this._cctvState?.activeCamera || null); }; input.addEventListener('keydown', (event) => { if (event.key === 'Enter') finish(true); else if (event.key === 'Escape') finish(false); event.stopPropagation(); }); input.addEventListener('blur', () => finish(true)); input.addEventListener('click', (event) => event.stopPropagation()); } /** * Synchronizes the pose readout chips, ADJUST button, and SAVE/RESET * disabled states with the active camera. * @param {boolean} enabled - Whether the CCTV layer is currently enabled. * @param {object|null} activeCamera - The active camera data object (may be null). * @returns {void} */ _syncCctvCalReadout(enabled, activeCamera) { const canCalibrate = !!enabled && !!activeCamera; if (this._cctvAdjustBtn) { const adjustOn = !!this._cctvState?.calibrationMode; this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate); this._cctvAdjustBtn.textContent = adjustOn ? 'ADJUST ON' : 'ADJUST'; this._cctvAdjustBtn.disabled = !canCalibrate; } if (this._cctvCalReadout) { for (const chip of this._cctvCalReadout.querySelectorAll('.cctv-cal-value')) { if (chip.querySelector('input')) continue; // an edit is in flight — don't clobber const field = CCTV_CAL_FIELDS[chip.dataset.calField]; if (!field) continue; const value = canCalibrate ? field.get(activeCamera) : null; chip.textContent = Number.isFinite(value) ? `${field.label} ${Number(value).toFixed(field.decimals)}${field.unit}` : `${field.label} --`; chip.disabled = !canCalibrate; } } for (const el of [this._cctvCalibSaveBtn, this._cctvCalibResetBtn]) { if (el) el.disabled = !canCalibrate; } } /** * 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._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._cctvState?.activeCameraId, activate: () => cctvLayer.focusNearest({ focus: false }), fly: (cameraId) => this._runExplicitCctvFocus( () => cameraId, (selectedId) => cctvLayer.focusCamera(selectedId, 1.6), ), }); return true; } /** * Maps a `deriveCalBadge` value (cctv.js) to its panel copy. Single source * of truth for CAL-badge casing — used by both the quality chip and the * meta line so the two never drift onto different label conventions. * @param {'calibrated'|'curated'|'raw-prior'|null} badge - Badge state from cctv.js. * @returns {string} Display label, or '--' when there is no active camera. */ _calBadgeLabel(badge) { switch (badge) { case 'calibrated': return 'CALIBRATED'; case 'curated': return 'CURATED'; case 'raw-prior': return 'RAW PRIOR'; default: return '--'; } } /** * Full re-render of the CCTV panel UI from a CCTV layer state snapshot. * Updates enable button, camera select dropdown, navigation buttons, * coverage/auto-hop/projection toggles, quality chip, source badge, * metadata line, frame image, calibration controls, and summary text. * @param {object|null} state - CCTV layer UI state, or null to render empty. * @returns {void} */ _renderCctvState(state) { this._cctvState = state || null; const cameras = state?.cameras || []; const enabled = !!state?.enabled && !!this._dataManager?.isEnabled('cctv'); const activeId = state?.activeCameraId || ''; const activeCamera = state?.activeCamera || null; // Auto-expand the panel when the active camera CHANGES to a new non-null // id while the layer is enabled. Covers click-on-globe, panel controls, // and voice (selectCamera/cycleCamera/focusNearest all notify through // this subscription). The last-seen guard keeps routine notifications // from re-expanding a panel the user deliberately collapsed, and timed // auto-hop transitions only expand on the first activation so the panel // does not pop open on every hop. const effectiveActiveId = enabled ? (activeId || null) : null; const isFirstActivation = this._lastSeenCctvActiveId === null; if (effectiveActiveId && effectiveActiveId !== this._lastSeenCctvActiveId && (!state?.autoHop || isFirstActivation)) { this.setPanelCollapsed('cctv-panel', false, { explicit: Boolean(state?.explicitSelection) }); } this._lastSeenCctvActiveId = effectiveActiveId; this._updateCctvSyncChip(state?.loading, enabled); if (this._cctvEnableBtn) { this._cctvEnableBtn.classList.toggle('active', enabled); this._cctvEnableBtn.textContent = enabled ? 'CCTV ON' : 'CCTV OFF'; } if (this._cctvSelect) { const shouldRebuild = this._cctvSelect.options.length !== cameras.length || cameras.some((cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id); if (shouldRebuild) { this._cctvSelect.innerHTML = ''; for (const camera of cameras) { const option = document.createElement('option'); option.value = camera.id; option.textContent = `${camera.city} · ${camera.name}`; this._cctvSelect.appendChild(option); } } this._cctvSelect.disabled = !enabled || cameras.length === 0; if (activeId && Array.from(this._cctvSelect.options).some((opt) => opt.value === activeId)) { this._cctvSelect.value = activeId; } else if (!activeId) { this._cctvSelect.selectedIndex = -1; } } for (const btn of [this._cctvNearestBtn, this._cctvPrevBtn, this._cctvNextBtn]) { if (!btn) continue; btn.disabled = !enabled || cameras.length === 0; } if (this._cctvFocusBtn) { this._cctvFocusBtn.disabled = !enabled || cameras.length === 0 || !activeId; } if (this._cctvCoverageBtn) { // Tri-state (viewshed design §3b): off → on (wireframes) → viewshed // (color-coded volumes). The click handler cycles; this renders. const mode = state?.coverageMode || (state?.showCoverage ? 'on' : 'off'); this._cctvCoverageBtn.classList.toggle('active', mode !== 'off'); this._cctvCoverageBtn.textContent = mode === 'viewshed' ? 'VIEWSHED ON' : mode === 'on' ? 'COVERAGE ON' : 'COVERAGE OFF'; this._cctvCoverageBtn.disabled = !enabled; } if (this._cctvAutoHopBtn) { const autoHop = !!state?.autoHop; this._cctvAutoHopBtn.classList.toggle('active', autoHop); this._cctvAutoHopBtn.textContent = autoHop ? 'AUTO HOP ON' : 'AUTO HOP OFF'; this._cctvAutoHopBtn.disabled = !enabled; } if (this._cctvProjectionBtn) { const showProjection = state?.showProjection !== false; this._cctvProjectionBtn.classList.toggle('active', showProjection); this._cctvProjectionBtn.textContent = showProjection ? 'PROJECTION ON' : 'PROJECTION OFF'; this._cctvProjectionBtn.disabled = !enabled; } if (this._cctvQualityChip) { // CAL badge (cctv-v2 design §3b, amended by LOCKED §9.2 — panel-only, // no in-world tint): three states driven by cctv.js's deriveCalBadge, // no client-side scoring math. Casing is unified via _calBadgeLabel so // the chip and the meta line never drift onto different conventions. // Save-gated persistence (viewshed design §3e): unsaved live edits show // EDITED on top of whatever the persisted badge state is — SAVE CAL // promotes to CALIBRATED, RESET CAL clears. const badge = activeCamera?.calBadge || null; const dirty = !!activeCamera?.calDirty; this._cctvQualityChip.textContent = dirty ? 'CAL · EDITED (UNSAVED)' : `CAL · ${this._calBadgeLabel(badge)}`; this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : (badge || ''); } this._syncCctvCalReadout(enabled, activeCamera); if (this._cctvMeta) { if (activeCamera) { const provider = activeCamera.sourceLabel || activeCamera.provider || 'Configured Source'; const statusMsg = activeCamera.sourceMessage ? ` · ${activeCamera.sourceMessage}` : ''; const calBadge = activeCamera.calBadge ? this._calBadgeLabel(activeCamera.calBadge) : ''; const projLabel = state?.showProjection !== false ? 'MONITOR' : 'OFF'; this._cctvMeta.textContent = `${activeCamera.city} · HDG ${Math.round(activeCamera.headingDeg)}° · FOV ${Math.round(activeCamera.fovDeg)}° · RANGE ${Math.round(activeCamera.rangeM)}m · ${projLabel}${calBadge ? ` · ${calBadge}` : ''} · ${provider}${statusMsg}`; } else if (cameras.length > 0) { this._cctvMeta.textContent = enabled ? `${cameras.length} cameras loaded · click a camera to activate` : `${cameras.length} cameras loaded · enable CCTV to activate`; } else { this._cctvMeta.textContent = 'Enable CCTV to load camera intersections'; } } if (this._cctvFrame) { const nextSrc = enabled ? activeCamera?.frameUrl : null; const nextCameraId = enabled ? (activeCamera?.id || '') : ''; const cameraChanged = this._cctvFrame.dataset.cameraId !== nextCameraId; const frameLoading = this._cctvFrame.dataset.loading === 'true'; // A same-camera refresh waits for the current image to settle. Replacing // src every 10 seconds can cancel a slow but healthy decode forever and // leave SNAPSHOT · OK beside a blank/loading preview. Camera changes are // immediate so navigation never waits on the prior camera's request. if (nextSrc && (cameraChanged || (!frameLoading && this._cctvFrame.dataset.currentSrc !== nextSrc))) { this._queueCctvFrame(nextSrc, nextCameraId, cameraChanged); } if (!nextSrc) { this._clearCctvFrame(); } } this._syncCctvSourceBadge(activeCamera, enabled); this._typeCctvSummary(state?.summary || 'Enable CCTV to start camera-linked intelligence summaries.'); } /** * Typewriter-animates CCTV summary text into the summary element. * Skips animation if the text hasn't changed since the last call. * Advances 3 characters per 20ms tick for a fast teletype effect. * @param {string} text - Summary text to display. * @returns {void} */ _typeCctvSummary(text) { if (!this._cctvSummary) return; const nextText = String(text || '').trim() || 'No summary available.'; if (nextText === this._lastCctvSummaryText) return; this._lastCctvSummaryText = nextText; clearInterval(this._cctvSummaryTypingTimer); this._cctvSummary.textContent = ''; let idx = 0; this._cctvSummaryTypingTimer = setInterval(() => { idx += 3; if (idx >= nextText.length) { this._cctvSummary.textContent = nextText; clearInterval(this._cctvSummaryTypingTimer); this._cctvSummaryTypingTimer = null; return; } this._cctvSummary.textContent = nextText.slice(0, idx); }, 20); } /** * 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() { return { mode: this._contextMode || null, active: Boolean(this._contextMode), changing: Boolean(this._contextModeChanging), entering: this._contextModeEntering || null, canContact: !this._contextMode || this._contextMode === 'flights', canMission: !this._contextMode || this._contextMode === 'space-missions', snapshotCaptured: Boolean(this._contextSessionSnapshot), }; } /** * 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}>} */ async setContextMode(mode, { notificationToken = null, signal = null, isCurrent = null, claimVisualAuthority = true, } = {}) { const requestIsCurrent = () => !signal?.aborted && (typeof isCurrent !== 'function' || isCurrent()); const cancellationResult = () => ({ ok: false, action: 'set_context_mode', cancelled: true, error: 'Context request was superseded by a newer voice turn', ...this.getContextModeState(), ...(this._contextTransitionFailedLayerIds?.length ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } : {}), }); if (!requestIsCurrent()) return cancellationResult(); try { if (!mode || mode === 'off') { // Validated explicit transition — Context owns detection, so take the // visual lane before a delayed shared restore can reclaim it. Internal // Cockpit choreography opts out: it is not an operator Context request. if (claimVisualAuthority) this._claimContextVisualAuthority(); const result = await this._selectContextMode(null, { notificationToken, signal }); if (result === null || (!requestIsCurrent() && result !== true)) return cancellationResult(); const state = this.getContextModeState(); return { ok: result === true, action: 'set_context_mode', mode: state.mode, ...state, ...(result === true ? {} : { error: 'Context mode transition did not complete' }), ...(this._contextTransitionFailedLayerIds?.length ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } : {}), }; } const canonical = mode === 'contacts' ? 'flights' : mode; if (!['flights', 'space-missions'].includes(canonical)) { return { ok: false, action: 'set_context_mode', error: `Unknown context mode: ${mode}`, mode: this._contextMode, ...this.getContextModeState(), }; } const priorMode = this._contextMode; // Claimed only after the mode enum validates above, so a rejected request // takes no authority and leaves the shared visual state eligible. Internal // Cockpit choreography opts out: it is not an operator Context request. if (claimVisualAuthority) this._claimContextVisualAuthority(); const transitioned = await this._selectContextMode(canonical, { notificationToken, signal }); if (transitioned === null || (!requestIsCurrent() && transitioned !== true)) return cancellationResult(); const state = this.getContextModeState(); // A cross-mode switch tears the prior mode down before it commits, so a // cancelled or failed switch rests on Context OFF. Say that plainly: // reporting a bare "did not complete" while the operator's Context is // gone is the dishonesty this whole path was fixed for. The state fields // below carry the same verdict, so text and state cannot disagree. const crossModeSwitchLost = transitioned !== true && Boolean(priorMode) && priorMode !== canonical && !state.mode; return { ok: transitioned === true, action: 'set_context_mode', mode: state.mode, ...state, ...(transitioned === true ? {} : { // Named in the operator's vocabulary, not the internal id: this // string is read by the voice model, which takes 'contacts'. error: crossModeSwitchLost ? `Switch to ${contextModeWord(canonical)} did not complete — Context is now off` : 'Context mode transition did not complete', ...(crossModeSwitchLost ? { contextOff: true, priorMode } : {}), }), ...(this._contextTransitionFailedLayerIds?.length ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } : {}), }; } catch (error) { if (!requestIsCurrent()) { return { ...cancellationResult(), ...(Array.isArray(error?.failedLayerIds) ? { failedLayerIds: [...error.failedLayerIds] } : {}), }; } return { ok: false, action: 'set_context_mode', error: error?.message || 'Context mode transition failed', ...(Array.isArray(error?.failedLayerIds) ? { failedLayerIds: [...error.failedLayerIds] } : {}), ...this.getContextModeState(), }; } } /** * 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() { if (this._clearSelectedLayersPromise) return this._clearSelectedLayersPromise; if (!this._dataManager?.clearSelectedLayers) { return Promise.resolve({ targetIds: [], items: [], clearedIds: [], notClearedIds: [] }); } const generation = ++this._contextModeGeneration; const notificationToken = Symbol('clear-selected-layers'); if (this._contextRestoreState) this._contextRestoreState.cancelled = true; this._contextModeChanging = true; this._contextMode = null; this._contextModeEntering = null; this._contextModeEntryIntent = null; this._contextModeReplacementIntent = null; this._contextSessionSnapshot = null; this._contextRestoreState = null; this._preservePanelStateDuringLayerClear = true; this._syncContextModeButtons(); this._userFacingContextNotificationTokens.add(notificationToken); this._clearLayersControl?.setBusy(true); const managerOperation = this._dataManager.clearSelectedLayers({ origin: 'user', notificationToken, }); this._clearSelectedLayersManagerPromise = managerOperation; const operation = managerOperation.then((result) => { if (result.targetIds.length === 0) { this._showToast('No selected data layers'); } else if (result.notClearedIds.length > 0) { this._showToast(`${result.notClearedIds.length} data layer${result.notClearedIds.length === 1 ? '' : 's'} could not be cleared`); } else { this._showToast(`Cleared ${result.clearedIds.length} data layer${result.clearedIds.length === 1 ? '' : 's'}`); } return result; }).catch((error) => { console.warn('[Data] clear selected layers failed', error); this._showToast('Selected data layers could not be cleared'); return { targetIds: [], items: [], clearedIds: [], notClearedIds: [], error, }; }).finally(() => { this._userFacingContextNotificationTokens.delete(notificationToken); if (generation === this._contextModeGeneration) { this._contextModeChanging = false; this._syncContextModeButtons(); } this._clearLayersControl?.setBusy(false); this._preservePanelStateDuringLayerClear = false; this._clearSelectedLayersManagerPromise = null; this._clearSelectedLayersPromise = null; }); this._clearSelectedLayersPromise = operation; return operation; } /** * 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() { const definitions = [ ['hud', this._hudBtn?.closest('.pp-toggle-group')], ['detection', this._detectionBtn?.closest('.pp-toggle-group')], ['parameters', this._sliderPanel], ['models3d', this._models3dBtn?.closest('.pp-toggle-group')], ]; this._cockpitDisplayPortalRecords = definitions.flatMap(([name, group]) => { const slot = this._cockpitDisplayPanel?.querySelector( `[data-cockpit-display-slot="${name}"]`, ); if (!group || !slot || !group.parentNode) return []; const anchor = document.createComment(`cockpit-display-home:${name}`); group.before(anchor); return [{ name, group, slot, anchor }]; }); this._standardDisplayScrollTop = this._ppToggles?.scrollTop || 0; this._cockpitDisplayScrollTop = this._cockpitDisplayPanel?.scrollTop || 0; this._standardDisplayScrollHandler = () => { if (!this._cockpitDisplayPortalActive) { this._standardDisplayScrollTop = this._ppToggles?.scrollTop || 0; } }; this._cockpitDisplayScrollHandler = () => { if (this._cockpitDisplayPortalActive) { this._cockpitDisplayScrollTop = this._cockpitDisplayPanel?.scrollTop || 0; } }; this._ppToggles?.addEventListener('scroll', this._standardDisplayScrollHandler, { passive: true }); this._cockpitDisplayPanel?.addEventListener('scroll', this._cockpitDisplayScrollHandler, { passive: true }); this._cockpitDisplayModeHandler = (event) => { this._setCockpitDisplayPortalActive(event?.detail?.active === true); }; window.addEventListener('gev:cockpit-mode-changed', this._cockpitDisplayModeHandler); this._setCockpitDisplayPortalActive(document.body.classList.contains('cockpit-mode')); } /** * 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) { const nextActive = active === true; if (this._cockpitDisplayPortalActive === nextActive) return; const focusedRecord = this._cockpitDisplayPortalRecords.find((record) => ( record.group.contains(document.activeElement) )); const focusedElement = focusedRecord ? document.activeElement : null; this._displayPortalScrollRestoreOwner = nextActive ? 'cockpit' : 'standard'; this._cockpitDisplayPortalActive = nextActive; for (const record of this._cockpitDisplayPortalRecords) { if (nextActive) { record.slot.append(record.group); } else if (record.anchor.parentNode) { record.anchor.after(record.group); } } this._cockpitDisplayPanel?.classList.toggle('uses-shared-display-controls', nextActive); requestAnimationFrame(() => { if (nextActive && this._cockpitDisplayPanel) { this._cockpitDisplayPanel.scrollTop = this._cockpitDisplayScrollTop; } if (!nextActive && this._ppToggles) { this._ppToggles.scrollTop = this._standardDisplayScrollTop; } focusedElement?.focus?.({ preventScroll: true }); // Portal movement can trigger one more adaptive-layout/clamp pass after // the first frame. Reapply the owning surface's saved position once the // new layout has fully settled. requestAnimationFrame(() => { if (nextActive && this._cockpitDisplayPanel) { this._cockpitDisplayPanel.scrollTop = this._cockpitDisplayScrollTop; } if (!nextActive && this._ppToggles) { this._ppToggles.scrollTop = this._standardDisplayScrollTop; } if (this._displayPortalScrollRestoreOwner === (nextActive ? 'cockpit' : 'standard')) { this._displayPortalScrollRestoreOwner = null; } }); }); this._layoutRightPanels(); this.cockpitView?.scheduleContextLayout(); } /** * 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._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._contextModeGeneration += 1; 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; this._contextModeChanging = true; this._contextMode = null; await this._restoreContextSession(); // 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(); if (this._cockpitDisplayModeHandler) { window.removeEventListener('gev:cockpit-mode-changed', this._cockpitDisplayModeHandler); this._cockpitDisplayModeHandler = null; } this._setCockpitDisplayPortalActive(false); this._ppToggles?.removeEventListener('scroll', this._standardDisplayScrollHandler); this._cockpitDisplayPanel?.removeEventListener('scroll', this._cockpitDisplayScrollHandler); this._standardDisplayScrollHandler = null; this._cockpitDisplayScrollHandler = null; for (const record of this._cockpitDisplayPortalRecords) record.anchor.remove(); this._cockpitDisplayPortalRecords = []; this._dataManagerBeforeDestroyUnsubscribe?.(); this._dataManagerBeforeDestroyUnsubscribe = null; this._dataManagerVisibilityGuardUnsubscribe?.(); this._dataManagerVisibilityGuardUnsubscribe = null; this._dataManagerVisibilityRequestUnsubscribe?.(); this._dataManagerVisibilityRequestUnsubscribe = null; this._dataManagerUnsubscribe?.(); this._dataManagerUnsubscribe = 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; } this._radioUnsubscribe?.(); this._radioUnsubscribe = null; this._radioTunerAbort?.abort(); this._radioTunerAbort = null; this._radioTunerBandPinnedForNavigation = false; this._radioTunerDragSnapshot = null; this._radioTunerPool = []; this._radioTunerCameraRemove?.(); this._radioTunerCameraRemove = null; this._refreshRadioTunerBand = null; document.getElementById('title-bar')?.classList.remove('radio-broadcasting'); radioLayer.endTuning(); if (this._radioSelectedHandler) { document.removeEventListener('gev:radio-selected', this._radioSelectedHandler); this._radioSelectedHandler = null; } destroyTrackedReadout(); destroyDetection(); destroyWorldOverlay(); this._visualEffects.destroy(); } }