Files
gods-eye-view/src/panelStackLayout.js
T

150 lines
5.8 KiB
JavaScript

/**
* Fits expanded panels into a shared vertical corridor. Natural heights are
* retained when they fit; constrained panels keep a usable floor and share
* the remaining room in proportion to their unmet height.
*
* @param {object} input Layout measurements.
* @param {number[]} input.naturalHeights Expanded-panel natural heights.
* @param {number} input.availableHeight Height available to expanded panels.
* @param {number} [input.minimumHeight=96] Preferred usable floor per panel.
* @returns {number[]} One allocated height per expanded panel.
*/
export function allocatePanelStackHeights({
naturalHeights,
availableHeight,
minimumHeight = 96,
}) {
const natural = naturalHeights.map((height) => Math.max(0, Number(height) || 0));
if (!natural.length) return [];
const available = Math.max(0, Number(availableHeight) || 0);
const naturalTotal = natural.reduce((sum, height) => sum + height, 0);
if (naturalTotal <= available) return natural;
if (available === 0) return natural.map(() => 0);
const preferredFloor = Math.max(0, Number(minimumHeight) || 0);
const base = natural.map((height) => Math.min(height, preferredFloor));
const baseTotal = base.reduce((sum, height) => sum + height, 0);
if (baseTotal >= available) {
const scale = baseTotal > 0 ? available / baseTotal : 0;
return base.map((height) => height * scale);
}
const remaining = available - baseTotal;
const unmet = natural.map((height, index) => Math.max(0, height - base[index]));
const unmetTotal = unmet.reduce((sum, height) => sum + height, 0);
if (unmetTotal <= 0) return base;
return base.map((height, index) => height + remaining * (unmet[index] / unmetTotal));
}
/**
* Solves the accordion corridor's bottom boundary against the obstacles that
* still limit it.
*
* Every painted obstacle limits the corridor. This includes Cockpit CONTACT
* and peripheral HUD surfaces, so reopening a map panel cannot cover them.
*
* @param {object} input Corridor measurements.
* @param {number} input.baseBottom Viewport-inset bottom boundary, in px.
* @param {Array<{top: number}>} [input.obstacles] Live obstacle tops.
* @param {number} [input.safeGap=0] Clearance kept above each obstacle, in px.
* @returns {number} Bottom boundary in px.
*/
export function resolveLeftStackBottomBoundary({
baseBottom,
obstacles = [],
safeGap = 0,
}) {
let bottom = Number(baseBottom) || 0;
const gap = Number(safeGap) || 0;
for (const obstacle of obstacles) {
const top = Number(obstacle?.top);
if (!Number.isFinite(top)) continue;
bottom = Math.min(bottom, top - gap);
}
return bottom;
}
/**
* Returns later expanded panels whose allocation would expose less than the
* requested share of their intrinsic height. The first panel always remains
* expanded so every lane keeps one useful primary surface.
*
* @param {object} input Panel height measurements.
* @param {number[]} input.naturalHeights Intrinsic expanded heights.
* @param {number[]} input.allocatedHeights Allocated expanded heights.
* @param {number} [input.minimumVisibleRatio=0.5] Minimum useful height share.
* @param {boolean} [input.collapseLaterPanels=false] Collapse every competitor after the primary panel.
* @returns {number[]} Candidate indices to present as collapsed controls.
*/
export function panelStackAutoCollapseIndices({
naturalHeights,
allocatedHeights,
minimumVisibleRatio = 0.5,
collapseLaterPanels = false,
}) {
const threshold = Math.max(0, Number(minimumVisibleRatio) || 0);
const collapsed = [];
for (let index = 1; index < naturalHeights.length; index += 1) {
if (collapseLaterPanels) {
collapsed.push(index);
continue;
}
const natural = Math.max(0, Number(naturalHeights[index]) || 0);
const allocated = Math.max(0, Number(allocatedHeights[index]) || 0);
if (natural > 0 && allocated / natural < threshold) collapsed.push(index);
}
return collapsed;
}
/**
* Balance a desktop panel corridor around the viewport midpoint without
* crossing its measured obstacle boundaries. If centering would shrink the
* lane below its usable minimum, retain the original aligned corridor.
*
* @param {object} input Corridor measurements.
* @param {number} input.viewportHeight Current viewport height.
* @param {number} input.safeTop Proposed corridor top.
* @param {number} input.safeBottom Proposed corridor bottom.
* @param {number} input.obstacleSafeTop Highest obstacle-safe top boundary.
* @param {number} input.obstacleSafeBottom Lowest obstacle-safe bottom boundary.
* @param {number} input.minimumHeight Minimum useful lane height.
* @returns {{ safeTop: number, safeBottom: number }} Bounded corridor.
*/
export function resolvePanelStackCorridor({
viewportHeight,
safeTop,
safeBottom,
obstacleSafeTop,
obstacleSafeBottom,
minimumHeight,
}) {
const height = Math.max(1, Number(viewportHeight) || 1);
const boundaryTop = Math.max(0, Number(obstacleSafeTop) || 0);
const boundaryBottom = Math.max(
boundaryTop,
Math.min(height, Number(obstacleSafeBottom) || 0),
);
let top = Math.max(boundaryTop, Math.min(boundaryBottom, Number(safeTop) || 0));
let bottom = Math.max(top, Math.min(boundaryBottom, Number(safeBottom) || 0));
const minimum = Math.max(0, Number(minimumHeight) || 0);
const midpoint = height * 0.5;
if (top < midpoint && bottom > midpoint) {
const centeredHalfHeight = Math.min(midpoint - top, bottom - midpoint);
const centeredTop = midpoint - centeredHalfHeight;
const centeredBottom = midpoint + centeredHalfHeight;
if (centeredBottom - centeredTop >= minimum) {
top = centeredTop;
bottom = centeredBottom;
}
}
if (bottom - top < minimum) {
top = Math.max(boundaryTop, bottom - minimum);
bottom = Math.min(boundaryBottom, Math.max(bottom, top + minimum));
}
return { safeTop: top, safeBottom: bottom };
}