refactor(ui): extract adaptive panel rail layout

This commit is contained in:
Sameh Khamis committed 2026-09-12 19:53:17 -07:00
1 parent 83df873879
commit bf77ae61b8
18 files changed
+816 -521

No files matched your search

+3
View File
@@ -13,6 +13,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
- Extract adaptive panel rail placement and measurement into reusable UI modules,
preserving obstacle clearance, responsive allocation, disclosure and scroll behavior.
- Extract shared surface keyboard handling for the welcome launcher and Provider
Settings, preserving Tab/Escape behavior and releasing the listener on teardown.
+18
View File
@@ -213,3 +213,21 @@ current visible/enabled controls for each key; ordinary movement within those
boundaries remains native. The component honors already-handled keys and has no
app, server, storage or network dependencies. Its package boundary is checked
independently from the standalone screens that consume it.
## Panel rail layout
`gods-eye-view/ui/layout` exports synchronous `layoutLeftPanelRail` and
`layoutRightPanelRail` passes, `measurePanelNaturalHeight`, and the existing pure
corridor/allocation helpers. Separate modules own left placement, right placement,
DOM height measurement and rail geometry. They import no application, renderer,
server, storage or network modules; package checks build this entry independently.
Callers supply rail/obstacle DOM nodes, the viewport, HUD state, the preferred
panel, disclosure/retry callbacks and the left measurement cache. Right layout
reads the caller's Display scroll value at measurement time and restores it
within the resulting scroll range. The left pass notifies its caller after
alignment so the right pass can follow. Neither pass installs listeners, timers
or observers; construction/import does no work. Scheduling, preference writes,
share restoration and movement of controls between containers remain caller-owned.
Existing helper imports from `cockpitMath.js` and `rightRailPolicy.js` remain
compatible through re-exports.
+15
View File
@@ -1,5 +1,20 @@
# God's Eye View Current State
## Adaptive panel rail layout
`ui/layout` supplies the left/right rail layout passes, natural-height
measurement and pure corridor/allocation helpers. The UI facade passes live DOM
nodes, obstacle nodes, HUD presentation, preferred panels and callbacks. It
retains observers, frame scheduling, saved/share preferences and Cockpit portals.
The left rail keeps its measured collapsed heights and obstacle-safe corridor;
the right rail follows its top baseline and preserves Display's scroll owner.
Automatic collapse remains presentation only, prioritizes the latest explicit
panel, and honors keyboard focus on the right. Mobile layout still releases
desktop allocation styles. Stable Display allocation avoids unnecessary style
writes. This extraction does not change panel positions or layout defaults.
## Surface keyboard lifecycle
`ui/surfaces` owns the capture-phase keyboard listener, Tab cycling and return
+2 -1
View File
@@ -120,6 +120,7 @@
},
"./search": "./src/search/index.js",
"./ui/panels": "./src/ui/panelDisclosure.js",
"./ui/surfaces": "./src/ui/surfaceKeyboard.js"
"./ui/surfaces": "./src/ui/surfaceKeyboard.js",
"./ui/layout": "./src/ui/panelRails.js"
}
}
+14
View File
@@ -273,5 +273,19 @@
"exports": ["./ui/surfaces"],
"modules": ["src/ui/surfaceKeyboard.js"],
"external": []
},
"panel-rail-layout": {
"exports": [
"./ui/layout"
],
"modules": [
"src/ui/panelRails.js",
"src/ui/leftPanelRail.js",
"src/ui/rightPanelRail.js",
"src/ui/panelMeasurement.js",
"src/ui/panelRailGeometry.js",
"src/panelStackLayout.js"
],
"external": []
}
}
+5 -9
View File
@@ -525,12 +525,10 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
assert.match(rightObstacles[1], /#cockpit-hud \.cockpit-topline/);
assert.match(leftObstacles[1], /#cockpit-hud \.cockpit-topline > div/);
assert.match(rightObstacles[1], /#cockpit-hud \.cockpit-topline > div/);
const leftLayout = ui.match(
/_syncLeftPanelAdaptiveLayout\(\) \{([\s\S]*?)\n \}\n\n \/\*\*/,
);
const leftLayout = fs.readFileSync(new URL('./ui/leftPanelRail.js', import.meta.url), 'utf8');
assert.ok(leftLayout, 'left accordion layout pass is missing');
assert.doesNotMatch(
leftLayout[1],
leftLayout,
/setProperty\('--cockpit-utility-top'/,
'the right margin must not borrow the left accordion corridor: it is solved '
+ 'against left-lane obstacles and put the strip through the briefing card',
@@ -585,17 +583,15 @@ test('an expanded Cockpit left panel stays above Contact, HUD, and attribution',
/COCKPIT_PASSABLE_LEFT_OBSTACLE_SELECTOR|cockpitOverlaysPassable/,
'Cockpit obstacles must never be bypassed by an expanded map panel',
);
const leftLayout = ui.match(
/_syncLeftPanelAdaptiveLayout\(\) \{([\s\S]*?)\n \}\n\n \/\*\*/,
);
const leftLayout = fs.readFileSync(new URL('./ui/leftPanelRail.js', import.meta.url), 'utf8');
assert.ok(leftLayout, 'left accordion layout pass is missing');
assert.match(
leftLayout[1],
leftLayout,
/rect\.top >= baseTop\) \{\s*\n\s*bottomObstacles\.push\(\{ top: rect\.top \}\);/,
'every rendered lower-lane obstacle must constrain the panel corridor',
);
assert.match(
leftLayout[1],
leftLayout,
/safeBottom = resolveLeftStackBottomBoundary\(\{[\s\S]*?safeGap,\s*\n\s*\}\);/,
);
const cockpitHud = css.match(/#cockpit-hud\s*\{[\s\S]*?z-index:\s*(\d+);/);
+2 -42
View File
@@ -1,3 +1,5 @@
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
/** Normalize a heading into the [0, 360) range. */
export function normalizeHeading(value) {
if (!Number.isFinite(value)) return 0;
@@ -282,45 +284,3 @@ export function resolveCockpitContextReadout({ snapshot = null, info = null } =
contactLost,
};
}
/**
* Resolve a vertically centered panel slot inside a HUD rail while respecting
* live rectangles that intersect that rail above or below the viewport center.
*/
export function resolveHudRailLayout({
viewportHeight,
panelHeight,
laneLeft,
laneRight,
obstacles = [],
baseTop,
baseBottom,
gap = 12,
align = 'center',
}) {
if (![viewportHeight, panelHeight, laneLeft, laneRight, baseTop, baseBottom]
.every(Number.isFinite) || viewportHeight <= 0 || laneRight <= laneLeft) return null;
const midpoint = viewportHeight * 0.5;
let safeTop = Math.max(0, baseTop);
let safeBottom = Math.min(viewportHeight, baseBottom);
for (const rect of obstacles) {
if (![rect?.left, rect?.right, rect?.top, rect?.bottom].every(Number.isFinite)) continue;
if (rect.right <= laneLeft || rect.left >= laneRight || rect.bottom <= rect.top) continue;
if (rect.bottom <= midpoint) safeTop = Math.max(safeTop, rect.bottom + gap);
else if (rect.top >= midpoint) safeBottom = Math.min(safeBottom, rect.top - gap);
}
safeBottom = Math.max(safeTop, safeBottom);
const availableHeight = Math.max(0, safeBottom - safeTop);
const renderedHeight = Math.min(Math.max(0, panelHeight), availableHeight);
return {
top: align === 'start'
? safeTop
: safeTop + Math.max(0, (availableHeight - renderedHeight) * 0.5),
maxHeight: availableHeight,
safeTop,
safeBottom,
constrained: panelHeight > availableHeight,
};
}
+3 -2
View File
@@ -479,9 +479,10 @@ test('the full-width rail cannot inherit a height that overrides its floor', ()
// over-constrained. It is safe only because the rail's layout pass switches
// to a mobile mode at the SAME breakpoint and removes both the class and the
// custom property. Pin that, or the exemption above is unearned.
const gate = ui.indexOf("window.matchMedia('(max-width: 720px)')");
const rail = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'rightPanelRail.js'), 'utf8');
const gate = rail.indexOf("windowRef.matchMedia('(max-width: 720px)')");
assert.ok(gate > 0, 'the rail layout pass no longer keys off (max-width: 720px)');
const mobileBranch = ui.slice(gate, ui.indexOf("layoutMode = 'mobile'", gate) + 40);
const mobileBranch = rail.slice(gate, rail.indexOf("layoutMode = 'mobile'", gate) + 40);
assert.match(mobileBranch, /stack\.classList\.remove\('layout-focus'\)/);
assert.match(mobileBranch, /stack\.style\.removeProperty\('--right-stack-max-height'\)/);
});
+18 -15
View File
@@ -1,6 +1,9 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const leftRail = readFileSync(new URL('./ui/leftPanelRail.js', import.meta.url), 'utf8');
const rightRail = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8');
const rails = leftRail + rightRail;
import {
allocatePanelStackHeights,
panelStackAutoCollapseIndices,
@@ -135,7 +138,7 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
assert.doesNotMatch(ui, /_enforce(?:Left|Right)PanelAccordion/);
assert.match(ui, /classList\.add\('collapsed', 'layout-auto-collapsed'\)/);
assert.match(rails, /classList\.add\('collapsed', 'layout-auto-collapsed'\)/);
assert.match(ui, /classList\.remove\('collapsed', 'layout-auto-collapsed'\)/);
assert.match(ui, /reconsiderAutoCollapse/);
assert.match(
@@ -146,32 +149,32 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
ui,
/_scheduleLeftPanelLayout\(\{[\s\S]*?reconsiderAutoCollapse: this\._leftPanelStack\?\.contains\(panelEl\) === true/,
);
assert.match(ui, /collapseLaterPanels: shouldFocus && this\.hud\.getVariant\(\) === 'tactical'/);
assert.match(rails, /collapseLaterPanels: shouldFocus && hud\.variant === 'tactical'/);
assert.match(ui, /this\._leftStackPreferredPanelId = leftOwnerPanel\.id;/);
assert.match(
ui,
leftRail,
/preferredExpandedPanel[\s\S]*?\[preferredExpandedPanel, \.\.\.expandedPanelsInDomOrder/,
'the latest explicitly opened left panel must receive primary allocation',
);
assert.match(ui, /this\._rightStackPreferredPanelId = rightOwnerPanel\.id;/);
assert.match(
ui,
/panel\.id === this\._rightStackPreferredPanelId[\s\S]*?\[preferredExpandedPanel, \.\.\.expandedPanelsInDomOrder/,
rightRail,
/panel\.id === preferredPanelId[\s\S]*?\[preferredExpandedPanel, \.\.\.expandedPanelsInDomOrder/,
'the latest explicitly opened right panel must receive primary allocation',
);
assert.match(ui, /panelId === 'radio-panel'[\s\S]*?document\.getElementById\('global-context-panel'\)/);
assert.match(ui, /focusedExpandedPanel = expandedPanelsInDomOrder\.find\(\(panel\) => panel\.contains\(document\.activeElement\)\)/);
assert.match(rightRail, /focusedExpandedPanel = expandedPanelsInDomOrder\.find\(\(panel\) => panel\.contains\(documentRef\.activeElement\)\)/);
assert.match(ui, /setAttribute\('aria-expanded', String\(!collapsed\)\)/);
assert.match(ui, /--left-panel-allocated-height/);
assert.match(ui, /--right-panel-allocated-height/);
assert.match(leftRail, /--left-panel-allocated-height/);
assert.match(rightRail, /--right-panel-allocated-height/);
assert.match(
ui,
/expandedPanels[\s\S]*?removeProperty\('--left-panel-allocated-height'\)[\s\S]*?_measureLeftPanelNaturalHeight/,
leftRail,
/expandedPanels[\s\S]*?removeProperty\('--left-panel-allocated-height'\)[\s\S]*?measurePanelNaturalHeight/,
'left intrinsic measurement must clear the prior allocation first',
);
assert.match(
ui,
/panel !== this\._ppToggles[\s\S]*?removeProperty\('--right-panel-allocated-height'\)[\s\S]*?const naturalHeight/,
rightRail,
/panel !== displayPanel[\s\S]*?removeProperty\('--right-panel-allocated-height'\)[\s\S]*?const naturalHeight/,
'right intrinsic measurement must retain Display allocation while clearing other panel allocations',
);
assert.match(css, /var\(--left-panel-allocated-height/);
@@ -182,7 +185,7 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
'focus mode must hide every collapsed sibling, including presentation-only auto-collapses',
);
assert.doesNotMatch(css, /layout-focus > \[data-panel-id\]\.collapsed:not\(\.layout-auto-collapsed\)/);
assert.match(ui, /const hiddenSibling = shouldFocus && panel\.classList\.contains\('collapsed'\);/);
assert.match(leftRail, /const hiddenSibling = shouldFocus && panel\.classList\.contains\('collapsed'\);/);
});
test('share-panel state excludes responsive collapse and preserves recipient preferences', () => {
@@ -213,8 +216,8 @@ test('parameterized Display presets keep one stable scroll owner', () => {
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
assert.match(css, /#pp-toggles:not\(\.collapsed\) > #param-slider-panel\.active\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?max-height:\s*none;[\s\S]*?overflow-y:\s*visible;/);
assert.match(ui, /const displayScrollTop = this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?this\._ppToggles\?\.scrollTop \|\| 0/);
assert.match(ui, /this\._ppToggles\.scrollTop = Math\.min\(displayScrollTop, maxScrollTop\);/);
assert.match(ui, /readDisplayScrollTop: \(\) => this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?this\._ppToggles\?\.scrollTop \|\| 0/);
assert.match(rightRail, /displayPanel\.scrollTop = Math\.min\(displayScrollTop, maxScrollTop\);/);
assert.match(
ui,
/this\._sliderPanel\.classList\.remove\('active'\);\s*this\._scheduleRightPanelLayout\(\);/,
+1 -12
View File
@@ -1,15 +1,4 @@
/**
* Tactical HUD gives an expanded right-rail panel the whole control lane.
* Other HUD layouts keep collapsed launchers visible for quick switching.
*
* @param {object} input Current rail state.
* @param {string} input.hudVariant Active HUD layout variant.
* @param {boolean} input.hasExpandedPanel Whether any rail panel is expanded.
* @returns {boolean} Whether collapsed sibling launchers should be hidden.
*/
export function shouldHideCollapsedRightPanels({ hudVariant, hasExpandedPanel }) {
return hudVariant === 'tactical' && Boolean(hasExpandedPanel);
}
export { shouldHideCollapsedRightPanels } from './ui/panelRailGeometry.js';
const GLOBAL_CONTEXT_EXPLICIT_ACTIONS = new Set([
'contacts',
+2 -2
View File
@@ -32,9 +32,9 @@ test('other HUD layouts keep collapsed right-rail launchers visible', () => {
});
test('desktop Display participates in Tactical exclusivity without changing mobile Display behavior', () => {
const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const ui = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8');
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
assert.match(ui, /const isMobile = window\.matchMedia\('\(max-width: 720px\)'\)\.matches/);
assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/);
assert.match(
ui,
/!panel\.classList\.contains\('collapsed'\) && \(!isMobile \|\| panel\.id !== 'pp-toggles'\)/,
+24 -438
View File
@@ -1,3 +1,4 @@
import { layoutLeftPanelRail, layoutRightPanelRail } from './ui/panelRails.js';
import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js';
import * as Cesium from 'cesium';
import { retroShader } from './styles/retro.js';
@@ -109,14 +110,7 @@ import {
} from './contextModePolicy.js';
import {
shouldExpandGlobalContextPanel,
shouldHideCollapsedRightPanels,
} from './rightRailPolicy.js';
import {
allocatePanelStackHeights,
panelStackAutoCollapseIndices,
resolveLeftStackBottomBoundary,
resolvePanelStackCorridor,
} from './panelStackLayout.js';
import {
resolveCockpitUtilityAnchor,
resolveCockpitUtilityLayout,
@@ -6868,187 +6862,20 @@ export class StyleManager {
* @returns {void}
*/
_syncRightPanelAdaptiveLayout() {
const stack = this._rightPanelStack;
if (!stack) return;
const panels = [...stack.children].filter((panel) => panel.matches('[data-panel-id]'));
if (!this.hud.visible || this.hud.getVariant() !== 'tactical') {
for (const panel of panels.filter((item) => item.classList.contains('layout-auto-collapsed'))) {
panel.classList.remove('collapsed', 'layout-auto-collapsed');
this._syncPanelCollapseButton(panel);
}
}
const isMobile = window.matchMedia('(max-width: 720px)').matches;
const hasExpandedPanel = panels.some((panel) => (
!panel.classList.contains('collapsed') && (!isMobile || panel.id !== 'pp-toggles')
));
const exclusive = shouldHideCollapsedRightPanels({
hudVariant: this.hud.getVariant(),
hasExpandedPanel,
layoutRightPanelRail({
stack: this._rightPanelStack,
obstacles: document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR),
windowRef: window,
hud: { visible: this.hud.visible, variant: this.hud.getVariant() },
preferredPanelId: this._rightStackPreferredPanelId,
onCollapse: (panel) => this._syncPanelCollapseButton(panel),
onRetry: () => this._scheduleRightPanelLayout(),
leftStack: this._leftPanelStack,
displayPanel: this._ppToggles,
readDisplayScrollTop: () => this._displayPortalScrollRestoreOwner === 'standard'
? this._standardDisplayScrollTop
: (this._ppToggles?.scrollTop || 0),
});
stack.classList.toggle('layout-exclusive', exclusive);
for (const panel of panels) {
if (exclusive && panel.classList.contains('collapsed')) panel.setAttribute('aria-hidden', 'true');
else panel.removeAttribute('aria-hidden');
}
if (isMobile) {
stack.classList.remove('layout-focus');
stack.style.removeProperty('--right-stack-safe-top');
stack.style.removeProperty('--right-stack-max-height');
for (const panel of panels) panel.style.removeProperty('--right-panel-allocated-height');
stack.dataset.layoutMode = 'mobile';
return;
}
const viewportHeight = Math.max(1, window.innerHeight);
const safeGap = Math.max(8, viewportHeight * 0.012);
const stackRect = stack.getBoundingClientRect();
const leftStackTop = this._leftPanelStack?.getBoundingClientRect().top;
const alignedTop = Number.isFinite(leftStackTop)
? leftStackTop
: viewportHeight * 0.26;
const obstacleRects = [];
for (const obstacle of document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR)) {
if (stack.contains(obstacle)) continue;
let hiddenByAncestor = false;
for (let element = obstacle; element; element = element.parentElement) {
const style = getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
hiddenByAncestor = true;
break;
}
}
if (hiddenByAncestor) continue;
const rect = obstacle.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) continue;
obstacleRects.push({
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
});
}
const visiblePanels = panels.filter((panel) => (
!exclusive || !panel.classList.contains('collapsed')
));
const displayScrollTop = this._displayPortalScrollRestoreOwner === 'standard'
? this._standardDisplayScrollTop
: (this._ppToggles?.scrollTop || 0);
// Measure intrinsic content, not the allocation written by the previous
// layout pass. Display is the exception: its own scrollHeight already
// exposes every control, and removing its live allocation can reset the
// user's scroll position while HUD or preset content is settling.
for (const panel of visiblePanels) {
if (!panel.classList.contains('collapsed') && panel !== this._ppToggles) {
panel.style.removeProperty('--right-panel-allocated-height');
}
}
const gap = parseFloat(getComputedStyle(stack).rowGap) || 0;
const naturalHeight = visiblePanels.reduce((total, panel) => (
total + Math.max(
panel.getBoundingClientRect().height,
panel.scrollHeight || 0,
panel.classList.contains('collapsed') ? 42 : 0,
)
), 0) + gap * Math.max(0, visiblePanels.length - 1);
const layout = resolveHudRailLayout({
viewportHeight,
panelHeight: naturalHeight,
laneLeft: stackRect.left,
laneRight: stackRect.right,
obstacles: obstacleRects,
baseTop: alignedTop,
baseBottom: viewportHeight * 0.96,
gap: safeGap,
align: 'start',
});
if (!layout) return;
const { safeTop, safeBottom, maxHeight: availableHeight } = layout;
const stabilityBand = viewportHeight * 0.01;
const wasFocused = stack.classList.contains('layout-focus');
const shouldFocus = wasFocused
? naturalHeight > availableHeight - stabilityBand * 2
: naturalHeight > availableHeight - stabilityBand;
const layoutTop = shouldFocus ? safeTop : layout.top;
const collapsedHeight = visiblePanels.reduce((total, panel) => (
panel.classList.contains('collapsed')
? total + panel.getBoundingClientRect().height
: total
), 0);
const expandedPanelsInDomOrder = visiblePanels.filter((panel) => !panel.classList.contains('collapsed'));
const focusedExpandedPanel = expandedPanelsInDomOrder.find((panel) => panel.contains(document.activeElement));
const preferredExpandedPanel = expandedPanelsInDomOrder.find(
(panel) => panel.id === this._rightStackPreferredPanelId,
) || focusedExpandedPanel;
// Match the left lane: allocation order follows the latest explicit
// disclosure, not DOM order. A focused panel is the fallback owner so
// temporary presentation collapse never strands keyboard focus.
const expandedPanels = preferredExpandedPanel
? [preferredExpandedPanel, ...expandedPanelsInDomOrder.filter((panel) => panel !== preferredExpandedPanel)]
: expandedPanelsInDomOrder;
const expandedAvailableHeight = Math.max(
0,
safeBottom - layoutTop - collapsedHeight - gap * Math.max(0, visiblePanels.length - 1),
);
const expandedHeights = allocatePanelStackHeights({
naturalHeights: expandedPanels.map((panel) => Math.max(
panel.getBoundingClientRect().height,
panel.scrollHeight || 0,
)),
availableHeight: expandedAvailableHeight,
});
const autoCollapseIndices = this.hud.visible ? panelStackAutoCollapseIndices({
naturalHeights: expandedPanels.map((panel) => Math.max(
panel.getBoundingClientRect().height,
panel.scrollHeight || 0,
)),
allocatedHeights: expandedHeights,
collapseLaterPanels: shouldFocus && this.hud.getVariant() === 'tactical',
}) : [];
if (autoCollapseIndices.length) {
for (const index of autoCollapseIndices) {
const panel = expandedPanels[index];
panel.classList.add('collapsed', 'layout-auto-collapsed');
this._syncPanelCollapseButton(panel);
}
this._scheduleRightPanelLayout();
return;
}
// Write-if-changed. This pass runs on the 500 ms stats cadence, and an
// unconditional REMOVE-then-SET of an unchanged allocation is two style
// mutations per tick on `#pp-toggles` (the one panel the measure-strip
// above deliberately skips) — churn that reads as a genuine panel move to
// the world-overlay host's occluder observer and defeats parked-idle
// render savings. Only a real allocation change may touch the attribute.
expandedPanels.forEach((panel, index) => {
const next = `${expandedHeights[index].toFixed(1)}px`;
if (panel.style.getPropertyValue('--right-panel-allocated-height') !== next) {
panel.style.setProperty('--right-panel-allocated-height', next);
}
});
for (const panel of panels) {
if (expandedPanels.includes(panel)) continue;
panel.style.removeProperty('--right-panel-allocated-height');
}
stack.style.setProperty('--right-stack-safe-top', `${layoutTop.toFixed(1)}px`);
stack.style.setProperty('--right-stack-max-height', `${Math.max(0, safeBottom - layoutTop).toFixed(1)}px`);
stack.classList.toggle('layout-focus', shouldFocus);
stack.dataset.layoutMode = shouldFocus ? 'focus' : 'normal';
stack.dataset.safeTop = layoutTop.toFixed(1);
stack.dataset.safeBottom = safeBottom.toFixed(1);
stack.dataset.availableHeight = availableHeight.toFixed(1);
stack.dataset.requiredHeight = naturalHeight.toFixed(1);
stack.dataset.expandedCount = String(expandedPanels.length);
if (this._ppToggles && expandedPanels.includes(this._ppToggles)) {
const maxScrollTop = Math.max(0, this._ppToggles.scrollHeight - this._ppToggles.clientHeight);
this._ppToggles.scrollTop = Math.min(displayScrollTop, maxScrollTop);
}
}
/**
@@ -7152,40 +6979,6 @@ export class StyleManager {
});
}
/**
* Estimates an expanded panel's unconstrained content height from its
* visible direct children and their scroll extents. This avoids treating a
* flex-grown panel as naturally tall while still accounting for nested lists.
* @param {HTMLElement} panel - Expanded accordion panel.
* @returns {number} Natural height in rendered CSS pixels.
*/
_measureLeftPanelNaturalHeight(panel) {
const inner = [...panel.children].find((child) => !child.classList.contains('panel-glow'));
if (!inner) return Math.ceil(panel.scrollHeight || panel.getBoundingClientRect().height);
const innerRect = inner.getBoundingClientRect();
const panelStyle = getComputedStyle(panel);
const innerStyle = getComputedStyle(inner);
const paddingBottom = parseFloat(innerStyle.paddingBottom) || 0;
let contentBottom = parseFloat(innerStyle.paddingTop) || 0;
for (const child of inner.children) {
const childStyle = getComputedStyle(child);
if (childStyle.display === 'none' || childStyle.visibility === 'hidden') continue;
const childRect = child.getBoundingClientRect();
const marginBottom = parseFloat(childStyle.marginBottom) || 0;
const naturalChildHeight = Math.max(childRect.height, child.scrollHeight || 0);
const childBottom = childRect.top - innerRect.top + naturalChildHeight + marginBottom;
contentBottom = Math.max(contentBottom, childBottom);
}
const wrapperChrome = (parseFloat(panelStyle.borderTopWidth) || 0)
+ (parseFloat(panelStyle.borderBottomWidth) || 0)
+ (parseFloat(panelStyle.paddingTop) || 0)
+ (parseFloat(panelStyle.paddingBottom) || 0);
return Math.ceil(contentBottom + paddingBottom + wrapperChrome);
}
/**
* Measures a live obstacle-free corridor for the left accordion and toggles
* focus mode only when the expanded panel plus sibling labels cannot fit.
@@ -7193,224 +6986,17 @@ export class StyleManager {
* @returns {void}
*/
_syncLeftPanelAdaptiveLayout() {
const stack = this._leftPanelStack;
if (!stack) return;
const panels = [...stack.querySelectorAll(':scope > [data-panel-id]')];
if (!panels.length) return;
if (!this.hud.visible || this.hud.getVariant() !== 'tactical') {
for (const panel of panels.filter((item) => item.classList.contains('layout-auto-collapsed'))) {
panel.classList.remove('collapsed', 'layout-auto-collapsed');
this._syncPanelCollapseButton(panel);
}
}
// The existing narrow-screen composition has its own full-width stack.
// Keep this desktop lane engine from fighting those dedicated rules.
if (window.matchMedia('(max-width: 720px)').matches) {
stack.classList.remove('layout-focus');
stack.classList.remove('layout-tail');
stack.style.removeProperty('--left-stack-safe-top');
stack.style.removeProperty('--left-stack-safe-bottom');
stack.style.removeProperty('--left-stack-centered-height');
stack.dataset.layoutMode = 'mobile';
for (const panel of panels) {
panel.removeAttribute('aria-hidden');
panel.style.removeProperty('--left-panel-allocated-height');
}
return;
}
const viewportHeight = Math.max(1, window.innerHeight);
const stackRect = stack.getBoundingClientRect();
const baseTop = viewportHeight * 0.26;
const baseBottomInset = viewportHeight * 0.04;
const safeGap = viewportHeight * 0.012;
let obstacleSafeTop = viewportHeight * 0.04;
let safeTop = baseTop;
let safeBottom = viewportHeight - baseBottomInset;
const bottomObstacles = [];
for (const obstacle of document.querySelectorAll(LEFT_STACK_OBSTACLE_SELECTOR)) {
if (stack.contains(obstacle)) continue;
let hiddenByAncestor = false;
for (let element = obstacle; element; element = element.parentElement) {
const style = getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
hiddenByAncestor = true;
break;
}
}
if (hiddenByAncestor) continue;
const rect = obstacle.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) continue;
const overlapsHorizontally = rect.right > stackRect.left && rect.left < stackRect.right;
if (!overlapsHorizontally) continue;
if (rect.top < baseTop && rect.bottom <= viewportHeight * 0.5) {
const obstacleBottom = rect.bottom + safeGap;
obstacleSafeTop = Math.max(obstacleSafeTop, obstacleBottom);
safeTop = Math.max(safeTop, obstacleBottom);
} else if (rect.top >= baseTop) {
bottomObstacles.push({ top: rect.top });
}
}
safeBottom = resolveLeftStackBottomBoundary({
baseBottom: safeBottom,
obstacles: bottomObstacles,
safeGap,
layoutLeftPanelRail({
stack: this._leftPanelStack,
obstacles: document.querySelectorAll(LEFT_STACK_OBSTACLE_SELECTOR),
windowRef: window,
hud: { visible: this.hud.visible, variant: this.hud.getVariant() },
preferredPanelId: this._leftStackPreferredPanelId,
onCollapse: (panel) => this._syncPanelCollapseButton(panel),
onRetry: () => this._scheduleLeftPanelLayout(),
collapsedHeights: this._leftStackCollapsedHeights,
onAligned: () => this._scheduleRightPanelLayout(),
});
const obstacleSafeBottom = safeBottom;
// Keep the accordion visually centered when the balanced corridor remains
// useful. During live viewport-height changes, retain the aligned lane
// instead of extending a tiny midpoint corridor through a lower obstacle.
const minimumLaneHeight = viewportHeight * 0.16;
({ safeTop, safeBottom } = resolvePanelStackCorridor({
viewportHeight,
safeTop,
safeBottom,
obstacleSafeTop,
obstacleSafeBottom,
minimumHeight: minimumLaneHeight,
}));
const viewportMidpoint = viewportHeight * 0.5;
for (const panel of panels) {
const rect = panel.getBoundingClientRect();
if (panel.classList.contains('collapsed') && rect.height > 0) {
this._leftStackCollapsedHeights.set(panel.id, rect.height);
}
}
const expandedPanelsInDomOrder = panels.filter((panel) => !panel.classList.contains('collapsed'));
const preferredExpandedPanel = expandedPanelsInDomOrder.find(
(panel) => panel.id === this._leftStackPreferredPanelId,
);
// Auto-collapse is a presentation fallback, not permission to undo the
// user's newest disclosure. Measure and allocate that explicitly opened
// panel first so an older expanded sibling yields when the corridor cannot
// usefully present both (for example Map Stack followed by Scenes).
const expandedPanels = preferredExpandedPanel
? [preferredExpandedPanel, ...expandedPanelsInDomOrder.filter((panel) => panel !== preferredExpandedPanel)]
: expandedPanelsInDomOrder;
// Clear the prior pass before reading intrinsic heights. The allocated
// outer height and the inner scroller otherwise feed their constrained
// size back into the next HUD-mode calculation.
for (const panel of expandedPanels) {
panel.style.removeProperty('--left-panel-allocated-height');
}
const availableHeight = Math.max(0, safeBottom - safeTop);
const naturalExpandedHeights = expandedPanels.map((panel) => this._measureLeftPanelNaturalHeight(panel));
const naturalExpandedHeight = naturalExpandedHeights.reduce((sum, height) => sum + height, 0);
const siblingHeight = panels.reduce((total, panel) => {
if (!panel.classList.contains('collapsed')) return total;
const measured = this._leftStackCollapsedHeights.get(panel.id);
return total + (measured || panel.getBoundingClientRect().height || 0);
}, 0);
let requiredHeight = siblingHeight;
const rowGap = parseFloat(getComputedStyle(stack).rowGap) || 0;
if (expandedPanels.length) {
requiredHeight += naturalExpandedHeight;
requiredHeight += rowGap * Math.max(0, panels.length - 1);
} else {
requiredHeight += rowGap * Math.max(0, panels.length - 1);
}
const wasFocused = stack.classList.contains('layout-focus');
const wasTail = stack.classList.contains('layout-tail');
const wasConstrained = wasFocused || wasTail;
const stabilityBand = viewportHeight * 0.01;
const exceedsCenteredCorridor = expandedPanels.length > 0 && (wasConstrained
? requiredHeight > availableHeight - stabilityBand * 2
: requiredHeight > availableHeight - stabilityBand);
const tailRequiredHeight = naturalExpandedHeight
+ siblingHeight
+ rowGap * Math.max(0, panels.length - 1);
// A compact expansion should not make the whole control stack jump down
// merely to center a few short rows. Preserve the normal top anchor when
// the centered stack would begin below it; tall stacks can still grow
// upward around the viewport midpoint as their content requires.
const centeredTailTop = viewportMidpoint - tailRequiredHeight * 0.5;
const tailLayoutTop = Math.min(centeredTailTop, safeTop);
const tailLayoutBottom = tailLayoutTop + tailRequiredHeight;
const tailAvailableHeight = Math.max(0, obstacleSafeBottom - obstacleSafeTop);
const tailTolerance = wasTail ? stabilityBand : -stabilityBand;
const shouldTail = expandedPanels.length > 0
&& tailLayoutTop >= obstacleSafeTop - tailTolerance
&& tailLayoutBottom <= obstacleSafeBottom + tailTolerance;
const shouldFocus = exceedsCenteredCorridor && !shouldTail;
// Focus mode owns the lane, so let every expanded panel share the full
// obstacle-safe corridor. Tail/normal layouts keep the balanced
// viewport centering used for compact accordion stacks.
const layoutTop = shouldFocus
? obstacleSafeTop
: shouldTail ? tailLayoutTop : safeTop;
const layoutBottom = shouldFocus
? obstacleSafeBottom
: shouldTail ? tailLayoutBottom : safeBottom;
const topPct = (layoutTop / viewportHeight) * 100;
const bottomPct = ((viewportHeight - layoutBottom) / viewportHeight) * 100;
const topValue = `${topPct.toFixed(3)}vh`;
const bottomValue = `${bottomPct.toFixed(3)}vh`;
const expandedAvailableHeight = shouldFocus
? Math.max(0, layoutBottom - layoutTop
- rowGap * Math.max(0, expandedPanels.length - 1))
: naturalExpandedHeight;
const allocatedExpandedHeights = allocatePanelStackHeights({
naturalHeights: naturalExpandedHeights,
availableHeight: expandedAvailableHeight,
});
const autoCollapseIndices = this.hud.visible ? panelStackAutoCollapseIndices({
naturalHeights: naturalExpandedHeights,
allocatedHeights: allocatedExpandedHeights,
collapseLaterPanels: shouldFocus && this.hud.getVariant() === 'tactical',
}) : [];
if (autoCollapseIndices.length) {
for (const index of autoCollapseIndices) {
const panel = expandedPanels[index];
panel.classList.add('collapsed', 'layout-auto-collapsed');
this._syncPanelCollapseButton(panel);
}
this._scheduleLeftPanelLayout();
return;
}
if (stack.style.getPropertyValue('--left-stack-safe-top') !== topValue) {
stack.style.setProperty('--left-stack-safe-top', topValue);
}
if (stack.style.getPropertyValue('--left-stack-safe-bottom') !== bottomValue) {
stack.style.setProperty('--left-stack-safe-bottom', bottomValue);
}
stack.style.removeProperty('--left-stack-centered-height');
for (const panel of panels) panel.style.removeProperty('--left-panel-allocated-height');
expandedPanels.forEach((panel, index) => {
panel.style.setProperty('--left-panel-allocated-height', `${allocatedExpandedHeights[index].toFixed(1)}px`);
});
stack.classList.toggle('layout-focus', shouldFocus);
stack.classList.toggle('layout-tail', shouldTail);
stack.dataset.layoutMode = shouldFocus ? 'focus' : shouldTail ? 'tail' : 'normal';
stack.dataset.safeTopPct = topPct.toFixed(2);
stack.dataset.safeBottomPct = (100 - bottomPct).toFixed(2);
stack.dataset.availableHeightPct = ((availableHeight / viewportHeight) * 100).toFixed(2);
stack.dataset.requiredHeightPct = ((requiredHeight / viewportHeight) * 100).toFixed(2);
stack.dataset.tailAvailableHeightPct = ((tailAvailableHeight / viewportHeight) * 100).toFixed(2);
stack.dataset.expandedCount = String(expandedPanels.length);
// Cockpit Display/Radio live in the opposite margin and no longer borrow
// this corridor: the left accordion's top is solved against left-lane
// obstacles, which put the strip straight through the briefing card.
// CockpitView.syncSignalLayout() owns `--cockpit-utility-top` instead.
for (const panel of panels) {
const hiddenSibling = shouldFocus && panel.classList.contains('collapsed');
if (hiddenSibling) panel.setAttribute('aria-hidden', 'true');
else panel.removeAttribute('aria-hidden');
}
// The right controls share this top baseline; update them after the left
// accordion commits an HUD-variant or obstacle-driven position change.
this._scheduleRightPanelLayout();
}
/**
+254
View File
@@ -0,0 +1,254 @@
import {
allocatePanelStackHeights,
panelStackAutoCollapseIndices,
resolveLeftStackBottomBoundary,
resolvePanelStackCorridor,
} from '../panelStackLayout.js';
import { measurePanelNaturalHeight } from './panelMeasurement.js';
/**
* Measure and place the left panel rail for one synchronous layout pass.
* The caller owns scheduling, obstacle selection, disclosure preferences and
* persistence. Auto-collapse is presentation only and reports through callbacks.
* @param {object} options Live DOM and caller policy.
* @param {HTMLElement} options.stack Rail element.
* @param {Iterable<HTMLElement>} options.obstacles Caller-selected obstacle nodes.
* @param {Window} options.windowRef Viewport and style reader.
* @param {{visible: boolean, variant: string}} options.hud Current HUD presentation.
* @param {string} options.preferredPanelId Most recently opened panel.
* @param {Function} options.onCollapse Update a panel's disclosure chrome.
* @param {Function} options.onRetry Request another pass after automatic collapse.
* @param {Function} [options.getComputedStyle] Optional DOM style reader override.
* @param {Map<string, number>} options.collapsedHeights Caller-owned measurement cache.
* @param {Function} options.onAligned Notify the caller after the left rail moves.
*/
export function layoutLeftPanelRail({
stack,
obstacles,
windowRef,
hud,
preferredPanelId,
onCollapse,
onRetry,
collapsedHeights,
onAligned,
getComputedStyle = (element) => windowRef.getComputedStyle(element)
}) {
if (!stack) return;
const panels = [...stack.querySelectorAll(':scope > [data-panel-id]')];
if (!panels.length) return;
if (!hud.visible || hud.variant !== 'tactical') {
for (const panel of panels.filter((item) => item.classList.contains('layout-auto-collapsed'))) {
panel.classList.remove('collapsed', 'layout-auto-collapsed');
onCollapse(panel);
}
}
// The existing narrow-screen composition has its own full-width stack.
// Keep this desktop lane engine from fighting those dedicated rules.
if (windowRef.matchMedia('(max-width: 720px)').matches) {
stack.classList.remove('layout-focus');
stack.classList.remove('layout-tail');
stack.style.removeProperty('--left-stack-safe-top');
stack.style.removeProperty('--left-stack-safe-bottom');
stack.style.removeProperty('--left-stack-centered-height');
stack.dataset.layoutMode = 'mobile';
for (const panel of panels) {
panel.removeAttribute('aria-hidden');
panel.style.removeProperty('--left-panel-allocated-height');
}
return;
}
const viewportHeight = Math.max(1, windowRef.innerHeight);
const stackRect = stack.getBoundingClientRect();
const baseTop = viewportHeight * 0.26;
const baseBottomInset = viewportHeight * 0.04;
const safeGap = viewportHeight * 0.012;
let obstacleSafeTop = viewportHeight * 0.04;
let safeTop = baseTop;
let safeBottom = viewportHeight - baseBottomInset;
const bottomObstacles = [];
for (const obstacle of obstacles) {
if (stack.contains(obstacle)) continue;
let hiddenByAncestor = false;
for (let element = obstacle; element; element = element.parentElement) {
const style = getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
hiddenByAncestor = true;
break;
}
}
if (hiddenByAncestor) continue;
const rect = obstacle.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) continue;
const overlapsHorizontally = rect.right > stackRect.left && rect.left < stackRect.right;
if (!overlapsHorizontally) continue;
if (rect.top < baseTop && rect.bottom <= viewportHeight * 0.5) {
const obstacleBottom = rect.bottom + safeGap;
obstacleSafeTop = Math.max(obstacleSafeTop, obstacleBottom);
safeTop = Math.max(safeTop, obstacleBottom);
} else if (rect.top >= baseTop) {
bottomObstacles.push({ top: rect.top });
}
}
safeBottom = resolveLeftStackBottomBoundary({
baseBottom: safeBottom,
obstacles: bottomObstacles,
safeGap,
});
const obstacleSafeBottom = safeBottom;
// Keep the accordion visually centered when the balanced corridor remains
// useful. During live viewport-height changes, retain the aligned lane
// instead of extending a tiny midpoint corridor through a lower obstacle.
const minimumLaneHeight = viewportHeight * 0.16;
({ safeTop, safeBottom } = resolvePanelStackCorridor({
viewportHeight,
safeTop,
safeBottom,
obstacleSafeTop,
obstacleSafeBottom,
minimumHeight: minimumLaneHeight,
}));
const viewportMidpoint = viewportHeight * 0.5;
for (const panel of panels) {
const rect = panel.getBoundingClientRect();
if (panel.classList.contains('collapsed') && rect.height > 0) {
collapsedHeights.set(panel.id, rect.height);
}
}
const expandedPanelsInDomOrder = panels.filter((panel) => !panel.classList.contains('collapsed'));
const preferredExpandedPanel = expandedPanelsInDomOrder.find(
(panel) => panel.id === preferredPanelId,
);
// Auto-collapse is a presentation fallback, not permission to undo the
// user's newest disclosure. Measure and allocate that explicitly opened
// panel first so an older expanded sibling yields when the corridor cannot
// usefully present both (for example Map Stack followed by Scenes).
const expandedPanels = preferredExpandedPanel
? [preferredExpandedPanel, ...expandedPanelsInDomOrder.filter((panel) => panel !== preferredExpandedPanel)]
: expandedPanelsInDomOrder;
// Clear the prior pass before reading intrinsic heights. The allocated
// outer height and the inner scroller otherwise feed their constrained
// size back into the next HUD-mode calculation.
for (const panel of expandedPanels) {
panel.style.removeProperty('--left-panel-allocated-height');
}
const availableHeight = Math.max(0, safeBottom - safeTop);
const naturalExpandedHeights = expandedPanels.map((panel) => measurePanelNaturalHeight(panel, getComputedStyle));
const naturalExpandedHeight = naturalExpandedHeights.reduce((sum, height) => sum + height, 0);
const siblingHeight = panels.reduce((total, panel) => {
if (!panel.classList.contains('collapsed')) return total;
const measured = collapsedHeights.get(panel.id);
return total + (measured || panel.getBoundingClientRect().height || 0);
}, 0);
let requiredHeight = siblingHeight;
const rowGap = parseFloat(getComputedStyle(stack).rowGap) || 0;
if (expandedPanels.length) {
requiredHeight += naturalExpandedHeight;
requiredHeight += rowGap * Math.max(0, panels.length - 1);
} else {
requiredHeight += rowGap * Math.max(0, panels.length - 1);
}
const wasFocused = stack.classList.contains('layout-focus');
const wasTail = stack.classList.contains('layout-tail');
const wasConstrained = wasFocused || wasTail;
const stabilityBand = viewportHeight * 0.01;
const exceedsCenteredCorridor = expandedPanels.length > 0 && (wasConstrained
? requiredHeight > availableHeight - stabilityBand * 2
: requiredHeight > availableHeight - stabilityBand);
const tailRequiredHeight = naturalExpandedHeight
+ siblingHeight
+ rowGap * Math.max(0, panels.length - 1);
// A compact expansion should not make the whole control stack jump down
// merely to center a few short rows. Preserve the normal top anchor when
// the centered stack would begin below it; tall stacks can still grow
// upward around the viewport midpoint as their content requires.
const centeredTailTop = viewportMidpoint - tailRequiredHeight * 0.5;
const tailLayoutTop = Math.min(centeredTailTop, safeTop);
const tailLayoutBottom = tailLayoutTop + tailRequiredHeight;
const tailAvailableHeight = Math.max(0, obstacleSafeBottom - obstacleSafeTop);
const tailTolerance = wasTail ? stabilityBand : -stabilityBand;
const shouldTail = expandedPanels.length > 0
&& tailLayoutTop >= obstacleSafeTop - tailTolerance
&& tailLayoutBottom <= obstacleSafeBottom + tailTolerance;
const shouldFocus = exceedsCenteredCorridor && !shouldTail;
// Focus mode owns the lane, so let every expanded panel share the full
// obstacle-safe corridor. Tail/normal layouts keep the balanced
// viewport centering used for compact accordion stacks.
const layoutTop = shouldFocus
? obstacleSafeTop
: shouldTail ? tailLayoutTop : safeTop;
const layoutBottom = shouldFocus
? obstacleSafeBottom
: shouldTail ? tailLayoutBottom : safeBottom;
const topPct = (layoutTop / viewportHeight) * 100;
const bottomPct = ((viewportHeight - layoutBottom) / viewportHeight) * 100;
const topValue = `${topPct.toFixed(3)}vh`;
const bottomValue = `${bottomPct.toFixed(3)}vh`;
const expandedAvailableHeight = shouldFocus
? Math.max(0, layoutBottom - layoutTop
- rowGap * Math.max(0, expandedPanels.length - 1))
: naturalExpandedHeight;
const allocatedExpandedHeights = allocatePanelStackHeights({
naturalHeights: naturalExpandedHeights,
availableHeight: expandedAvailableHeight,
});
const autoCollapseIndices = hud.visible ? panelStackAutoCollapseIndices({
naturalHeights: naturalExpandedHeights,
allocatedHeights: allocatedExpandedHeights,
collapseLaterPanels: shouldFocus && hud.variant === 'tactical',
}) : [];
if (autoCollapseIndices.length) {
for (const index of autoCollapseIndices) {
const panel = expandedPanels[index];
panel.classList.add('collapsed', 'layout-auto-collapsed');
onCollapse(panel);
}
onRetry();
return;
}
if (stack.style.getPropertyValue('--left-stack-safe-top') !== topValue) {
stack.style.setProperty('--left-stack-safe-top', topValue);
}
if (stack.style.getPropertyValue('--left-stack-safe-bottom') !== bottomValue) {
stack.style.setProperty('--left-stack-safe-bottom', bottomValue);
}
stack.style.removeProperty('--left-stack-centered-height');
for (const panel of panels) panel.style.removeProperty('--left-panel-allocated-height');
expandedPanels.forEach((panel, index) => {
panel.style.setProperty('--left-panel-allocated-height', `${allocatedExpandedHeights[index].toFixed(1)}px`);
});
stack.classList.toggle('layout-focus', shouldFocus);
stack.classList.toggle('layout-tail', shouldTail);
stack.dataset.layoutMode = shouldFocus ? 'focus' : shouldTail ? 'tail' : 'normal';
stack.dataset.safeTopPct = topPct.toFixed(2);
stack.dataset.safeBottomPct = (100 - bottomPct).toFixed(2);
stack.dataset.availableHeightPct = ((availableHeight / viewportHeight) * 100).toFixed(2);
stack.dataset.requiredHeightPct = ((requiredHeight / viewportHeight) * 100).toFixed(2);
stack.dataset.tailAvailableHeightPct = ((tailAvailableHeight / viewportHeight) * 100).toFixed(2);
stack.dataset.expandedCount = String(expandedPanels.length);
// Cockpit Display/Radio live in the opposite margin and no longer borrow
// this corridor: the left accordion's top is solved against left-lane
// obstacles, which put the strip straight through the briefing card.
// CockpitView.syncSignalLayout() owns `--cockpit-utility-top` instead.
for (const panel of panels) {
const hiddenSibling = shouldFocus && panel.classList.contains('collapsed');
if (hiddenSibling) panel.setAttribute('aria-hidden', 'true');
else panel.removeAttribute('aria-hidden');
}
// The right controls share this top baseline; update them after the left
// accordion commits an HUD-variant or obstacle-driven position change.
onAligned();
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Measure unconstrained visible content without feeding the allocated outer
* height back into the next layout pass.
* @param {HTMLElement} panel Panel whose natural height is needed.
* @param {Function} getComputedStyle Style reader for the panel's document.
* @returns {number} Natural height in CSS pixels.
*/
export function measurePanelNaturalHeight(panel, getComputedStyle) {
const inner = [...panel.children].find((child) => !child.classList.contains('panel-glow'));
if (!inner) return Math.ceil(panel.scrollHeight || panel.getBoundingClientRect().height);
const innerRect = inner.getBoundingClientRect();
const panelStyle = getComputedStyle(panel);
const innerStyle = getComputedStyle(inner);
const paddingBottom = parseFloat(innerStyle.paddingBottom) || 0;
let contentBottom = parseFloat(innerStyle.paddingTop) || 0;
for (const child of inner.children) {
const childStyle = getComputedStyle(child);
if (childStyle.display === 'none' || childStyle.visibility === 'hidden') continue;
const childRect = child.getBoundingClientRect();
const marginBottom = parseFloat(childStyle.marginBottom) || 0;
const naturalChildHeight = Math.max(childRect.height, child.scrollHeight || 0);
const childBottom = childRect.top - innerRect.top + naturalChildHeight + marginBottom;
contentBottom = Math.max(contentBottom, childBottom);
}
const wrapperChrome = (parseFloat(panelStyle.borderTopWidth) || 0)
+ (parseFloat(panelStyle.borderBottomWidth) || 0)
+ (parseFloat(panelStyle.paddingTop) || 0)
+ (parseFloat(panelStyle.paddingBottom) || 0);
return Math.ceil(contentBottom + paddingBottom + wrapperChrome);
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Resolve a vertically centered panel slot inside a HUD rail while respecting
* live rectangles that intersect that rail above or below the viewport center.
*/
export function resolveHudRailLayout({
viewportHeight,
panelHeight,
laneLeft,
laneRight,
obstacles = [],
baseTop,
baseBottom,
gap = 12,
align = 'center',
}) {
if (![viewportHeight, panelHeight, laneLeft, laneRight, baseTop, baseBottom]
.every(Number.isFinite) || viewportHeight <= 0 || laneRight <= laneLeft) return null;
const midpoint = viewportHeight * 0.5;
let safeTop = Math.max(0, baseTop);
let safeBottom = Math.min(viewportHeight, baseBottom);
for (const rect of obstacles) {
if (![rect?.left, rect?.right, rect?.top, rect?.bottom].every(Number.isFinite)) continue;
if (rect.right <= laneLeft || rect.left >= laneRight || rect.bottom <= rect.top) continue;
if (rect.bottom <= midpoint) safeTop = Math.max(safeTop, rect.bottom + gap);
else if (rect.top >= midpoint) safeBottom = Math.min(safeBottom, rect.top - gap);
}
safeBottom = Math.max(safeTop, safeBottom);
const availableHeight = Math.max(0, safeBottom - safeTop);
const renderedHeight = Math.min(Math.max(0, panelHeight), availableHeight);
return {
top: align === 'start'
? safeTop
: safeTop + Math.max(0, (availableHeight - renderedHeight) * 0.5),
maxHeight: availableHeight,
safeTop,
safeBottom,
constrained: panelHeight > availableHeight,
};
}
/**
* Tactical HUD gives an expanded right-rail panel the whole control lane.
* Other HUD layouts keep collapsed launchers visible for quick switching.
*
* @param {object} input Current rail state.
* @param {string} input.hudVariant Active HUD layout variant.
* @param {boolean} input.hasExpandedPanel Whether any rail panel is expanded.
* @returns {boolean} Whether collapsed sibling launchers should be hidden.
*/
export function shouldHideCollapsedRightPanels({ hudVariant, hasExpandedPanel }) {
return hudVariant === 'tactical' && Boolean(hasExpandedPanel);
}
+5
View File
@@ -0,0 +1,5 @@
export { layoutLeftPanelRail } from './leftPanelRail.js';
export { layoutRightPanelRail } from './rightPanelRail.js';
export { measurePanelNaturalHeight } from './panelMeasurement.js';
export { resolveHudRailLayout, shouldHideCollapsedRightPanels } from './panelRailGeometry.js';
export { allocatePanelStackHeights, panelStackAutoCollapseIndices, resolveLeftStackBottomBoundary, resolvePanelStackCorridor } from '../panelStackLayout.js';
+145
View File
@@ -0,0 +1,145 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { layoutLeftPanelRail, layoutRightPanelRail, measurePanelNaturalHeight } from './panelRails.js';
function element(id, { height = 42, top = 234, left = 20, width = 272, collapsed = false } = {}) {
const classes = new Set(collapsed ? ['collapsed'] : []);
const properties = new Map();
const attributes = new Map();
const writes = [];
const node = {
id, children: [], dataset: {}, parentElement: null, scrollHeight: height,
clientHeight: height, scrollTop: 0,
computed: { display: 'block', visibility: 'visible', opacity: '1', rowGap: '12px' },
rect: { top, left, width, height, right: left + width, bottom: top + height },
classList: {
contains: (name) => classes.has(name),
add: (...names) => names.forEach(name => classes.add(name)),
remove: (...names) => names.forEach(name => classes.delete(name)),
toggle(name, value) { if (value) classes.add(name); else classes.delete(name); },
},
style: {
setProperty(name, value) { writes.push(['set', name, value]); properties.set(name, value); },
getPropertyValue: name => properties.get(name) || '',
removeProperty(name) { writes.push(['remove', name]); properties.delete(name); },
},
getBoundingClientRect() { return this.rect; },
matches: selector => selector === '[data-panel-id]',
contains(target) { return target === this || this.children.some(child => child.contains(target)); },
querySelectorAll() { return this.children; },
setAttribute: (name, value) => attributes.set(name, value),
removeAttribute: name => attributes.delete(name),
getAttribute: name => attributes.get(name),
writes,
};
return node;
}
function fixture(side, { mobile = false, hud = { visible: true, variant: 'tactical' } } = {}) {
const first = element('first', {height: 42, collapsed: true});
const second = element('second', {height: 42, collapsed: true});
const stack = element('rail', {height: 96, left: side === 'left' ? 20 : 1100});
stack.children = [first, second];
first.parentElement = second.parentElement = stack;
const documentRef = { activeElement: null };
stack.ownerDocument = documentRef;
const collapsed = [];
let retries = 0, aligned = 0;
const options = {
stack, hud, documentRef, obstacles: [], collapsedHeights: new Map(),
windowRef: {innerHeight: 900, matchMedia: () => ({matches: mobile}), getComputedStyle: node => node.computed},
onCollapse: panel => collapsed.push(panel.id),
onRetry: () => { retries += 1; },
onAligned: () => { aligned += 1; },
displayPanel: null, readDisplayScrollTop: () => 0, leftStack: element('left'),
};
const run = () => (side === 'left' ? layoutLeftPanelRail : layoutRightPanelRail)(options);
const expand = (panel, height) => {panel.classList.remove('collapsed'); panel.scrollHeight = height; panel.rect.height = height; panel.rect.bottom = panel.rect.top + height;};
return {options, first, second, stack, run, expand, collapsed, retries: () => retries, aligned: () => aligned};
}
test('missing rails are inert without browser globals', () => {
layoutLeftPanelRail({});
layoutRightPanelRail({});
});
test('left layout records collapsed heights and aligns the right rail without changing disclosure', () => {
const f = fixture('left'); f.run();
assert.equal(f.options.collapsedHeights.get('first'), 42);
assert.equal(f.first.classList.contains('collapsed'), true);
assert.equal(f.aligned(), 1);
assert.equal(f.retries(), 0);
assert.equal(f.stack.dataset.layoutMode, 'normal');
});
test('left corridor respects a lower obstacle but ignores an obstacle hidden by its parent', () => {
const f = fixture('left');
const blocker = element('blocker', {top: 600, height: 80});
const hidden = element('hidden', {top: 300, height: 80});
hidden.parentElement = element('hidden-parent'); hidden.parentElement.computed.opacity = '0';
f.options.obstacles = [blocker, hidden]; f.run();
assert.equal(Number(f.stack.dataset.safeBottomPct), 65.47);
});
for (const side of ['left', 'right']) {
test(`${side} mobile layout releases desktop height/position styles and labels`, () => {
const f = fixture(side, {mobile: true});
f.stack.classList.add('layout-focus');
f.stack.style.setProperty(`--${side}-stack-safe-top`, '300px');
f.first.style.setProperty(`--${side}-panel-allocated-height`, '99px');
f.first.setAttribute('aria-hidden','true'); f.run();
assert.equal(f.stack.dataset.layoutMode, 'mobile');
assert.equal(f.stack.style.getPropertyValue(`--${side}-stack-safe-top`), '');
assert.equal(f.first.style.getPropertyValue(`--${side}-panel-allocated-height`), '');
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
});
test(`${side} constrained layout preserves the preferred panel and requests another pass`, () => {
const f = fixture(side); f.expand(f.first, 900); f.expand(f.second, 900);
f.options.preferredPanelId = 'second'; f.run();
assert.equal(f.second.classList.contains('collapsed'), false);
assert.equal(f.first.classList.contains('layout-auto-collapsed'), true);
assert.deepEqual(f.collapsed, ['first']);
assert.equal(f.retries(), 1);
});
test(`${side} hidden HUD restores automatic collapse without altering manual collapse`, () => {
const f = fixture(side, {hud: {visible: false, variant: 'tactical'}});
f.first.classList.add('layout-auto-collapsed'); f.run();
assert.equal(f.first.classList.contains('collapsed'), false);
assert.equal(f.second.classList.contains('collapsed'), true);
assert.deepEqual(f.collapsed, ['first']);
});
}
test('right layout uses keyboard focus when there is no preferred panel', () => {
const f = fixture('right'); f.expand(f.first, 900); f.expand(f.second, 900);
f.options.documentRef.activeElement = f.second; f.run();
assert.equal(f.first.classList.contains('layout-auto-collapsed'), true);
assert.equal(f.second.classList.contains('collapsed'), false);
});
test('right layout retains Display allocation during measurement and caps restored scroll', () => {
const f = fixture('right'); f.first.id = 'pp-toggles'; f.expand(f.first, 900);
f.first.clientHeight = 400; f.options.displayPanel = f.first; f.options.readDisplayScrollTop = () => 800;
f.first.style.setProperty('--right-panel-allocated-height', '600px'); f.first.writes.length = 0;
f.run();
assert.equal(f.first.scrollTop, 500);
assert.equal(f.first.writes.some(([op, name]) => op === 'remove' && name === '--right-panel-allocated-height'), false);
f.first.writes.length = 0; f.run();
assert.equal(f.first.writes.some(([,name]) => name === '--right-panel-allocated-height'), false, 'stable allocation must not churn the style attribute');
});
test('right rail aligns to the current left rail and excludes hidden obstacles', () => {
const f = fixture('right'); f.options.leftStack.rect.top = 200;
const hidden = element('hidden', {left: 1100, top: 100, height: 200});
hidden.computed.display = 'none'; f.options.obstacles = [hidden]; f.run();
assert.equal(f.stack.dataset.safeTop, '200.0');
});
test('natural height includes visible content, margins and wrapper chrome, excluding hidden rows', () => {
const panel = element('panel');
const inner = element('inner', {top: 100}); inner.computed.paddingTop = '10px'; inner.computed.paddingBottom = '5px';
const row = element('row', {top: 110, height: 40}); row.scrollHeight = 80; row.computed.marginBottom = '3px';
const hidden = element('hidden', {top: 1000, height: 900}); hidden.computed.visibility = 'hidden';
panel.computed.borderTopWidth = '1px'; panel.computed.borderBottomWidth = '1px';
panel.children = [inner]; inner.children = [row, hidden];
assert.equal(measurePanelNaturalHeight(panel, node => node.computed), 100);
});
+217
View File
@@ -0,0 +1,217 @@
import {
allocatePanelStackHeights,
panelStackAutoCollapseIndices,
} from '../panelStackLayout.js';
import { resolveHudRailLayout, shouldHideCollapsedRightPanels } from './panelRailGeometry.js';
/**
* Measure and place the right panel rail for one synchronous layout pass.
* The caller owns scheduling, obstacle selection, disclosure preferences and
* persistence. Auto-collapse is presentation only and reports through callbacks.
* @param {object} options Live DOM and caller policy.
* @param {HTMLElement} options.stack Rail element.
* @param {Iterable<HTMLElement>} options.obstacles Caller-selected obstacle nodes.
* @param {Window} options.windowRef Viewport and style reader.
* @param {{visible: boolean, variant: string}} options.hud Current HUD presentation.
* @param {string} options.preferredPanelId Most recently opened panel.
* @param {Function} options.onCollapse Update a panel's disclosure chrome.
* @param {Function} options.onRetry Request another pass after automatic collapse.
* @param {Function} [options.getComputedStyle] Optional DOM style reader override.
* @param {HTMLElement} options.leftStack Rail supplying the shared top baseline.
* @param {HTMLElement} options.displayPanel Panel whose allocation owns its scroll.
* @param {Function} options.readDisplayScrollTop Read the caller's scroll restoration value.
* @param {Document} [options.documentRef] Document supplying current keyboard focus.
*/
export function layoutRightPanelRail({
stack,
obstacles,
windowRef,
hud,
preferredPanelId,
onCollapse,
onRetry,
leftStack,
displayPanel,
readDisplayScrollTop,
documentRef = stack?.ownerDocument,
getComputedStyle = (element) => windowRef.getComputedStyle(element)
}) {
if (!stack) return;
const panels = [...stack.children].filter((panel) => panel.matches('[data-panel-id]'));
if (!hud.visible || hud.variant !== 'tactical') {
for (const panel of panels.filter((item) => item.classList.contains('layout-auto-collapsed'))) {
panel.classList.remove('collapsed', 'layout-auto-collapsed');
onCollapse(panel);
}
}
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
const hasExpandedPanel = panels.some((panel) => (
!panel.classList.contains('collapsed') && (!isMobile || panel.id !== 'pp-toggles')
));
const exclusive = shouldHideCollapsedRightPanels({
hudVariant: hud.variant,
hasExpandedPanel,
});
stack.classList.toggle('layout-exclusive', exclusive);
for (const panel of panels) {
if (exclusive && panel.classList.contains('collapsed')) panel.setAttribute('aria-hidden', 'true');
else panel.removeAttribute('aria-hidden');
}
if (isMobile) {
stack.classList.remove('layout-focus');
stack.style.removeProperty('--right-stack-safe-top');
stack.style.removeProperty('--right-stack-max-height');
for (const panel of panels) panel.style.removeProperty('--right-panel-allocated-height');
stack.dataset.layoutMode = 'mobile';
return;
}
const viewportHeight = Math.max(1, windowRef.innerHeight);
const safeGap = Math.max(8, viewportHeight * 0.012);
const stackRect = stack.getBoundingClientRect();
const leftStackTop = leftStack?.getBoundingClientRect().top;
const alignedTop = Number.isFinite(leftStackTop)
? leftStackTop
: viewportHeight * 0.26;
const obstacleRects = [];
for (const obstacle of obstacles) {
if (stack.contains(obstacle)) continue;
let hiddenByAncestor = false;
for (let element = obstacle; element; element = element.parentElement) {
const style = getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
hiddenByAncestor = true;
break;
}
}
if (hiddenByAncestor) continue;
const rect = obstacle.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) continue;
obstacleRects.push({
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
});
}
const visiblePanels = panels.filter((panel) => (
!exclusive || !panel.classList.contains('collapsed')
));
const displayScrollTop = readDisplayScrollTop();
// Measure intrinsic content, not the allocation written by the previous
// layout pass. Display is the exception: its own scrollHeight already
// exposes every control, and removing its live allocation can reset the
// user's scroll position while HUD or preset content is settling.
for (const panel of visiblePanels) {
if (!panel.classList.contains('collapsed') && panel !== displayPanel) {
panel.style.removeProperty('--right-panel-allocated-height');
}
}
const gap = parseFloat(getComputedStyle(stack).rowGap) || 0;
const naturalHeight = visiblePanels.reduce((total, panel) => (
total + Math.max(
panel.getBoundingClientRect().height,
panel.scrollHeight || 0,
panel.classList.contains('collapsed') ? 42 : 0,
)
), 0) + gap * Math.max(0, visiblePanels.length - 1);
const layout = resolveHudRailLayout({
viewportHeight,
panelHeight: naturalHeight,
laneLeft: stackRect.left,
laneRight: stackRect.right,
obstacles: obstacleRects,
baseTop: alignedTop,
baseBottom: viewportHeight * 0.96,
gap: safeGap,
align: 'start',
});
if (!layout) return;
const { safeTop, safeBottom, maxHeight: availableHeight } = layout;
const stabilityBand = viewportHeight * 0.01;
const wasFocused = stack.classList.contains('layout-focus');
const shouldFocus = wasFocused
? naturalHeight > availableHeight - stabilityBand * 2
: naturalHeight > availableHeight - stabilityBand;
const layoutTop = shouldFocus ? safeTop : layout.top;
const collapsedHeight = visiblePanels.reduce((total, panel) => (
panel.classList.contains('collapsed')
? total + panel.getBoundingClientRect().height
: total
), 0);
const expandedPanelsInDomOrder = visiblePanels.filter((panel) => !panel.classList.contains('collapsed'));
const focusedExpandedPanel = expandedPanelsInDomOrder.find((panel) => panel.contains(documentRef.activeElement));
const preferredExpandedPanel = expandedPanelsInDomOrder.find(
(panel) => panel.id === preferredPanelId,
) || focusedExpandedPanel;
// Match the left lane: allocation order follows the latest explicit
// disclosure, not DOM order. A focused panel is the fallback owner so
// temporary presentation collapse never strands keyboard focus.
const expandedPanels = preferredExpandedPanel
? [preferredExpandedPanel, ...expandedPanelsInDomOrder.filter((panel) => panel !== preferredExpandedPanel)]
: expandedPanelsInDomOrder;
const expandedAvailableHeight = Math.max(
0,
safeBottom - layoutTop - collapsedHeight - gap * Math.max(0, visiblePanels.length - 1),
);
const expandedHeights = allocatePanelStackHeights({
naturalHeights: expandedPanels.map((panel) => Math.max(
panel.getBoundingClientRect().height,
panel.scrollHeight || 0,
)),
availableHeight: expandedAvailableHeight,
});
const autoCollapseIndices = hud.visible ? panelStackAutoCollapseIndices({
naturalHeights: expandedPanels.map((panel) => Math.max(
panel.getBoundingClientRect().height,
panel.scrollHeight || 0,
)),
allocatedHeights: expandedHeights,
collapseLaterPanels: shouldFocus && hud.variant === 'tactical',
}) : [];
if (autoCollapseIndices.length) {
for (const index of autoCollapseIndices) {
const panel = expandedPanels[index];
panel.classList.add('collapsed', 'layout-auto-collapsed');
onCollapse(panel);
}
onRetry();
return;
}
// Write-if-changed. This pass runs on the 500 ms stats cadence, and an
// unconditional REMOVE-then-SET of an unchanged allocation is two style
// mutations per tick on `#pp-toggles` (the one panel the measure-strip
// above deliberately skips) — churn that reads as a genuine panel move to
// the world-overlay host's occluder observer and defeats parked-idle
// render savings. Only a real allocation change may touch the attribute.
expandedPanels.forEach((panel, index) => {
const next = `${expandedHeights[index].toFixed(1)}px`;
if (panel.style.getPropertyValue('--right-panel-allocated-height') !== next) {
panel.style.setProperty('--right-panel-allocated-height', next);
}
});
for (const panel of panels) {
if (expandedPanels.includes(panel)) continue;
panel.style.removeProperty('--right-panel-allocated-height');
}
stack.style.setProperty('--right-stack-safe-top', `${layoutTop.toFixed(1)}px`);
stack.style.setProperty('--right-stack-max-height', `${Math.max(0, safeBottom - layoutTop).toFixed(1)}px`);
stack.classList.toggle('layout-focus', shouldFocus);
stack.dataset.layoutMode = shouldFocus ? 'focus' : 'normal';
stack.dataset.safeTop = layoutTop.toFixed(1);
stack.dataset.safeBottom = safeBottom.toFixed(1);
stack.dataset.availableHeight = availableHeight.toFixed(1);
stack.dataset.requiredHeight = naturalHeight.toFixed(1);
stack.dataset.expandedCount = String(expandedPanels.length);
if (displayPanel && expandedPanels.includes(displayPanel)) {
const maxScrollTop = Math.max(0, displayPanel.scrollHeight - displayPanel.clientHeight);
displayPanel.scrollTop = Math.min(displayScrollTop, maxScrollTop);
}
}