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