Files
gods-eye-view/src/ui/panelChrome.js
T
Kevin Rajan c1dd4171db fix(ui): verify real PanelChrome rail ownership of compact Radio
Carry reviewed #816 onto current main without conflicts; preserve i18n updates, replace obsolete changelog edit with a fragment, and add a constructor-wiring regression alongside the owner and rail tests.

Credit: raysung-2026 (report), asturksever (review), quanticsoul4772 (Windows diagnosis).
2026-10-17 04:56:07 -05:00

679 lines
22 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 { t, msg } from '../i18n/index.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: 'street-level-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',
'street-level-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),
onPanelResized: (panelId) => this._onPanelResized(panelId),
});
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();
this._panelPosition._initPanelDrag();
}
_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',
'street-level-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() || t('panel');
const titleKey = collapsed
? msg('Expand {name}')
: msg('Collapse {name}');
btn.title = t(titleKey, { name: panelName });
btn.setAttribute('aria-label', t(titleKey, { name: panelName }));
if (panelEl.id === 'radio-panel') {
// Radio keeps the wording of its static button (context.html).
btn.title = t(
collapsed ? msg('Expand Radio') : msg('Collapse Radio'),
);
btn.setAttribute(
'aria-label',
t(
collapsed
? msg('Expand Radio section')
: msg('Collapse 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() || t('panel');
const titleKey = collapsed
? msg('Expand {name}')
: msg('Collapse {name}');
dockToggle.setAttribute('aria-expanded', String(!collapsed));
dockToggle.setAttribute('aria-label', t(titleKey, { name: panelName }));
dockToggle.title = t(titleKey, { name: 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?.();
}
/** @returns {boolean} Whether the panel was floating and is now docked. */
dockPanel(panelId) {
return this._panelPosition?.dockPanel?.(panelId) === true;
}
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.
// A floating window is outside the accordion both ways.
if (
explicit &&
!restore &&
!nextCollapsed &&
document.documentElement?.dataset.uiTheme === 'cyber' &&
panelEl.parentElement === this._rightPanelStack &&
!panelEl.classList.contains('panel-floating')
) {
for (const peer of this._rightPanelStack.children) {
if (
peer !== panelEl &&
peer.matches('[data-panel-id]') &&
!peer.classList.contains('panel-floating') &&
!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,
});
}
// A repeated Context-open request must clear a stale compact Radio mini
// even when no collapsed class changes.
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 === 'global-context-panel' &&
this._contextRadioDock?.classList.contains('disclosure-open')
) {
this._setRadioDisclosure?.(false);
}
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);
// Only a user's own collapse docks a floating window; restores, cockpit
// entry and accordion peers keep its place.
if (explicit && !restore)
this._panelPosition?.onPanelCollapsed?.(panelId, 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?.();
}
}