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

+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);
}
}