import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { useLocation } from "react-router"; import { useMapFrame } from "../map/camera"; type Detent = "peek" | "half" | "full"; /** Screens that open tall on phones (decision 0.5.166). */ const OPEN_FULL = new Set(["/settings", "/logbook", "/pilots", "/hqs", "/locations", "/market"]); /** Screens with extra columns: a wider panel on desktop. */ const WIDE = new Set(["/contracts", "/logbook"]); const PEEK_PX = 96; const HALF = 0.56; const HEADER_PX = 64; const NAV_PX = 64; const detentPx = (detent: Detent, height: number) => detent === "peek" ? PEEK_PX : detent === "half" ? Math.round(height * HALF) : height - HEADER_PX - NAV_PX; export const panelIsWide = (pathname: string) => WIDE.has(pathname); /** * The glass panel screens live in (decision 0.5.166): a bottom sheet with drag * detents on phones, a right-hand column from `md`. On `/map` it holds only the * selected place or ship. It tells the map where the uncovered area is. */ export function PanelHost({ children }: { children: ReactNode }) { const { pathname } = useLocation(); const onMap = pathname === "/map"; const [detent, setDetent] = useState(OPEN_FULL.has(pathname) ? "full" : "half"); const [dragPx, setDragPx] = useState(null); const hostRef = useRef(null); const drag = useRef<{ startY: number; startPx: number; moved: number } | null>(null); // Each screen opens at its own height. useEffect(() => { setDetent(OPEN_FULL.has(pathname) ? "full" : "half"); }, [pathname]); // Keep the map centered in what the panel leaves showing. useLayoutEffect(() => { const host = hostRef.current; if (!host) return; const measure = () => { const rect = host.getBoundingClientRect(); const { innerWidth: width, innerHeight: height } = window; const desktop = width >= 768; const left = desktop ? 96 : 0; const right = desktop ? rect.left : width; const top = desktop ? 0 : HEADER_PX; const bottom = desktop ? height : onMap ? height - NAV_PX : rect.top; useMapFrame.setState({ offsetX: (left + right) / 2 - width / 2, offsetY: (top + bottom) / 2 - height / 2, covered: !desktop && bottom - top < 40, }); }; measure(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(measure); observer.observe(host); window.addEventListener("resize", measure); return () => { observer.disconnect(); window.removeEventListener("resize", measure); }; }, [onMap, pathname]); const onPointerDown = (e: React.PointerEvent) => { e.currentTarget.setPointerCapture?.(e.pointerId); drag.current = { startY: e.clientY, startPx: detentPx(detent, window.innerHeight), moved: 0, }; }; const onPointerMove = (e: React.PointerEvent) => { if (!drag.current) return; const delta = drag.current.startY - e.clientY; drag.current.moved = Math.max(drag.current.moved, Math.abs(delta)); if (drag.current.moved > 4) { const max = detentPx("full", window.innerHeight); setDragPx(Math.max(PEEK_PX, Math.min(max, drag.current.startPx + delta))); } }; const onPointerUp = () => { if (dragPx !== null) { const details: Detent[] = ["peek", "half", "full"]; const nearest = details.reduce((best, d) => Math.abs(detentPx(d, window.innerHeight) - dragPx) < Math.abs(detentPx(best, window.innerHeight) - dragPx) ? d : best, ); setDetent(nearest); } setDragPx(null); drag.current = null; }; // A tap or the keyboard steps through the heights, so dragging is never required. const step = () => { if (drag.current && drag.current.moved > 4) return; setDetent((d) => (d === "half" ? "full" : d === "full" ? "peek" : "half")); }; const wide = WIDE.has(pathname); if (onMap) { return (
{children}
); } return (
{children}
); }