Files
gods-eye-view/src/ui/panelChrome.js
T
Kevin Rajan d9e2c8a7e7 fix(ui): enforce compact Radio ownership on right-rail restores
Close the overlay when adaptive layout restores an auto-collapsed Context directly. Regression covers the reachable rail reflow path; changelog credits the original report by raysung-2026 (#804).
2026-10-12 10:38:19 -05:00

643 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { UiLifetime } from './uiLifetime.js';
import { displayPanelScroller } from './displayPanelScroll.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: 'weather-panel' },
{ id: 'recent-imagery-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',
'weather-panel',
'recent-imagery-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._syncRailPanelCollapseButton(panel),
readDisplayScrollTop: () =>
this._displayPortalScrollRestoreOwner === 'standard'
? this._standardDisplayScrollTop
: displayPanelScroller(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);
}
_syncRailPanelCollapseButton(panelEl) {
// Adaptive rail layout may directly restore an auto-collapsed Context
// without invoking setPanelCollapsed(). Enforce Radio ownership there too.
if (
panelEl?.id === 'global-context-panel' &&
!panelEl.classList.contains('collapsed') &&
this._contextRadioDock?.classList.contains('disclosure-open')
) {
this._setRadioDisclosure?.(false);
}
this._syncPanelCollapseButton(panelEl);
}
_syncPanelCollapseButton(panelEl) {
const isRightRail = [
'pp-toggles',
'cctv-panel',
'weather-panel',
'recent-imagery-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') ||
panelEl.classList.contains('cyber-accordion-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') ||
panelEl.classList.contains('cyber-accordion-collapsed');
// A user opening a Cyber rail panel owns the whole accordion, including
// peers that were only presentation-collapsed during a saved-state restore.
if (
explicit &&
!restore &&
!nextCollapsed &&
document.documentElement?.dataset.uiTheme === 'cyber' &&
panelEl.parentElement === this._rightPanelStack
) {
for (const peer of this._rightPanelStack.children) {
if (
peer !== panelEl &&
peer.matches('[data-panel-id]') &&
!peer.hidden
) {
this.setPanelCollapsed(peer.id, true, {
explicit,
persist,
syncShare: false,
});
}
}
}
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;
}
// Reveal Radio's owner before the no-op check: the nested section may
// already be expanded while its Context parent is closed.
if (
document.documentElement?.dataset.uiTheme === 'cyber' &&
!nextCollapsed &&
panelId === 'radio-panel' &&
document
.getElementById('global-context-panel')
?.classList.contains('collapsed')
) {
this.setPanelCollapsed('global-context-panel', false, {
explicit,
restore,
persist,
syncShare,
});
}
// Parent ownership is an invariant even on a no-op disclosure request.
// A stale compact Radio disclosure is absolutely positioned outside the
// measured Context rail; clear it before returning from an already-open
// Context request so it cannot paint over the active Context body.
if (
!nextCollapsed &&
panelId === 'global-context-panel' &&
this._contextRadioDock?.classList.contains('disclosure-open')
) {
this._setRadioDisclosure?.(false);
}
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',
'cyber-accordion-collapsed',
);
if (
!nextCollapsed &&
this.cockpitView?.active &&
panelId === 'data-panel'
) {
this._cockpitContextCollapsedForDataPanel =
!this.cockpitView.contextCollapsed;
if (this._cockpitContextCollapsedForDataPanel) {
this.cockpitView.setContextCollapsed(true);
}
}
if (
!nextCollapsed &&
panelId === 'radio-panel' &&
document.documentElement?.dataset.uiTheme !== 'cyber' &&
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?.();
}
}