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