mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
9444 lines
410 KiB
JavaScript
9444 lines
410 KiB
JavaScript
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 `<span class="${slot === 0 ? 'active' : ''}" style="--slot:${slot};--depth:${Math.abs(slot)}">${formatCompassDivision(division)}</span>`;
|
||
})
|
||
.join('');
|
||
}
|
||
}
|
||
if (this.clock) this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z';
|
||
if (this.position) {
|
||
const lat = Number.isFinite(info.latitude)
|
||
? `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}` : '--';
|
||
const lon = Number.isFinite(info.longitude)
|
||
? `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}` : '--';
|
||
this.position.textContent = `${lat} · ${lon}`;
|
||
}
|
||
if (this.aircraftMeta) {
|
||
const feedState = this.surfaceAcquiring
|
||
? 'ACQUIRING SURFACE'
|
||
: (this.surfaceFallback ? 'SURFACE FALLBACK' : (info.stale ? 'STALE FEED' : 'LIVE TRACK'));
|
||
this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`;
|
||
}
|
||
this.updateRoute(info);
|
||
if (forceContext
|
||
|| cockpitUiUpdateDue(nowMs, this.lastContextUpdateMs, COCKPIT_CONTEXT_UPDATE_MS)) {
|
||
this.lastContextUpdateMs = nowMs;
|
||
this.updateLocalPosition(info);
|
||
this.maybeRefreshRegionalBrief(info);
|
||
this.updateContext(info, heading);
|
||
}
|
||
if (this.hud) this.hud.dataset.layer = info.layerId || 'flights';
|
||
}
|
||
|
||
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<void>}
|
||
*/
|
||
async _setMapStack(stackId, { syncShare = true } = {}) {
|
||
if (!this.mapStackController) return;
|
||
return this._mapSourceControls.select(stackId, { syncShare });
|
||
}
|
||
|
||
/**
|
||
* Syncs the map stack chip row and status chip with controller state. The
|
||
* lit chip always follows `state.activeId`, never the click — a rejected or
|
||
* superseded switch therefore leaves the genuinely active stack lit.
|
||
* @param {object} state - Map stack controller state.
|
||
* @returns {void}
|
||
*/
|
||
_renderMapStackState(state) {
|
||
this._mapSourceControls?.render(state);
|
||
}
|
||
|
||
/**
|
||
* 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<boolean>} 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<boolean>} 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 <img> 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<boolean>} 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<boolean>} 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<object>} 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<object>} 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<void>} 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();
|
||
}
|
||
}
|