Files
planet/frontend/public/earth/js/hud-panels.js
2026-04-20 12:03:09 +08:00

234 lines
6.8 KiB
JavaScript

const DEFAULT_COLLAPSED_CLASS = "hud-panel--collapsed";
const DEFAULT_HIDDEN_CLASS = "hud-panel-hidden";
function getHudScale() {
const rootStyle = getComputedStyle(document.documentElement);
const scale = parseFloat(rootStyle.getPropertyValue("--hud-scale"));
return Number.isFinite(scale) && scale > 0 ? scale : 1;
}
function getEdgeFlipThresholdPx() {
const rootStyle = getComputedStyle(document.documentElement);
const hudOffset = parseFloat(rootStyle.getPropertyValue("--hud-offset"));
if (Number.isFinite(hudOffset) && hudOffset > 0) {
return hudOffset;
}
return 20 * getHudScale();
}
function clampExpandDirection(direction) {
return direction === "up" ? "up" : "down";
}
function resolveElement(target, root = document) {
if (!target) return null;
if (target instanceof HTMLElement) return target;
if (typeof target === "string") {
return root.querySelector(target);
}
return null;
}
function pickExpandDirection({
panelRect,
preferredDirection,
}) {
const spaceBelow = Math.max(0, window.innerHeight - panelRect.bottom);
const edgeFlipThresholdPx = getEdgeFlipThresholdPx();
const preferred = clampExpandDirection(preferredDirection);
// Pure edge-threshold contract:
// - d < threshold => "up" family
// - d >= threshold => "down" family
// Do not pre-flip early based on expanded height.
if (spaceBelow <= edgeFlipThresholdPx) return "up";
if (spaceBelow > edgeFlipThresholdPx) return "down";
return preferred;
}
function getCollapseButtonState({ collapsed, direction, expandLabel, collapseLabel }) {
// The arrow always describes the next action and must stay aligned with the
// real expansion direction chosen by the controller. Panels should not add
// their own extra CSS rotation on top of this mapping.
if (collapsed) {
return {
title: expandLabel,
icon: direction === "up" ? "expand_less" : "expand_more",
};
}
return {
title: collapseLabel,
icon: direction === "up" ? "expand_more" : "expand_less",
};
}
export function createHUDPanel({
panel,
header,
body,
collapseBtn,
bodyCollapsedClass = "",
preferredDirection = "down",
collapsedClass = DEFAULT_COLLAPSED_CLASS,
hiddenClass = DEFAULT_HIDDEN_CLASS,
expandLabel = "展开",
collapseLabel = "折叠",
}) {
const panelEl = resolveElement(panel);
const headerEl = resolveElement(header, panelEl ?? document);
const bodyEl = resolveElement(body, panelEl ?? document);
const collapseBtnEl = resolveElement(collapseBtn, panelEl ?? document);
if (!(panelEl instanceof HTMLElement) || !(headerEl instanceof HTMLElement) || !(bodyEl instanceof HTMLElement)) {
return {
panel: panelEl,
header: headerEl,
body: bodyEl,
collapseBtn: collapseBtnEl,
setCollapsed() {},
setVisible() {},
syncLayout() {},
destroy() {},
isCollapsed() {
return false;
},
isVisible() {
return false;
},
getExpandDirection() {
return clampExpandDirection(preferredDirection);
},
};
}
let currentDirection = clampExpandDirection(preferredDirection);
const shouldAnchorBottomDuringToggle = () =>
panelEl.dataset.dragged === "true" && typeof panelEl.style.top === "string" && panelEl.style.top !== "";
const compensateTopForBottomAnchor = (beforeBottom) => {
if (!shouldAnchorBottomDuringToggle()) return;
const afterBottom = panelEl.getBoundingClientRect().bottom;
const delta = afterBottom - beforeBottom;
if (delta === 0) return;
panelEl.style.top = `${parseFloat(panelEl.style.top) - delta}px`;
};
const syncDirection = () => {
const expandedHeight = Math.max(bodyEl.scrollHeight, bodyEl.getBoundingClientRect().height);
const nextDirection = pickExpandDirection({
panelRect: panelEl.getBoundingClientRect(),
preferredDirection,
});
currentDirection = nextDirection;
panelEl.classList.toggle("hud-panel--expand-up", nextDirection === "up");
panelEl.classList.toggle("hud-panel--expand-down", nextDirection !== "up");
panelEl.dataset.expandDirection = nextDirection;
};
const syncButton = () => {
if (!(collapseBtnEl instanceof HTMLElement)) return;
const iconEl = collapseBtnEl.querySelector(".material-symbols-rounded");
const { title, icon } = getCollapseButtonState({
collapsed: panelEl.classList.contains(collapsedClass),
direction: currentDirection,
expandLabel,
collapseLabel,
});
collapseBtnEl.title = title;
collapseBtnEl.setAttribute("aria-label", title);
collapseBtnEl.dataset.expandDirection = currentDirection;
if (iconEl) {
iconEl.textContent = icon;
}
};
const syncLayout = () => {
syncDirection();
syncButton();
};
const setCollapsed = (collapsed) => {
syncDirection();
const nextCollapsed = Boolean(collapsed);
const shouldCompensate = currentDirection === "up" && shouldAnchorBottomDuringToggle();
const bottomBefore = shouldCompensate ? panelEl.getBoundingClientRect().bottom : 0;
if (shouldCompensate) {
bodyEl.style.transition = "none";
}
panelEl.classList.toggle(collapsedClass, nextCollapsed);
if (bodyCollapsedClass) {
bodyEl.classList.toggle(bodyCollapsedClass, nextCollapsed);
}
if (shouldCompensate) {
void panelEl.offsetHeight;
compensateTopForBottomAnchor(bottomBefore);
requestAnimationFrame(() => {
bodyEl.style.transition = "";
});
}
syncButton();
};
const setVisible = (visible) => {
panelEl.classList.toggle(hiddenClass, !visible);
if (visible) {
syncLayout();
}
};
const handleViewportChange = () => {
if (!panelEl.classList.contains(hiddenClass)) {
syncLayout();
}
};
const handlePointerMove = () => {
if (!panelEl.classList.contains(hiddenClass) && panelEl.classList.contains("is-dragging")) {
syncLayout();
}
};
window.addEventListener("resize", handleViewportChange);
document.addEventListener("pointerup", handleViewportChange);
document.addEventListener("pointermove", handlePointerMove);
syncLayout();
requestAnimationFrame(syncLayout);
return {
panel: panelEl,
header: headerEl,
body: bodyEl,
collapseBtn: collapseBtnEl,
setCollapsed,
setVisible,
syncLayout,
destroy() {
window.removeEventListener("resize", handleViewportChange);
document.removeEventListener("pointerup", handleViewportChange);
document.removeEventListener("pointermove", handlePointerMove);
},
isCollapsed() {
return panelEl.classList.contains(collapsedClass);
},
isVisible() {
return !panelEl.classList.contains(hiddenClass);
},
getExpandDirection() {
return currentDirection;
},
};
}
export function setupCollapsibleHudPanel(options) {
return createHUDPanel(options);
}