mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
refactor: separate application UI state owners
This commit is contained in:
1 parent
ddc1edf3af
commit
dc81cf585c
34 files changed
+3378
-2201
No files matched your search
+428
-2140
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,233 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
beginDeferredNavigation,
|
||||
reassertNavigationHandoff,
|
||||
runExplicitNavigation,
|
||||
} from '../navigationPolicy.js';
|
||||
|
||||
/** Own camera authority generations, pending search UI and tracking handoff. */
|
||||
export class NavigationController {
|
||||
constructor({
|
||||
viewer,
|
||||
tracking,
|
||||
searchInput,
|
||||
interruptCameraMotion,
|
||||
isCockpitActive,
|
||||
clearLocation,
|
||||
cancelShareSelection,
|
||||
getDataManager,
|
||||
stopOrbit,
|
||||
showToast,
|
||||
}) {
|
||||
Object.assign(this, {
|
||||
viewer,
|
||||
tracking,
|
||||
searchInput,
|
||||
interruptCameraMotion,
|
||||
isCockpitActive,
|
||||
clearLocation,
|
||||
cancelShareSelection,
|
||||
getDataManager,
|
||||
stopOrbit,
|
||||
showToast,
|
||||
});
|
||||
this._navigationGeneration = 0;
|
||||
this._activeLocationSearchGeneration = null;
|
||||
this._disposed = false;
|
||||
}
|
||||
_stampNavigation({
|
||||
cancelPendingSelection = true,
|
||||
clearSearchedLocation = true,
|
||||
} = {}) {
|
||||
const { flightsLayer, militaryFlightsLayer, satellitesLayer } =
|
||||
this.tracking;
|
||||
this._navigationGeneration += 1;
|
||||
// A newer destination owns the camera, so the last free-text search is no
|
||||
// longer where we are. DEFERRED navigation opts out here and clears at the
|
||||
// reassert seam instead: a geocode that never resolves moves no camera, and
|
||||
// a lookup that fails must not blank a readout that is still true.
|
||||
if (clearSearchedLocation) this.clearLocation();
|
||||
if (cancelPendingSelection) {
|
||||
const passivelyClearedShareSelection = this.cancelShareSelection();
|
||||
try {
|
||||
flightsLayer.cancelPendingTrackingRestore?.();
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
try {
|
||||
militaryFlightsLayer.cancelPendingTrackingRestore?.();
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
try {
|
||||
satellitesLayer.cancelPendingTrackingRestore?.();
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
// A deliberate destination supersedes share-selected entities that have
|
||||
// not arrived yet. Active owners publish their clear when released.
|
||||
if (!passivelyClearedShareSelection && !flightsLayer.getTrackedInfo?.()) {
|
||||
this.getDataManager()?.setLayerParams(
|
||||
'flights',
|
||||
{
|
||||
selectedFlightsTrackingId: null,
|
||||
},
|
||||
{ origin: 'tool' },
|
||||
);
|
||||
}
|
||||
if (
|
||||
!passivelyClearedShareSelection &&
|
||||
!militaryFlightsLayer.getTrackedInfo?.()
|
||||
) {
|
||||
this.getDataManager()?.setLayerParams(
|
||||
'military',
|
||||
{
|
||||
selectedMilitaryTrackingId: null,
|
||||
},
|
||||
{ origin: 'tool' },
|
||||
);
|
||||
}
|
||||
if (
|
||||
!passivelyClearedShareSelection &&
|
||||
!satellitesLayer.getTrackedInfo?.()
|
||||
) {
|
||||
this.getDataManager()?.setLayerParams(
|
||||
'satellites',
|
||||
{
|
||||
selectedSatTrackingId: null,
|
||||
},
|
||||
{ origin: 'tool' },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (this._activeLocationSearchGeneration !== null) {
|
||||
this._settleLocationSearchUi(this._activeLocationSearchGeneration);
|
||||
}
|
||||
return this._navigationGeneration;
|
||||
}
|
||||
|
||||
_settleLocationSearchUi(generation) {
|
||||
if (this._activeLocationSearchGeneration !== generation) return;
|
||||
this._activeLocationSearchGeneration = null;
|
||||
this.searchInput?.classList.remove('searching', 'expanded');
|
||||
if (this.searchInput) this.searchInput.value = '';
|
||||
this.searchInput?.blur();
|
||||
}
|
||||
|
||||
_releaseFollowCamera({
|
||||
preserveVesselSelection = true,
|
||||
preserveCameraFlight = false,
|
||||
trackingOrigin = 'tool',
|
||||
} = {}) {
|
||||
const {
|
||||
flightsLayer,
|
||||
militaryFlightsLayer,
|
||||
satellitesLayer,
|
||||
aisLiveVesselsLayer,
|
||||
militaryAwarenessLayer,
|
||||
rocketLaunchesLayer,
|
||||
} = this.tracking;
|
||||
let contactSelected = false;
|
||||
try {
|
||||
contactSelected = Boolean(
|
||||
militaryAwarenessLayer.releaseCameraOwnership?.({
|
||||
preserveVesselSelection,
|
||||
origin: trackingOrigin,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
try {
|
||||
flightsLayer.stopTracking?.({ origin: trackingOrigin });
|
||||
} catch {
|
||||
/* best-effort release */
|
||||
}
|
||||
try {
|
||||
militaryFlightsLayer.stopTracking?.({ origin: trackingOrigin });
|
||||
} catch {
|
||||
/* best-effort release */
|
||||
}
|
||||
if (!preserveVesselSelection) {
|
||||
try {
|
||||
aisLiveVesselsLayer.clearSelection?.();
|
||||
} catch {
|
||||
/* best-effort release */
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
satellitesLayer.stopTracking?.({ origin: trackingOrigin });
|
||||
} catch {
|
||||
/* best-effort release */
|
||||
}
|
||||
try {
|
||||
rocketLaunchesLayer.releaseCameraOwnership?.();
|
||||
} catch {
|
||||
/* best-effort release */
|
||||
}
|
||||
this.viewer.trackedEntity = undefined;
|
||||
this.interruptCameraMotion('explicit-navigation');
|
||||
this.stopOrbit();
|
||||
if (!preserveCameraFlight) this.viewer.camera.cancelFlight();
|
||||
try {
|
||||
this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
} catch {
|
||||
/* teardown race */
|
||||
}
|
||||
return contactSelected;
|
||||
}
|
||||
|
||||
_runExplicitNavigation(noun, navigate, releaseOptions = undefined) {
|
||||
return runExplicitNavigation({
|
||||
disposed: this._disposed,
|
||||
cockpitActive: this.isCockpitActive(),
|
||||
noun,
|
||||
showToast: (text) => this.showToast(text),
|
||||
stamp: () => this._stampNavigation(),
|
||||
release: () => this._releaseFollowCamera(releaseOptions),
|
||||
navigate,
|
||||
});
|
||||
}
|
||||
|
||||
_beginDeferredNavigation(
|
||||
noun = 'location',
|
||||
{ cancelPendingSelection = true } = {},
|
||||
) {
|
||||
return beginDeferredNavigation({
|
||||
disposed: this._disposed,
|
||||
cockpitActive: this.isCockpitActive(),
|
||||
noun,
|
||||
showToast: (text) => this.showToast(text),
|
||||
// The searched-location readout survives the STAMP; only a flight that
|
||||
// actually starts invalidates it (see the release hook below).
|
||||
stamp: () =>
|
||||
this._stampNavigation({
|
||||
cancelPendingSelection,
|
||||
clearSearchedLocation: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
_reassertNavigationHandoff(generation) {
|
||||
return reassertNavigationHandoff({
|
||||
generation,
|
||||
currentGeneration: this._navigationGeneration,
|
||||
cockpitActive: this.isCockpitActive(),
|
||||
disposed: this._disposed,
|
||||
showToast: (text) => this.showToast(text),
|
||||
// Reached only once the handoff is granted, immediately before the
|
||||
// deferred flight starts — so a lookup that failed, was superseded, or
|
||||
// was refused by the cockpit leaves the old readout standing.
|
||||
release: () => {
|
||||
this.clearLocation();
|
||||
return this._releaseFollowCamera();
|
||||
},
|
||||
});
|
||||
}
|
||||
stop() {
|
||||
this._disposed = true;
|
||||
}
|
||||
destroy() {
|
||||
this.stop();
|
||||
this._stampNavigation();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
import { UiLifetime } from './uiLifetime.js';
|
||||
import { PanelPositionControls } from './panelPositionControls.js';
|
||||
import { PanelLayoutController } from './panelLayoutController.js';
|
||||
import {
|
||||
bindPanelDisclosure,
|
||||
collapsePanelOnEscape,
|
||||
createHoverDisclosure,
|
||||
} from './panelDisclosure.js';
|
||||
const SHARE_PANEL_STATE_SPECS = Object.freeze([
|
||||
{ id: 'control-panel', pinnable: true },
|
||||
{ id: 'location-bar', pinnable: true },
|
||||
{ id: 'data-panel' },
|
||||
{ id: 'cctv-panel' },
|
||||
{ id: 'radio-panel' },
|
||||
{ id: 'scene-panel' },
|
||||
{ id: 'global-context-panel' },
|
||||
{ id: 'pp-toggles' },
|
||||
{ id: 'param-slider-panel' },
|
||||
]);
|
||||
/** Standard map-view panels cleared out of the way on a fresh Cockpit entry. */
|
||||
const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
|
||||
'data-panel',
|
||||
'cctv-panel',
|
||||
'scene-panel',
|
||||
'pp-toggles',
|
||||
'global-context-panel',
|
||||
'radio-panel',
|
||||
]);
|
||||
|
||||
/** Own panel disclosure, docking, persistence and Cockpit rail restoration. */
|
||||
export class PanelChrome {
|
||||
constructor({
|
||||
elements,
|
||||
operations,
|
||||
readHud,
|
||||
readCockpit,
|
||||
readShareLinks,
|
||||
readInitialShare,
|
||||
readScrollRestoreOwner,
|
||||
readDisplayScrollTop,
|
||||
}) {
|
||||
Object.assign(this, elements, operations, {
|
||||
readHud,
|
||||
readCockpit,
|
||||
readShareLinks,
|
||||
readInitialShare,
|
||||
readScrollRestoreOwner,
|
||||
readDisplayScrollTop,
|
||||
});
|
||||
this._disposed = false;
|
||||
this._lifetime = new UiLifetime();
|
||||
this._cockpitPanelRestore = null;
|
||||
this._cockpitContextCollapsedForDataPanel = false;
|
||||
this._panelPosition = new PanelPositionControls({
|
||||
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
|
||||
layoutRightPanels: () => this._layoutRightPanels(),
|
||||
syncCctvPanelViewport: () => this._syncCctvPanelViewport(),
|
||||
showToast: (message) => this._showToast(message),
|
||||
});
|
||||
this._panelLayout = new PanelLayoutController({
|
||||
readHud: () => ({
|
||||
visible: this.hud.visible,
|
||||
variant: this.hud.getVariant(),
|
||||
}),
|
||||
scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(),
|
||||
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
|
||||
readDisplayScrollTop: () =>
|
||||
this._displayPortalScrollRestoreOwner === 'standard'
|
||||
? this._standardDisplayScrollTop
|
||||
: this._ppToggles?.scrollTop || 0,
|
||||
});
|
||||
}
|
||||
get hud() {
|
||||
return this.readHud();
|
||||
}
|
||||
get cockpitView() {
|
||||
return this.readCockpit();
|
||||
}
|
||||
get shareLinkManager() {
|
||||
return this.readShareLinks();
|
||||
}
|
||||
get _initialShareState() {
|
||||
return this.readInitialShare();
|
||||
}
|
||||
get _displayPortalScrollRestoreOwner() {
|
||||
return this.readScrollRestoreOwner();
|
||||
}
|
||||
get _standardDisplayScrollTop() {
|
||||
return this.readDisplayScrollTop();
|
||||
}
|
||||
|
||||
_initPanelChrome() {
|
||||
for (const control of this._panelDisclosureControls || [])
|
||||
control.destroy();
|
||||
this._panelDisclosureControls = [];
|
||||
const targets = new Map();
|
||||
document
|
||||
.querySelectorAll('.panel-collapse-btn[data-collapse-target]')
|
||||
.forEach((button) => {
|
||||
const targetId = button.dataset.collapseTarget;
|
||||
if (!targetId) return;
|
||||
if (!targets.has(targetId)) targets.set(targetId, []);
|
||||
targets.get(targetId).push(button);
|
||||
});
|
||||
for (const [targetId, buttons] of targets) {
|
||||
const panel = document.getElementById(targetId);
|
||||
if (!panel) continue;
|
||||
this._panelDisclosureControls.push(
|
||||
bindPanelDisclosure({
|
||||
panel,
|
||||
buttons,
|
||||
onChange: (collapsed, options) =>
|
||||
this.setPanelCollapsed(targetId, collapsed, options),
|
||||
onEscape: (event) => this._collapsePanelOnEscape(event, targetId),
|
||||
}),
|
||||
);
|
||||
this._restorePanelCollapsedState(targetId, {
|
||||
allowStored: !this._initialShareState,
|
||||
});
|
||||
}
|
||||
// The command dock always starts compact; either wing reveals on hover,
|
||||
// focus, or click and collapses again after the interaction moves away.
|
||||
this.setPanelCollapsed('control-panel', true, {
|
||||
syncShare: false,
|
||||
persist: false,
|
||||
});
|
||||
this.setPanelCollapsed('location-bar', true, {
|
||||
syncShare: false,
|
||||
persist: false,
|
||||
});
|
||||
this._initAutoHoverPanel('control-panel', {
|
||||
openDelayMs: 140,
|
||||
closeDelayMs: 420,
|
||||
});
|
||||
this._initAutoHoverPanel('location-bar', {
|
||||
openDelayMs: 140,
|
||||
closeDelayMs: 420,
|
||||
});
|
||||
this._initCommandDockPins();
|
||||
this._initCommandDockTrayMetrics();
|
||||
this._maybeNotifyLayoutReset();
|
||||
}
|
||||
|
||||
_collapsePanelOnEscape(event, panelId) {
|
||||
return collapsePanelOnEscape(event, {
|
||||
panel: document.getElementById(panelId),
|
||||
onChange: (collapsed, options) =>
|
||||
this.setPanelCollapsed(panelId, collapsed, options),
|
||||
beforeCollapse: () => {
|
||||
if (panelId !== 'location-bar' || !this._locationSearch) return;
|
||||
this._locationSearch.classList.remove('expanded');
|
||||
this._locationSearch.value = '';
|
||||
this._locationSearch.blur();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_initCommandDockPins() {
|
||||
document
|
||||
.querySelectorAll('.dock-pin-btn[data-pin-target]')
|
||||
.forEach((button) => {
|
||||
this._lifetime.listen(button, 'click', (event) => {
|
||||
event.stopPropagation();
|
||||
const panelId = button.dataset.pinTarget;
|
||||
this._setCommandDockPanelPinState(panelId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_setCommandDockPanelPinState(
|
||||
panelId,
|
||||
pin,
|
||||
{ restore = false, persist = true, syncShare = true } = {},
|
||||
) {
|
||||
const panelEl = document.getElementById(panelId);
|
||||
const button = document.querySelector(
|
||||
`.dock-pin-btn[data-pin-target="${panelId}"]`,
|
||||
);
|
||||
if (!panelEl || !button) return undefined;
|
||||
const shouldPin =
|
||||
typeof pin === 'boolean'
|
||||
? pin
|
||||
: !panelEl.classList.contains('dock-pinned');
|
||||
panelEl.classList.toggle('dock-pinned', shouldPin);
|
||||
button.setAttribute('aria-pressed', String(shouldPin));
|
||||
document
|
||||
.querySelectorAll('#command-dock .dock-pinned-top')
|
||||
.forEach((pinnedPanel) => {
|
||||
pinnedPanel.classList.remove('dock-pinned-top');
|
||||
});
|
||||
if (shouldPin) {
|
||||
panelEl.classList.add('dock-pinned-top');
|
||||
this.setPanelCollapsed(panelId, false, {
|
||||
explicit: !restore,
|
||||
restore,
|
||||
persist,
|
||||
syncShare: false,
|
||||
});
|
||||
} else {
|
||||
const remainingPinnedPanel = document.querySelector(
|
||||
'#command-dock .dock-pinned',
|
||||
);
|
||||
remainingPinnedPanel?.classList.add('dock-pinned-top');
|
||||
if (!restore && !panelEl.matches(':hover')) {
|
||||
this.setPanelCollapsed(panelId, true, {
|
||||
explicit: true,
|
||||
persist,
|
||||
syncShare: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
this._updateCommandDockTrayStack();
|
||||
if (syncShare) {
|
||||
if (!restore) this.shareLinkManager?.claimRestoreLane?.('panel', panelId);
|
||||
this.shareLinkManager?.onPanelStateChange?.();
|
||||
}
|
||||
return shouldPin;
|
||||
}
|
||||
|
||||
_initCommandDockTrayMetrics() {
|
||||
return this._panelLayout._initCommandDockTrayMetrics();
|
||||
}
|
||||
|
||||
_updateCommandDockTrayStack() {
|
||||
return this._panelLayout._updateCommandDockTrayStack();
|
||||
}
|
||||
|
||||
_maybeNotifyLayoutReset() {
|
||||
return this._panelPosition._maybeNotifyLayoutReset();
|
||||
}
|
||||
|
||||
_initAutoHoverPanel(
|
||||
panelId,
|
||||
{ openDelayMs = 850, closeDelayMs = 1000 } = {},
|
||||
) {
|
||||
const panel = document.getElementById(panelId);
|
||||
if (!panel) return;
|
||||
this._hoverPanelControls ??= new Map();
|
||||
this._hoverPanelControls.get(panelId)?.destroy();
|
||||
const controller = createHoverDisclosure({
|
||||
panel,
|
||||
documentRef: document,
|
||||
disclosure: panel.querySelector(`[data-dock-toggle-target="${panelId}"]`),
|
||||
openDelayMs,
|
||||
closeDelayMs,
|
||||
isActive: () => !this._disposed,
|
||||
onChange: (collapsed, options) =>
|
||||
this.setPanelCollapsed(panelId, collapsed, options),
|
||||
onEscape: (event) => this._collapsePanelOnEscape(event, panelId),
|
||||
focusTarget:
|
||||
panelId === 'control-panel'
|
||||
? () =>
|
||||
panel.querySelector('.map-stack-chip.active') ||
|
||||
panel.querySelector('.map-stack-chip')
|
||||
: null,
|
||||
});
|
||||
this._hoverPanelControls.set(panelId, controller);
|
||||
if (panelId === 'control-panel') {
|
||||
this._cancelMapSourceFocus?.();
|
||||
this._cancelMapSourceFocus = controller.cancelPendingFocus;
|
||||
}
|
||||
}
|
||||
|
||||
_restorePanelCollapsedState(panelId, options1) {
|
||||
return this._panelPosition._restorePanelCollapsedState(panelId, options1);
|
||||
}
|
||||
|
||||
_savePanelCollapsedState(panelId, collapsed) {
|
||||
return this._panelPosition._savePanelCollapsedState(panelId, collapsed);
|
||||
}
|
||||
|
||||
_scheduleRightPanelLayout(options0) {
|
||||
return this._panelLayout._scheduleRightPanelLayout(options0);
|
||||
}
|
||||
|
||||
_scheduleLeftPanelLayout(options0) {
|
||||
return this._panelLayout._scheduleLeftPanelLayout(options0);
|
||||
}
|
||||
|
||||
_syncPanelCollapseButton(panelEl) {
|
||||
const isRightRail = [
|
||||
'pp-toggles',
|
||||
'cctv-panel',
|
||||
'global-context-panel',
|
||||
].includes(panelEl?.id);
|
||||
const collapsed = panelEl.classList.contains('collapsed');
|
||||
panelEl
|
||||
.querySelectorAll('.panel-collapse-btn[data-collapse-target]')
|
||||
.forEach((btn) => {
|
||||
const owner = btn.closest('[data-panel-id], #param-slider-panel');
|
||||
if (owner !== panelEl) return;
|
||||
if (isRightRail) {
|
||||
btn.textContent = collapsed ? '◀' : '▶';
|
||||
} else {
|
||||
btn.textContent = collapsed ? '+' : '−';
|
||||
}
|
||||
btn.setAttribute('aria-expanded', String(!collapsed));
|
||||
const panelName =
|
||||
panelEl
|
||||
.querySelector('.panel-title, .pp-header-label')
|
||||
?.textContent?.trim() || 'panel';
|
||||
const action = collapsed ? 'Expand' : 'Collapse';
|
||||
btn.title = `${action} ${panelName}`;
|
||||
btn.setAttribute('aria-label', `${action} ${panelName}`);
|
||||
if (panelEl.id === 'radio-panel') {
|
||||
const action = collapsed ? 'Expand' : 'Collapse';
|
||||
btn.title = `${action} Radio`;
|
||||
btn.setAttribute('aria-label', `${action} Radio section`);
|
||||
}
|
||||
});
|
||||
const dockToggle = panelEl.querySelector(
|
||||
`[data-dock-toggle-target="${panelEl.id}"]`,
|
||||
);
|
||||
if (dockToggle) {
|
||||
const panelName =
|
||||
panelEl
|
||||
.querySelector('.panel-title, .location-toolbar-label')
|
||||
?.textContent?.trim() || 'panel';
|
||||
const action = collapsed ? 'Expand' : 'Collapse';
|
||||
dockToggle.setAttribute('aria-expanded', String(!collapsed));
|
||||
dockToggle.setAttribute('aria-label', `${action} ${panelName}`);
|
||||
dockToggle.title = `${action} ${panelName}`;
|
||||
}
|
||||
if (panelEl.id === 'radio-panel' && this._contextRadioDetailsBtn) {
|
||||
this._contextRadioDetailsBtn.setAttribute(
|
||||
'aria-expanded',
|
||||
String(!collapsed),
|
||||
);
|
||||
}
|
||||
if (panelEl.id === 'radio-panel' || panelEl.id === 'global-context-panel') {
|
||||
this._syncContextRadioLauncherState();
|
||||
}
|
||||
}
|
||||
|
||||
_buildSharePanelState() {
|
||||
const specs = [];
|
||||
for (const spec of SHARE_PANEL_STATE_SPECS) {
|
||||
const panelEl = document.getElementById(spec.id);
|
||||
if (!panelEl) continue;
|
||||
// Responsive auto-collapse is presentation only; the recipient should
|
||||
// restore the user's explicit expanded preference at its own viewport.
|
||||
const collapsed = panelEl.classList.contains('layout-auto-collapsed')
|
||||
? false
|
||||
: panelEl.classList.contains('collapsed');
|
||||
const entry = { id: spec.id, collapsed };
|
||||
if (spec.pinnable)
|
||||
entry.pinned = panelEl.classList.contains('dock-pinned');
|
||||
specs.push(entry);
|
||||
}
|
||||
return specs.length ? { specs } : null;
|
||||
}
|
||||
|
||||
_restorePanelState(panelState) {
|
||||
if (!panelState || !Array.isArray(panelState.specs)) return;
|
||||
const specsById = new Map(panelState.specs.map((spec) => [spec.id, spec]));
|
||||
for (const spec of SHARE_PANEL_STATE_SPECS) {
|
||||
const state = specsById.get(spec.id);
|
||||
if (!state || typeof state.collapsed !== 'boolean') continue;
|
||||
if (spec.pinnable && typeof state.pinned === 'boolean') {
|
||||
this._setCommandDockPanelPinState(spec.id, state.pinned, {
|
||||
restore: true,
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
}
|
||||
const nextCollapsed =
|
||||
state.pinned && spec.pinnable ? false : state.collapsed;
|
||||
this.setPanelCollapsed(spec.id, nextCollapsed, {
|
||||
restore: true,
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
}
|
||||
this.shareLinkManager?.onPanelStateChange?.();
|
||||
}
|
||||
|
||||
setPanelCollapsed(
|
||||
panelId,
|
||||
collapsed,
|
||||
{
|
||||
explicit = false,
|
||||
restore = false,
|
||||
persist = true,
|
||||
syncShare = true,
|
||||
} = {},
|
||||
) {
|
||||
if (panelId === 'control-panel' && collapsed)
|
||||
this._cancelMapSourceFocus?.();
|
||||
const panelEl = document.getElementById(panelId);
|
||||
if (!panelEl) return;
|
||||
if (explicit && !restore)
|
||||
this.shareLinkManager?.claimRestoreLane?.('panel', panelId);
|
||||
const nextCollapsed = Boolean(collapsed);
|
||||
const wasAutoCollapsed = panelEl.classList.contains(
|
||||
'layout-auto-collapsed',
|
||||
);
|
||||
const leftOwnerPanel = this._leftPanelStack?.contains(panelEl)
|
||||
? panelEl
|
||||
: null;
|
||||
const rightOwnerPanel =
|
||||
panelId === 'radio-panel'
|
||||
? document.getElementById('global-context-panel')
|
||||
: this._rightPanelStack?.contains(panelEl)
|
||||
? panelEl
|
||||
: null;
|
||||
const priorLeftOwner = this._panelLayout._leftStackPreferredPanelId;
|
||||
const priorRightOwner = this._panelLayout._rightStackPreferredPanelId;
|
||||
if (explicit && !restore && !nextCollapsed && leftOwnerPanel) {
|
||||
this._panelLayout._leftStackPreferredPanelId = leftOwnerPanel.id;
|
||||
} else if (
|
||||
explicit &&
|
||||
!restore &&
|
||||
nextCollapsed &&
|
||||
leftOwnerPanel?.id === this._panelLayout._leftStackPreferredPanelId
|
||||
) {
|
||||
this._panelLayout._leftStackPreferredPanelId = null;
|
||||
}
|
||||
if (explicit && !restore && !nextCollapsed && rightOwnerPanel) {
|
||||
this._panelLayout._rightStackPreferredPanelId = rightOwnerPanel.id;
|
||||
} else if (
|
||||
explicit &&
|
||||
!restore &&
|
||||
nextCollapsed &&
|
||||
rightOwnerPanel?.id === this._panelLayout._rightStackPreferredPanelId
|
||||
) {
|
||||
this._panelLayout._rightStackPreferredPanelId = null;
|
||||
}
|
||||
if (
|
||||
panelEl.classList.contains('collapsed') === nextCollapsed &&
|
||||
!wasAutoCollapsed
|
||||
) {
|
||||
this._syncPanelCollapseButton(panelEl);
|
||||
if (priorLeftOwner !== this._panelLayout._leftStackPreferredPanelId) {
|
||||
this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true });
|
||||
}
|
||||
if (priorRightOwner !== this._panelLayout._rightStackPreferredPanelId) {
|
||||
this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
panelEl.classList.remove('layout-auto-collapsed');
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
this.cockpitView?.active &&
|
||||
panelId === 'data-panel'
|
||||
) {
|
||||
this._cockpitContextCollapsedForDataPanel =
|
||||
!this.cockpitView.contextCollapsed;
|
||||
if (this._cockpitContextCollapsedForDataPanel) {
|
||||
this.cockpitView.setContextCollapsed(true);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
panelId === 'global-context-panel' &&
|
||||
this._contextRadioDock?.classList.contains('disclosure-open')
|
||||
) {
|
||||
this._setRadioDisclosure?.(false);
|
||||
}
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
panelId === 'radio-panel' &&
|
||||
document
|
||||
.getElementById('global-context-panel')
|
||||
?.classList.contains('collapsed')
|
||||
) {
|
||||
this.setPanelCollapsed('global-context-panel', false, {
|
||||
restore,
|
||||
persist,
|
||||
syncShare,
|
||||
});
|
||||
}
|
||||
if (!nextCollapsed && !restore && panelId === 'location-bar') {
|
||||
const otherPanel = document.getElementById('control-panel');
|
||||
if (otherPanel && !otherPanel.classList.contains('dock-pinned')) {
|
||||
this.setPanelCollapsed('control-panel', true, {
|
||||
restore,
|
||||
persist,
|
||||
syncShare,
|
||||
});
|
||||
}
|
||||
} else if (!nextCollapsed && !restore && panelId === 'control-panel') {
|
||||
const otherPanel = document.getElementById('location-bar');
|
||||
if (otherPanel && !otherPanel.classList.contains('dock-pinned')) {
|
||||
this.setPanelCollapsed('location-bar', true, {
|
||||
restore,
|
||||
persist,
|
||||
syncShare,
|
||||
});
|
||||
}
|
||||
}
|
||||
panelEl.classList.toggle('collapsed', nextCollapsed);
|
||||
if (
|
||||
nextCollapsed &&
|
||||
this.cockpitView?.active &&
|
||||
panelId === 'data-panel' &&
|
||||
this._cockpitContextCollapsedForDataPanel
|
||||
) {
|
||||
this._cockpitContextCollapsedForDataPanel = false;
|
||||
this.cockpitView.setContextCollapsed(false);
|
||||
}
|
||||
this._syncPanelCollapseButton(panelEl);
|
||||
if (persist !== false)
|
||||
this._savePanelCollapsedState(panelId, nextCollapsed);
|
||||
if (panelId === 'pp-toggles') {
|
||||
this._layoutRightPanels();
|
||||
}
|
||||
if (this._rightPanelStack?.contains(panelEl)) {
|
||||
this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true });
|
||||
}
|
||||
if (panelId === 'cctv-panel') {
|
||||
this._syncCctvPanelViewport();
|
||||
}
|
||||
this._lifetime.frame(() => this._updateCommandDockTrayStack());
|
||||
this._scheduleLeftPanelLayout({
|
||||
reconsiderAutoCollapse: this._leftPanelStack?.contains(panelEl) === true,
|
||||
});
|
||||
if (syncShare) this.shareLinkManager?.onPanelStateChange?.();
|
||||
}
|
||||
|
||||
_layoutRightPanels() {
|
||||
this._scheduleRightPanelLayout();
|
||||
}
|
||||
enterCockpit() {
|
||||
// A new Cockpit session owns both side rails. Clear standard map-view
|
||||
// panels once on entry; NEXT/PREVIOUS never reaches this callback, so
|
||||
// panels the operator opens while already inside remain untouched.
|
||||
this._cockpitPanelRestore = new Map();
|
||||
this._cockpitContextCollapsedForDataPanel = false;
|
||||
for (const panelId of COCKPIT_ENTRY_COLLAPSE_PANEL_IDS) {
|
||||
const panel = document.getElementById(panelId);
|
||||
if (panel) {
|
||||
this._cockpitPanelRestore.set(
|
||||
panelId,
|
||||
panel.classList.contains('collapsed'),
|
||||
);
|
||||
}
|
||||
this.setPanelCollapsed(panelId, true, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
}
|
||||
this.cockpitView?.setContextCollapsed(false);
|
||||
this.cockpitView?.setSignalCollapsed(false, { user: true });
|
||||
}
|
||||
exitCockpit() {
|
||||
const restore = this._cockpitPanelRestore;
|
||||
this._cockpitPanelRestore = null;
|
||||
this._cockpitContextCollapsedForDataPanel = false;
|
||||
if (!restore) return;
|
||||
for (const [panelId, wasCollapsed] of restore) {
|
||||
this.setPanelCollapsed(panelId, wasCollapsed, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
destroy() {
|
||||
if (this._disposed) return;
|
||||
this._disposed = true;
|
||||
this._lifetime.destroy();
|
||||
this._panelPosition.destroy();
|
||||
this._panelLayout.destroy();
|
||||
for (const control of this._panelDisclosureControls || [])
|
||||
control.destroy();
|
||||
this._panelDisclosureControls = [];
|
||||
this._hoverPanelControls?.forEach((control) => control.destroy());
|
||||
this._hoverPanelControls?.clear();
|
||||
this._cancelMapSourceFocus?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { LayerStateCoordinator } from '../data/layerState.js';
|
||||
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
||||
import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
|
||||
import { UiLifetime } from './uiLifetime.js';
|
||||
|
||||
/** Own initial share restoration, durable layer state and restoration notices. */
|
||||
export class ShareRestoration {
|
||||
constructor({
|
||||
viewer,
|
||||
navigation,
|
||||
syncShareState,
|
||||
syncModels3d,
|
||||
showStatus,
|
||||
feedback,
|
||||
updateFeedback,
|
||||
}) {
|
||||
Object.assign(this, {
|
||||
viewer,
|
||||
navigation,
|
||||
syncShareState,
|
||||
syncModels3d,
|
||||
showStatus,
|
||||
feedback,
|
||||
updateFeedback,
|
||||
});
|
||||
this._lifetime = new UiLifetime();
|
||||
this._disposed = false;
|
||||
this._shareTrackingAcquiringKey = null;
|
||||
this._shareTrackingNoticeGeneration = 0;
|
||||
this._initialShareState = null;
|
||||
this._initialShareNavigationGeneration = null;
|
||||
this._initialShareRestoreTimeout = null;
|
||||
this._layerStateCoordinator = null;
|
||||
this._layerStateRestorePromise = null;
|
||||
}
|
||||
attachLinks(shareLinkManager) {
|
||||
this.shareLinkManager = shareLinkManager;
|
||||
this._initialShareState = shareLinkManager.parseInitialHash();
|
||||
}
|
||||
start() {
|
||||
// Restore from URL hash if present
|
||||
const savedState = this._initialShareState;
|
||||
this._initialShareRestorePromise = savedState
|
||||
? new Promise((resolve) => {
|
||||
this._resolveInitialShareRestore = resolve;
|
||||
})
|
||||
: Promise.resolve({ status: 'not-requested', share: null, layers: [] });
|
||||
if (savedState) {
|
||||
this._hasShareState = true;
|
||||
// Reserve camera authority now; the delayed mesh-friendly flight may
|
||||
// run only if no newer user, voice, or tracking navigation has won.
|
||||
this._initialShareNavigationGeneration =
|
||||
this.navigation._beginDeferredNavigation('shared view', {
|
||||
cancelPendingSelection: false,
|
||||
});
|
||||
this._initialShareRestoreTimeout = setTimeout(() => {
|
||||
this._initialShareRestoreTimeout = null;
|
||||
if (this._disposed) return;
|
||||
const generation = this._initialShareNavigationGeneration;
|
||||
const applyCamera =
|
||||
Number.isInteger(generation) &&
|
||||
this.navigation._reassertNavigationHandoff(generation);
|
||||
void (async () => {
|
||||
try {
|
||||
const share = await this.shareLinkManager.applyState(savedState, {
|
||||
applyCamera,
|
||||
navigationToken: generation,
|
||||
});
|
||||
const layers = await (this._layerStateRestorePromise ||
|
||||
Promise.resolve([]));
|
||||
const tracking =
|
||||
share.camera === 'applied'
|
||||
? await this._layerStateCoordinator?.restoreShareTrackingSelection?.()
|
||||
: {
|
||||
status: 'superseded',
|
||||
cleared:
|
||||
this._layerStateCoordinator?.cancelPendingShareTracking?.(
|
||||
'shared-camera-superseded',
|
||||
{ clearSelection: true },
|
||||
) === true,
|
||||
};
|
||||
this.shareLinkManager.completeInitialRestore();
|
||||
this._settleInitialShareRestore({
|
||||
status: 'settled',
|
||||
share,
|
||||
layers,
|
||||
tracking,
|
||||
});
|
||||
} catch (error) {
|
||||
this.shareLinkManager.completeInitialRestore();
|
||||
this._settleInitialShareRestore({
|
||||
status: 'failed',
|
||||
error: String(error?.message || error),
|
||||
share: null,
|
||||
layers: [],
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, 1500);
|
||||
} else {
|
||||
this.syncShareState();
|
||||
}
|
||||
// A recipient can orbit before or during the delayed share flight. That
|
||||
// gesture keeps ordinary layer state but revokes the passive base camera
|
||||
// and selected-subject Follow so delayed work cannot seize navigation.
|
||||
this._initialShareGestureHandler = () => {
|
||||
if (
|
||||
this._disposed ||
|
||||
!this._hasShareState ||
|
||||
!this._resolveInitialShareRestore
|
||||
)
|
||||
return;
|
||||
stampInitialShareGesture((options) =>
|
||||
this.navigation._stampNavigation(options),
|
||||
);
|
||||
};
|
||||
this.viewer?.canvas?.addEventListener(
|
||||
'pointerdown',
|
||||
this._initialShareGestureHandler,
|
||||
{
|
||||
passive: true,
|
||||
},
|
||||
);
|
||||
this.viewer?.canvas?.addEventListener(
|
||||
'wheel',
|
||||
this._initialShareGestureHandler,
|
||||
{
|
||||
passive: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
connect(dataManager) {
|
||||
this._dataManager = dataManager;
|
||||
this._layerStateCoordinator?.destroy();
|
||||
this._layerStateCoordinator = null;
|
||||
this._layerStateRestorePromise = null;
|
||||
if (this._dataManager) {
|
||||
this._layerStateCoordinator = new LayerStateCoordinator(
|
||||
this._dataManager,
|
||||
this.shareLinkManager,
|
||||
{
|
||||
onDurableStateChange: (state) => this.syncModels3d(state),
|
||||
onTrackingRestoreStatus: (result) =>
|
||||
this._handleShareTrackingRestoreStatus(result),
|
||||
},
|
||||
);
|
||||
this._layerStateRestorePromise = this._layerStateCoordinator.start({
|
||||
shareLayerState: this._initialShareState?.layerState || null,
|
||||
shareCreatedAtMs: this._initialShareState?.sharedAtMs ?? null,
|
||||
// Any valid camera/style share isolates recipient-local preferences,
|
||||
// including legacy and malformed-v2 layer payloads.
|
||||
allowLocalState: !this._initialShareState,
|
||||
});
|
||||
if (this._initialShareSelectionSuperseded) {
|
||||
this._layerStateCoordinator.cancelPendingShareTracking(
|
||||
'superseded-before-layer-coordinator-start',
|
||||
{ clearSelection: true },
|
||||
);
|
||||
}
|
||||
void this._layerStateRestorePromise.then(() => {
|
||||
this.syncModels3d(this._layerStateCoordinator?.getDurableState());
|
||||
});
|
||||
}
|
||||
}
|
||||
cancelSelection() {
|
||||
if (
|
||||
this._hasShareState &&
|
||||
this._resolveInitialShareRestore &&
|
||||
!this._layerStateCoordinator
|
||||
)
|
||||
this._initialShareSelectionSuperseded = true;
|
||||
return (
|
||||
this._layerStateCoordinator?.cancelPendingShareTracking?.(
|
||||
'superseded-by-explicit-navigation',
|
||||
{ clearSelection: true },
|
||||
) === true
|
||||
);
|
||||
}
|
||||
get initialRestorePromise() {
|
||||
return (
|
||||
this._initialShareRestorePromise ||
|
||||
Promise.resolve({ status: 'not-requested' })
|
||||
);
|
||||
}
|
||||
_handleShareTrackingRestoreStatus(result) {
|
||||
if (!result || this._disposed) return;
|
||||
const trackingKey = `${result.layerId || ''}:${result.targetId ?? ''}`;
|
||||
if (result.classification === 'pending') {
|
||||
this._shareTrackingNoticeGeneration += 1;
|
||||
this._shareTrackingAcquiringKey = trackingKey;
|
||||
this.showStatus('ACQUIRING', {
|
||||
state: 'acquiring',
|
||||
detail: `SHARED ${String(result.label || 'SUBJECT').toUpperCase()}`,
|
||||
persistent: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const ownsAcquiringNotice = this._shareTrackingAcquiringKey === trackingKey;
|
||||
if (ownsAcquiringNotice) {
|
||||
this._shareTrackingNoticeGeneration += 1;
|
||||
this._shareTrackingAcquiringKey = null;
|
||||
if (this.feedback._globalStatusNotice?.state === 'acquiring') {
|
||||
this.feedback._globalStatusNotice = null;
|
||||
this.updateFeedback();
|
||||
}
|
||||
}
|
||||
if (
|
||||
result.classification === 'followed' ||
|
||||
result.classification === 'cancelled'
|
||||
)
|
||||
return;
|
||||
// A stale terminal result must never replace a newer target's acquisition.
|
||||
if (this._shareTrackingAcquiringKey) return;
|
||||
const noticeGeneration = ownsAcquiringNotice
|
||||
? this._shareTrackingNoticeGeneration
|
||||
: ++this._shareTrackingNoticeGeneration;
|
||||
const subject = result.label || 'entity';
|
||||
const message =
|
||||
result.classification === 'expired'
|
||||
? `Shared ${subject} follow expired`
|
||||
: result.classification === 'source-unavailable'
|
||||
? `Shared ${subject} could not be restored — feed unavailable`
|
||||
: `Shared ${subject} is unavailable`;
|
||||
const showAfterStartupCover = () => {
|
||||
this._lifetime.frame(() => {
|
||||
if (
|
||||
!canPresentDeferredStatusNotice(
|
||||
noticeGeneration,
|
||||
this._shareTrackingNoticeGeneration,
|
||||
this._disposed,
|
||||
)
|
||||
)
|
||||
return;
|
||||
const startupCover = document.getElementById('loading-screen');
|
||||
if (
|
||||
!startupCover ||
|
||||
getComputedStyle(startupCover).visibility === 'hidden'
|
||||
) {
|
||||
this.showStatus(message);
|
||||
return;
|
||||
}
|
||||
let fallbackTimer = null;
|
||||
let removeStartupListener = () => {};
|
||||
const showOnce = () => {
|
||||
removeStartupListener();
|
||||
if (fallbackTimer) this._lifetime.cancelTimeout(fallbackTimer);
|
||||
if (
|
||||
canPresentDeferredStatusNotice(
|
||||
noticeGeneration,
|
||||
this._shareTrackingNoticeGeneration,
|
||||
this._disposed,
|
||||
)
|
||||
)
|
||||
this.showStatus(message);
|
||||
};
|
||||
removeStartupListener = this._lifetime.listen(
|
||||
startupCover,
|
||||
'transitionend',
|
||||
showOnce,
|
||||
{ once: true },
|
||||
);
|
||||
fallbackTimer = this._lifetime.timeout(showOnce, 1000);
|
||||
});
|
||||
};
|
||||
if (this._resolveInitialShareRestore) {
|
||||
void this.initialRestorePromise.then(showAfterStartupCover);
|
||||
return;
|
||||
}
|
||||
showAfterStartupCover();
|
||||
}
|
||||
_settleInitialShareRestore(result) {
|
||||
if (!this._resolveInitialShareRestore) return;
|
||||
const resolve = this._resolveInitialShareRestore;
|
||||
this._resolveInitialShareRestore = null;
|
||||
resolve(result);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:initial-share-restore-settled', { detail: result }),
|
||||
);
|
||||
}
|
||||
destroy() {
|
||||
if (this._disposed) return;
|
||||
this._disposed = true;
|
||||
this._shareTrackingNoticeGeneration += 1;
|
||||
this._shareTrackingAcquiringKey = null;
|
||||
this._layerStateCoordinator?.destroy();
|
||||
this._layerStateCoordinator = null;
|
||||
this._layerStateRestorePromise = null;
|
||||
clearTimeout(this._initialShareRestoreTimeout);
|
||||
this._initialShareRestoreTimeout = null;
|
||||
this._settleInitialShareRestore({
|
||||
status: 'destroyed',
|
||||
share: null,
|
||||
layers: [],
|
||||
});
|
||||
this.viewer?.canvas?.removeEventListener(
|
||||
'pointerdown',
|
||||
this._initialShareGestureHandler,
|
||||
);
|
||||
this.viewer?.canvas?.removeEventListener(
|
||||
'wheel',
|
||||
this._initialShareGestureHandler,
|
||||
);
|
||||
this._initialShareGestureHandler = null;
|
||||
this._lifetime.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { NavigationController } from './navigationController.js';
|
||||
import { ShareRestoration } from './shareRestoration.js';
|
||||
|
||||
function navigation() {
|
||||
const tracking = Object.fromEntries(
|
||||
[
|
||||
'flightsLayer',
|
||||
'militaryFlightsLayer',
|
||||
'satellitesLayer',
|
||||
'aisLiveVesselsLayer',
|
||||
'militaryAwarenessLayer',
|
||||
'rocketLaunchesLayer',
|
||||
].map((name) => [name, {}]),
|
||||
);
|
||||
return new NavigationController({
|
||||
viewer: { camera: { cancelFlight() {}, lookAtTransform() {} } },
|
||||
tracking,
|
||||
searchInput: null,
|
||||
interruptCameraMotion() {},
|
||||
isCockpitActive: () => false,
|
||||
clearLocation() {},
|
||||
cancelShareSelection: () => false,
|
||||
getDataManager: () => null,
|
||||
stopOrbit() {},
|
||||
showToast() {},
|
||||
});
|
||||
}
|
||||
|
||||
test('navigation generations belong to their owner and teardown closes both entry paths', () => {
|
||||
const first = navigation();
|
||||
const second = navigation();
|
||||
const a = first._beginDeferredNavigation();
|
||||
const b = second._beginDeferredNavigation();
|
||||
first._stampNavigation();
|
||||
assert.equal(first._reassertNavigationHandoff(a), false);
|
||||
assert.equal(second._reassertNavigationHandoff(b), true);
|
||||
let flights = 0;
|
||||
second.stop();
|
||||
assert.equal(second._beginDeferredNavigation(), false);
|
||||
assert.equal(
|
||||
second._runExplicitNavigation('location', () => {
|
||||
flights += 1;
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(flights, 0);
|
||||
const generation = second._navigationGeneration;
|
||||
second.destroy();
|
||||
assert.equal(second._navigationGeneration, generation + 1);
|
||||
});
|
||||
|
||||
test('share teardown settles its promise, removes gestures and rejects a retained timer callback', async (t) => {
|
||||
const prior = {
|
||||
window: globalThis.window,
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
};
|
||||
const timers = new Map();
|
||||
let timerId = 0;
|
||||
globalThis.window = new EventTarget();
|
||||
globalThis.setTimeout = (fn) => {
|
||||
timers.set(++timerId, fn);
|
||||
return timerId;
|
||||
};
|
||||
globalThis.clearTimeout = (id) => timers.delete(id);
|
||||
t.after(() => Object.assign(globalThis, prior));
|
||||
const canvas = new EventTarget();
|
||||
let stamps = 0;
|
||||
let applies = 0;
|
||||
const owner = new ShareRestoration({
|
||||
viewer: { canvas },
|
||||
navigation: {
|
||||
_beginDeferredNavigation: () => 1,
|
||||
_reassertNavigationHandoff: () => true,
|
||||
_stampNavigation: () => {
|
||||
stamps += 1;
|
||||
},
|
||||
},
|
||||
syncShareState() {},
|
||||
syncModels3d() {},
|
||||
showStatus() {},
|
||||
feedback: {},
|
||||
updateFeedback() {},
|
||||
});
|
||||
owner.attachLinks({
|
||||
parseInitialHash: () => ({ latitude: 30, longitude: -97 }),
|
||||
applyState: async () => {
|
||||
applies += 1;
|
||||
return { camera: 'applied' };
|
||||
},
|
||||
completeInitialRestore() {},
|
||||
});
|
||||
owner.start();
|
||||
const retained = [...timers.values()][0];
|
||||
canvas.dispatchEvent(new Event('wheel'));
|
||||
assert.equal(stamps, 1);
|
||||
owner.destroy();
|
||||
assert.equal((await owner.initialRestorePromise).status, 'destroyed');
|
||||
assert.equal(timers.size, 0);
|
||||
canvas.dispatchEvent(new Event('wheel'));
|
||||
retained();
|
||||
await Promise.resolve();
|
||||
assert.equal(stamps, 1);
|
||||
assert.equal(applies, 0);
|
||||
});
|
||||
|
||||
test('visual teardown restores owned fog and aircraft sensor state once', async (t) => {
|
||||
const { VisualSettings } = await import('./visualSettings.js');
|
||||
const priorDocument = globalThis.document;
|
||||
globalThis.document = {
|
||||
documentElement: { dataset: {} },
|
||||
getElementById: () => null,
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.document = priorDocument;
|
||||
});
|
||||
const calls = [];
|
||||
const viewer = { scene: { fog: { enabled: false } } };
|
||||
const owner = new VisualSettings({
|
||||
viewer,
|
||||
elements: {},
|
||||
operations: {},
|
||||
services: {
|
||||
governorRequestRender() {},
|
||||
holdContinuousRender() {},
|
||||
releaseContinuousRender() {},
|
||||
},
|
||||
readDataManager: () => ({ setLayerParams: (...args) => calls.push(args) }),
|
||||
});
|
||||
owner._irBoostActive = true;
|
||||
owner._irFogWasEnabled = true;
|
||||
owner.releaseIrBoost();
|
||||
owner.releaseIrBoost();
|
||||
assert.equal(viewer.scene.fog.enabled, true);
|
||||
assert.deepEqual(calls, [
|
||||
['flights', { irBoost: false }],
|
||||
['military', { irBoost: false }],
|
||||
]);
|
||||
assert.equal(owner._irBoostActive, false);
|
||||
owner.destroy();
|
||||
});
|
||||
@@ -143,3 +143,14 @@ export const SHARPEN_SHADER = /* glsl */ `
|
||||
out_FragColor = vec4(clamp(sharpened.rgb, 0.0, 1.0), center.a);
|
||||
}
|
||||
`;
|
||||
|
||||
/** Stable display labels for the active style and inherited Cockpit vision. */
|
||||
export const STYLE_STATUS_LABELS = {
|
||||
normal: 'NORMAL',
|
||||
retro: 'CRT',
|
||||
surveillance: 'NVG',
|
||||
thermal: 'FLIR',
|
||||
anime: 'ANIME',
|
||||
noir: 'NOIR',
|
||||
snow: 'SNOW',
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user