Step 29.3: the map shell and panel host

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-11-04 18:05:00 -05:00
parent 47ea4987b5
commit 06bf2f17b1
18 changed files with 355 additions and 117 deletions

View File

@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect, type ReactNode } from "react"; import { useEffect, type ReactNode } from "react";
import { Route, Routes } from "react-router"; import { Route, Routes } from "react-router";
import { useApi } from "./api/context"; import { useApi } from "./api/context";
import { startClockTicker, syncClock } from "./clock/clock"; import { startClockTicker, syncClock } from "./clock/clock";
@ -17,6 +17,7 @@ import { Fleet } from "./screens/Fleet";
import { Dashboard } from "./screens/Dashboard"; import { Dashboard } from "./screens/Dashboard";
import { HQs } from "./screens/HQs"; import { HQs } from "./screens/HQs";
import { Locations } from "./screens/Locations"; import { Locations } from "./screens/Locations";
import { MapScreen } from "./screens/MapScreen";
import { Market } from "./screens/Market"; import { Market } from "./screens/Market";
import { Pilots } from "./screens/Pilots"; import { Pilots } from "./screens/Pilots";
import { Logbook } from "./screens/Logbook"; import { Logbook } from "./screens/Logbook";
@ -27,16 +28,9 @@ import { usePlayerState, useRefetchOnArrival } from "./state/player";
import { RadioCallSheet, useRefetchOnRadio } from "./components/RadioCalls"; import { RadioCallSheet, useRefetchOnRadio } from "./components/RadioCalls";
import { Button } from "./ui"; import { Button } from "./ui";
// The map pulls in PixiJS; load it only when the Map tab is opened.
const MapScreen = lazy(() => import("./screens/MapScreen").then((m) => ({ default: m.MapScreen })));
const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = { const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
"/": <Dashboard />, "/": <Dashboard />,
"/map": ( "/map": <MapScreen />,
<Suspense fallback={<p className="p-4 text-ink-400">Loading map…</p>}>
<MapScreen />
</Suspense>
),
"/contracts": <Contracts />, "/contracts": <Contracts />,
"/fleet": <Fleet />, "/fleet": <Fleet />,
"/pilots": <Pilots />, "/pilots": <Pilots />,

View File

@ -1,9 +1,11 @@
import type { ReactNode } from "react"; import type { CSSProperties, ReactNode } from "react";
import { Link, NavLink, Outlet } from "react-router"; import { Link, NavLink, Outlet, useLocation } from "react-router";
import { GameClock } from "../clock/GameClock"; import { GameClock } from "../clock/GameClock";
import { SCREENS, type ScreenPath } from "../routes"; import { SCREENS, type ScreenPath } from "../routes";
import { Icon } from "../ui"; import { Icon } from "../ui";
import { useTrackScreen } from "./lastScreen"; import { useTrackScreen } from "./lastScreen";
import { MapBackdrop } from "./MapBackdrop";
import { panelIsWide, PanelHost } from "./PanelHost";
export interface LayoutProps { export interface LayoutProps {
/** Shown at the top right. Credits arrive with the player state (Step 8.5). */ /** Shown at the top right. Credits arrive with the player state (Step 8.5). */
@ -16,9 +18,9 @@ export interface LayoutProps {
} }
/** /**
* HUD header with credits and game clock, the current screen, and icon * The shell (CR-029): the map behind everything, a floating glass header with
* navigation: a bottom bar on phones, a left rail from `md` (CR-005). * credits and game clock, the current screen in a panel, and icon navigation:
* `--header-h` and `--nav-h` let full-height screens (the map) fit exactly. * a bottom bar on phones, a floating rail from `md`.
*/ */
export function Layout({ export function Layout({
credits = "—", credits = "—",
@ -27,53 +29,52 @@ export function Layout({
attention = {}, attention = {},
}: LayoutProps) { }: LayoutProps) {
useTrackScreen(); useTrackScreen();
const { pathname } = useLocation();
const panelWidth = panelIsWide(pathname) ? "45rem" : "26.25rem";
return ( return (
<div className="flex min-h-dvh flex-col text-ink-50 [--header-h:3.5rem] [--nav-h:calc(4.25rem+env(safe-area-inset-bottom))] md:pl-24 md:[--nav-h:0px]"> <div
<header className="sticky top-0 z-10 flex h-(--header-h) items-center justify-between gap-2 border-b border-space-700/80 bg-space-950/80 px-3 backdrop-blur-xl sm:px-4"> style={{ "--panel-w": panelWidth } as CSSProperties}
className="fixed inset-0 overflow-hidden bg-space-950 text-ink-50 [--nav-h:calc(4rem+env(safe-area-inset-bottom))] md:[--nav-h:0px]"
>
<MapBackdrop />
<header className="absolute inset-x-2 top-[max(0.5rem,env(safe-area-inset-top))] z-10 flex h-12 items-center justify-between gap-2 rounded-2xl border border-glass-line bg-glass px-3 md:top-4 md:right-[calc(var(--panel-w)+2rem)] md:left-24 md:backdrop-blur-xl">
{/* The wordmark only fits beside the chips from 640 px; on phones the mark alone. */} {/* The wordmark only fits beside the chips from 640 px; on phones the mark alone. */}
<div className="flex min-w-0 items-center gap-2 overflow-hidden"> <div className="flex min-w-0 items-center gap-2 overflow-hidden">
<LogoMark /> <LogoMark />
<span className="hidden font-display text-lg font-bold sm:inline">DEADHEAD</span> <span className="hidden font-title text-sm sm:inline">DEADHEAD</span>
<span className="hidden font-display text-xs text-cyan-300/70 lg:inline">
Space Freight
</span>
</div> </div>
<div className="flex shrink-0 items-center gap-1.5 sm:gap-2"> <div className="flex shrink-0 items-center gap-3">
<div className="flex items-center gap-1.5 rounded-lg border border-amber-400/45 bg-amber-500/10 px-2.5 py-1 text-amber-200 shadow-glow-amber"> <span className="flex items-center gap-1.5 text-ink-300">
<Icon name="credits" className="size-4 text-amber-300" /> <span aria-label="Game clock" className="text-[13px] tabular-nums">
<span aria-label="Credits" className="font-display text-sm font-bold tabular-nums">
{credits}
</span>
</div>
<div className="flex items-center gap-1.5 rounded-lg border border-space-600 bg-space-900/80 px-2.5 py-1 text-ink-300">
<Icon name="clock" className="size-4 text-cyan-300" />
<span aria-label="Game clock" className="font-display text-xs tabular-nums sm:text-sm">
{clock} {clock}
</span> </span>
</div> </span>
<span aria-label="Credits" className="text-[15px] font-semibold tabular-nums">
{credits}
</span>
<Link <Link
to="/settings" to="/settings"
aria-label="Settings" aria-label="Settings"
className="grid size-8 place-items-center rounded-lg border border-space-600 bg-space-900/80 text-ink-300 transition hover:border-amber-400/60 hover:text-amber-200 sm:size-9" className="grid size-9 place-items-center rounded-lg text-ink-300 transition hover:bg-white/8 hover:text-ink-50"
> >
<Icon name="gear" className="size-5" /> <Icon name="gear" className="size-5" />
</Link> </Link>
</div> </div>
</header> </header>
{banner && (
<div className="absolute inset-x-2 top-16 z-10 overflow-hidden rounded-xl md:top-20 md:right-[calc(var(--panel-w)+2rem)] md:left-24">
{banner} {banner}
</div>
)}
<main className="flex-1 overflow-x-hidden pb-[calc(var(--nav-h)+1.5rem)]"> <PanelHost>
<Outlet /> <Outlet />
</main> </PanelHost>
<nav <nav
aria-label="Main" aria-label="Main"
className="fixed inset-x-0 bottom-0 z-10 grid h-(--nav-h) grid-cols-8 border-t border-space-700/80 bg-space-950/85 pb-[env(safe-area-inset-bottom)] backdrop-blur-xl md:inset-x-auto md:top-0 md:left-0 md:flex md:h-dvh md:w-24 md:flex-col md:gap-0.5 md:overflow-y-auto md:border-t-0 md:border-r md:pt-[calc(var(--header-h)+0.75rem)] md:pb-4" className="absolute inset-x-0 bottom-0 z-10 flex h-(--nav-h) border-t border-glass-line bg-space-950/95 pb-[env(safe-area-inset-bottom)] md:top-4 md:right-auto md:bottom-4 md:left-4 md:h-auto md:w-16 md:flex-col md:gap-1 md:overflow-y-auto md:rounded-3xl md:border md:bg-glass md:py-2 md:backdrop-blur-xl"
> >
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 -top-px h-px bg-linear-to-r from-transparent via-amber-400/50 to-transparent md:hidden"
/>
{SCREENS.map((screen) => ( {SCREENS.map((screen) => (
<NavTab <NavTab
key={screen.path} key={screen.path}
@ -104,40 +105,24 @@ function NavTab({
to={path} to={path}
end={path === "/"} end={path === "/"}
className={({ isActive }) => className={({ isActive }) =>
`group relative flex flex-col items-center justify-center gap-0.5 transition-colors md:py-1.5 ${ `relative flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 text-xs font-medium transition-colors md:flex-none md:py-2 ${
isActive ? "text-amber-300" : "text-ink-400 hover:text-ink-50" isActive ? "text-action" : "text-ink-300 hover:text-ink-50"
}` }`
} }
> >
{({ isActive }) => ( <span className="relative grid size-6 place-items-center">
<> <Icon name={icon} className="size-5.5" />
<span
aria-hidden="true"
className={`absolute top-0 h-0.5 w-8 rounded-full bg-amber-400 shadow-glow-amber transition-opacity md:top-1/2 md:left-0 md:h-8 md:w-0.5 md:-translate-y-1/2 ${
isActive ? "opacity-100" : "opacity-0"
}`}
/>
<span
className={`relative grid size-10 place-items-center rounded-xl transition md:size-11 ${
isActive
? "bg-amber-500/15 shadow-glow-amber"
: "group-hover:bg-white/5 group-active:scale-95"
}`}
>
<Icon name={icon} className="size-6" />
{badge > 0 && ( {badge > 0 && (
<span <span
aria-hidden="true" aria-hidden="true"
data-testid={`badge-${label}`} data-testid={`badge-${label}`}
className="absolute -top-1 -right-1 grid h-4.5 min-w-4.5 animate-glow place-items-center rounded-full bg-rose-500 px-1 font-display text-xs font-bold text-white ring-2 ring-space-950" className="absolute -top-1.5 -right-2 grid h-4 min-w-4 place-items-center rounded-full bg-problem px-1 text-xs leading-none font-bold text-on-problem"
> >
{badge > 9 ? "9+" : badge} {badge > 9 ? "9+" : badge}
</span> </span>
)} )}
</span> </span>
<span className="font-display text-xs font-semibold text-xs">{label}</span> {label}
</>
)}
</NavLink> </NavLink>
); );
} }

View File

@ -0,0 +1,47 @@
import { lazy, Suspense } from "react";
import { useLocation, useNavigate } from "react-router";
import { resetMapView } from "../map/camera";
import { useMapSelection } from "../map/selection";
import { useLoadedPlayer } from "../state/actions";
import { buttonClass } from "../ui";
// The map pulls in PixiJS: load it after the first paint.
const MapCanvas = lazy(() => import("../map/MapCanvas").then((m) => ({ default: m.MapCanvas })));
/**
* The one map canvas of the session, behind every screen (decision 0.5.166).
* A tap on a place or ship opens it in the map view.
*/
export function MapBackdrop() {
const state = useLoadedPlayer();
const { pathname } = useLocation();
const navigate = useNavigate();
const selection = useMapSelection((s) => s.selection);
const select = useMapSelection((s) => s.select);
const onMap = pathname === "/map";
return (
<div className="absolute inset-0 z-0">
<Suspense fallback={null}>
<MapCanvas
state={state}
focused={onMap}
selected={selection}
onSelect={(next) => {
select(next);
if (next && !onMap) void navigate("/map");
}}
/>
</Suspense>
{onMap && (
<button
type="button"
onClick={resetMapView}
className={`absolute top-[calc(max(0.5rem,env(safe-area-inset-top))+3.5rem)] right-2 md:top-20 md:right-[calc(var(--panel-w)+2rem)] ${buttonClass("secondary", "sm")} bg-glass`}
>
Reset view
</button>
)}
</div>
);
}

View File

@ -0,0 +1,152 @@
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<Detent>(OPEN_FULL.has(pathname) ? "full" : "half");
const [dragPx, setDragPx] = useState<number | null>(null);
const hostRef = useRef<HTMLElement>(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<HTMLButtonElement>) => {
e.currentTarget.setPointerCapture?.(e.pointerId);
drag.current = {
startY: e.clientY,
startPx: detentPx(detent, window.innerHeight),
moved: 0,
};
};
const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
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 (
<main
ref={hostRef}
className="pointer-events-none absolute inset-x-2 bottom-[calc(var(--nav-h)+0.5rem)] z-10 md:top-4 md:right-4 md:bottom-auto md:left-auto md:w-(--panel-w)"
>
{children}
</main>
);
}
return (
<main
ref={hostRef}
style={
{
"--sheet-h": `${dragPx ?? detentPx(detent, globalThis.innerHeight ?? 800)}px`,
} as React.CSSProperties
}
className={`absolute inset-x-0 bottom-(--nav-h) z-10 flex h-(--sheet-h) flex-col rounded-t-3xl border-t border-glass-line bg-glass shadow-panel md:top-4 md:right-4 md:bottom-auto md:left-auto md:h-auto md:max-h-[calc(100%-2rem)] md:w-(--panel-w) md:rounded-3xl md:border md:backdrop-blur-xl ${
dragPx === null ? "transition-[height] duration-300 ease-out" : ""
}`}
>
<button
type="button"
aria-label={`Panel height: ${detent}. Change panel height`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onClick={step}
className="flex h-6 shrink-0 touch-none items-center justify-center md:hidden"
>
<span aria-hidden="true" className="h-1 w-9 rounded-full bg-white/30" />
</button>
<div
className={`@container min-h-0 flex-1 overflow-y-auto overscroll-contain ${wide ? "" : ""}`}
>
{children}
</div>
</main>
);
}

View File

@ -3,13 +3,16 @@ import { Application, Assets, type Texture } from "pixi.js";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { serverNow } from "../clock/clock"; import { serverNow } from "../clock/clock";
import { loadArt } from "./art/load"; import { loadArt } from "./art/load";
import { pan, useCamera, zoomAt, type Viewport } from "./camera"; import { framed, pan, useCamera, useMapFrame, zoomAt, type Viewport } from "./camera";
import { hitTest, type MapSelection } from "./hitTest"; import { hitTest, type MapSelection } from "./hitTest";
import { MapRenderer } from "./renderer"; import { MapRenderer } from "./renderer";
import { buildScene } from "./scene"; import { buildScene } from "./scene";
import { mapStyle } from "./style"; import { mapStyle } from "./style";
import { runWhileVisible } from "./ticker"; import { runWhileVisible } from "./ticker";
/** Frame rate while a panel is in front (decision 0.5.166). */
const BEHIND_PANEL_FPS = 30;
/** /**
* The system map canvas (Steps 10.1–10.2, CR-003). Loads the map art, then * The system map canvas (Steps 10.1–10.2, CR-003). Loads the map art, then
* draws locations and ships every frame from `state` and the server clock; * draws locations and ships every frame from `state` and the server clock;
@ -19,12 +22,15 @@ export function MapCanvas({
state, state,
onSelect, onSelect,
selected = null, selected = null,
focused = true,
}: { }: {
state: PlayerState; state: PlayerState;
/** A tap on a location or ship, or on empty space (null) (Step 10.3). */ /** A tap on a location or ship, or on empty space (null) (Step 10.3). */
onSelect?: (selection: MapSelection | null) => void; onSelect?: (selection: MapSelection | null) => void;
/** Highlighted with a ring (Step 10C.8). */ /** Highlighted with a ring (Step 10C.8). */
selected?: MapSelection | null; selected?: MapSelection | null;
/** The map is what the player is looking at: full frame rate and a loading note. */
focused?: boolean;
}) { }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const stateRef = useRef(state); const stateRef = useRef(state);
@ -34,12 +40,21 @@ export function MapCanvas({
const selectedRef = useRef(selected); const selectedRef = useRef(selected);
selectedRef.current = selected; selectedRef.current = selected;
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const tickerRef = useRef<{ maxFPS: number } | null>(null);
const frameRate = focused ? 0 : BEHIND_PANEL_FPS;
const frameRateRef = useRef(frameRate);
frameRateRef.current = frameRate;
useEffect(() => {
if (tickerRef.current) tickerRef.current.maxFPS = frameRate;
}, [frameRate]);
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
let cancelled = false; let cancelled = false;
let stopTicker = () => {}; let stopTicker = () => {};
let stopFrameWatch = () => {};
let renderer: MapRenderer | null = null; let renderer: MapRenderer | null = null;
const app = new Application(); const app = new Application();
@ -48,6 +63,7 @@ export function MapCanvas({
height: container.clientHeight, height: container.clientHeight,
}); });
const camera = () => framed(useCamera.getState(), useMapFrame.getState());
// Art and renderer load together; missing art falls back to placeholders (0.5.41). // Art and renderer load together; missing art falls back to placeholders (0.5.41).
const art = loadArt<Texture>(Assets, window.devicePixelRatio); const art = loadArt<Texture>(Assets, window.devicePixelRatio);
void Promise.all([ void Promise.all([
@ -74,12 +90,29 @@ export function MapCanvas({
map.draw( map.draw(
buildScene(stateRef.current, serverNow()), buildScene(stateRef.current, serverNow()),
viewport(), viewport(),
useCamera.getState(), camera(),
selectedRef.current, selectedRef.current,
), ),
); );
// Only draw while the page is visible (Step 10C.9). tickerRef.current = app.ticker;
stopTicker = runWhileVisible(app.ticker); app.ticker.maxFPS = frameRateRef.current;
// Only draw while the page is visible (Step 10C.9) and not fully covered (0.5.166).
let wanted = false;
const apply = () => {
if (wanted && !useMapFrame.getState().covered) app.ticker.start();
else app.ticker.stop();
};
stopFrameWatch = useMapFrame.subscribe(apply);
stopTicker = runWhileVisible({
start: () => {
wanted = true;
apply();
},
stop: () => {
wanted = false;
apply();
},
});
}) })
.catch(() => { .catch(() => {
// No WebGL/WebGPU (e.g. very old browsers, tests): the map stays blank. // No WebGL/WebGPU (e.g. very old browsers, tests): the map stays blank.
@ -90,6 +123,13 @@ export function MapCanvas({
// Pan with one pointer, pinch-zoom with two, wheel-zoom on desktop. A single // Pan with one pointer, pinch-zoom with two, wheel-zoom on desktop. A single
// pointer that barely moves is a tap, which selects what is under it. // pointer that barely moves is a tap, which selects what is under it.
const zoom = (factor: number, anchor: { x: number; y: number }) => {
const frame = useMapFrame.getState();
useCamera.setState((c) => {
const next = zoomAt(framed(c, frame), factor, anchor, viewport());
return { ...next, panX: next.panX - frame.offsetX, panY: next.panY - frame.offsetY };
});
};
const pointers = new Map<number, { x: number; y: number }>(); const pointers = new Map<number, { x: number; y: number }>();
let tap: { id: number; start: { x: number; y: number }; moved: number } | null = null; let tap: { id: number; start: { x: number; y: number }; moved: number } | null = null;
const local = (e: PointerEvent | WheelEvent) => { const local = (e: PointerEvent | WheelEvent) => {
@ -118,7 +158,7 @@ export function MapCanvas({
const before = Math.hypot(previous.x - other.x, previous.y - other.y); const before = Math.hypot(previous.x - other.x, previous.y - other.y);
const after = Math.hypot(current.x - other.x, current.y - other.y); const after = Math.hypot(current.x - other.x, current.y - other.y);
const mid = { x: (current.x + other.x) / 2, y: (current.y + other.y) / 2 }; const mid = { x: (current.x + other.x) / 2, y: (current.y + other.y) / 2 };
if (before > 0) useCamera.setState((c) => zoomAt(c, after / before, mid, viewport())); if (before > 0) zoom(after / before, mid);
} }
pointers.set(e.pointerId, current); pointers.set(e.pointerId, current);
}; };
@ -131,13 +171,13 @@ export function MapCanvas({
return; return;
} }
const scene = buildScene(stateRef.current, serverNow()); const scene = buildScene(stateRef.current, serverNow());
onSelectRef.current?.(hitTest(scene, local(e), viewport(), useCamera.getState())); onSelectRef.current?.(hitTest(scene, local(e), viewport(), camera()));
} }
if (tap?.id === e.pointerId) tap = null; if (tap?.id === e.pointerId) tap = null;
}; };
const onWheel = (e: WheelEvent) => { const onWheel = (e: WheelEvent) => {
e.preventDefault(); e.preventDefault();
useCamera.setState((c) => zoomAt(c, Math.exp(-e.deltaY * 0.001), local(e), viewport())); zoom(Math.exp(-e.deltaY * 0.001), local(e));
}; };
container.addEventListener("pointerdown", onDown); container.addEventListener("pointerdown", onDown);
container.addEventListener("pointermove", onMove); container.addEventListener("pointermove", onMove);
@ -148,6 +188,8 @@ export function MapCanvas({
return () => { return () => {
cancelled = true; cancelled = true;
stopTicker(); stopTicker();
stopFrameWatch();
tickerRef.current = null;
container.removeEventListener("pointerdown", onDown); container.removeEventListener("pointerdown", onDown);
container.removeEventListener("pointermove", onMove); container.removeEventListener("pointermove", onMove);
container.removeEventListener("pointerup", onUp); container.removeEventListener("pointerup", onUp);
@ -163,7 +205,7 @@ export function MapCanvas({
data-testid="map-canvas" data-testid="map-canvas"
className="relative h-full w-full touch-none overflow-hidden" className="relative h-full w-full touch-none overflow-hidden"
> >
{loading && ( {loading && focused && (
<p <p
role="status" role="status"
className="absolute inset-x-0 top-4 text-center font-display text-sm text-ink-400" className="absolute inset-x-0 top-4 text-center font-display text-sm text-ink-400"

View File

@ -44,6 +44,21 @@ export function resetMapView(): void {
usePreferences.getState().set({ mapCamera: null }); usePreferences.getState().set({ mapCamera: null });
} }
/**
* Where the map's center belongs: panels cover part of the screen, so the view
* centers on what's left (decision 0.5.166). `covered`: nothing is showing.
*/
export const useMapFrame = create<{ offsetX: number; offsetY: number; covered: boolean }>(() => ({
offsetX: 0,
offsetY: 0,
covered: false,
}));
/** The camera shifted by the frame, as drawn and hit-tested. */
export function framed(camera: Camera, frame: { offsetX: number; offsetY: number }): Camera {
return { ...camera, panX: camera.panX + frame.offsetX, panY: camera.panY + frame.offsetY };
}
/** Pixels per map unit at this zoom. */ /** Pixels per map unit at this zoom. */
export function pixelsPerUnit(viewport: Viewport, camera: Camera): number { export function pixelsPerUnit(viewport: Viewport, camera: Camera): number {
return (Math.min(viewport.width, viewport.height) / 2) * FIT * camera.zoom; return (Math.min(viewport.width, viewport.height) / 2) * FIT * camera.zoom;

View File

@ -0,0 +1,11 @@
import { create } from "zustand";
import type { MapSelection } from "./hitTest";
/** What the player last tapped on the map, shown in the map view (decision 0.5.166). */
export const useMapSelection = create<{
selection: MapSelection | null;
select: (selection: MapSelection | null) => void;
}>((set) => ({
selection: null,
select: (selection) => set({ selection }),
}));

View File

@ -93,15 +93,15 @@ export function Dashboard() {
.sort((a, b) => a.inShopUntil! - b.inShopUntil!); .sort((a, b) => a.inShopUntil! - b.inShopUntil!);
return ( return (
<Screen className="max-w-5xl"> <Screen>
<ScreenHeader title="Dashboard" eyebrow={state.company.name} icon="helm" /> <ScreenHeader title="Dashboard" eyebrow={state.company.name} icon="helm" />
{/* While the fleet sleeps, first thing on the Bridge (CR-024). */} {/* While the fleet sleeps, first thing on the Bridge (CR-024). */}
<FleetAsleep /> <FleetAsleep />
{/* Calls waiting for an answer, each reopening its card (CR-026). */} {/* Calls waiting for an answer, each reopening its card (CR-026). */}
<RadioCallsPanel /> <RadioCallsPanel />
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 @2xl:grid-cols-3">
<YourShip className="md:col-span-2" /> <YourShip className="@2xl:col-span-2" />
<Alerts /> <Alerts />
</div> </div>
@ -110,7 +110,7 @@ export function Dashboard() {
<StatTile label="Ships in flight" value={String(flying)} icon="send" tone="cyan" /> <StatTile label="Ships in flight" value={String(flying)} icon="send" tone="cyan" />
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 @2xl:grid-cols-2">
<div className="space-y-4"> <div className="space-y-4">
{state.tutorial && ( {state.tutorial && (
<Panel tone="amber"> <Panel tone="amber">
@ -349,7 +349,7 @@ function YourShip({ className = "" }: { className?: string }) {
</p> </p>
)} )}
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 @2xl:grid-cols-2">
<div> <div>
<Eyebrow>Fuel</Eyebrow> <Eyebrow>Fuel</Eyebrow>
{ship ? ( {ship ? (

View File

@ -42,7 +42,7 @@ export function Fleet() {
const discoveredIds = new Set(state.locations.filter((l) => l.discovered).map((l) => l.id)); const discoveredIds = new Set(state.locations.filter((l) => l.discovered).map((l) => l.id));
return ( return (
<Screen className="max-w-4xl"> <Screen>
<ScreenHeader <ScreenHeader
title="Fleet" title="Fleet"
eyebrow={ eyebrow={
@ -64,7 +64,7 @@ export function Fleet() {
</p> </p>
</Panel> </Panel>
) : ( ) : (
<ul className="grid gap-3 md:grid-cols-2"> <ul className="grid gap-3 @2xl:grid-cols-2">
{state.ships.map((ship) => ( {state.ships.map((ship) => (
<ShipRow <ShipRow
key={ship.id} key={ship.id}

View File

@ -15,7 +15,7 @@ export const HQ_ALLOWS = "Hire pilots here and take delivery of new ships here."
export function HQs() { export function HQs() {
const state = useLoadedPlayer(); const state = useLoadedPlayer();
return ( return (
<Screen className="max-w-4xl"> <Screen>
<ScreenHeader <ScreenHeader
title="HQs" title="HQs"
eyebrow="Your bases" eyebrow="Your bases"
@ -34,7 +34,7 @@ export function HQs() {
</Link> </Link>
</Panel> </Panel>
) : ( ) : (
<ul className="grid gap-4 md:grid-cols-2"> <ul className="grid gap-4 @2xl:grid-cols-2">
{state.bays.map((bay) => ( {state.bays.map((bay) => (
<HqCard key={bay.id} bay={bay} /> <HqCard key={bay.id} bay={bay} />
))} ))}

View File

@ -214,7 +214,7 @@ function LocationBoard({
<div className="space-y-3"> <div className="space-y-3">
{refresh} {refresh}
{stuck} {stuck}
<ul className="grid gap-3 sm:grid-cols-2"> <ul className="grid gap-3 @2xl:grid-cols-2">
{board.data.contracts.map((contract) => { {board.data.contracts.map((contract) => {
const requirements = requirementsText(contract); const requirements = requirementsText(contract);
return ( return (

View File

@ -26,14 +26,14 @@ export function Locations() {
const lease = useGameAction("leaseBay"); const lease = useGameAction("leaseBay");
return ( return (
<Screen className="max-w-4xl"> <Screen>
<ScreenHeader title="Locations" eyebrow="Known space" icon="places" /> <ScreenHeader title="Locations" eyebrow="Known space" icon="places" />
{lease.isError && ( {lease.isError && (
<p role="alert" className="text-sm text-rose-300"> <p role="alert" className="text-sm text-rose-300">
{errorMessage(lease.error)} {errorMessage(lease.error)}
</p> </p>
)} )}
<ul className="grid gap-3 md:grid-cols-2"> <ul className="grid gap-3 @2xl:grid-cols-2">
{content.locations.map((location) => { {content.locations.map((location) => {
const discovered = state.locations.find((l) => l.id === location.id)?.discovered; const discovered = state.locations.find((l) => l.id === location.id)?.discovered;
if (!discovered) { if (!discovered) {

View File

@ -1,15 +1,13 @@
import { content, type Location } from "@deadhead/content"; import { content, type Location } from "@deadhead/content";
import { FuelPrice } from "../components/Fuel"; import { FuelPrice } from "../components/Fuel";
import { realToGame } from "@deadhead/sim"; import { realToGame } from "@deadhead/sim";
import { useState, type ReactNode } from "react"; import type { ReactNode } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { formatGameTime } from "../clock/GameClock"; import { formatGameTime } from "../clock/GameClock";
import { Countdown } from "../components/Countdown"; import { Countdown } from "../components/Countdown";
import { cargoName, formatMillionKm, locationName } from "../format"; import { cargoName, formatMillionKm, locationName } from "../format";
import { distanceAt } from "../game"; import { distanceAt } from "../game";
import { resetMapView } from "../map/camera"; import { useMapSelection } from "../map/selection";
import type { MapSelection } from "../map/hitTest";
import { MapCanvas } from "../map/MapCanvas";
import { reachersByOrigin } from "../map/reach"; import { reachersByOrigin } from "../map/reach";
import { useLoadedPlayer } from "../state/actions"; import { useLoadedPlayer } from "../state/actions";
import { usePreferences } from "../state/preferences"; import { usePreferences } from "../state/preferences";
@ -24,32 +22,22 @@ const TYPE_LABELS: Record<Location["type"], string> = {
"surface-base": "Surface base", "surface-base": "Surface base",
}; };
/** The solar system map (Phase 10). Tap a location or ship for details. */ /** The map view (Phase 10): the map itself is the backdrop. Tap a place or ship for details. */
export function MapScreen() { export function MapScreen() {
const state = useLoadedPlayer(); const selection = useMapSelection((s) => s.selection);
const [selection, setSelection] = useState<MapSelection | null>(null); const select = useMapSelection((s) => s.select);
const close = () => select(null);
return ( return (
// Fill the space between the header and the bottom nav (none beside the <>
// desktop rail); the negative margin cancels the layout's scroll padding so
// the page never scrolls (CR-005).
<section className="relative -mb-[calc(var(--nav-h)+1.5rem)] h-[calc(100dvh-var(--header-h)-var(--nav-h))]">
<h1 className="sr-only">Map</h1> <h1 className="sr-only">Map</h1>
<MapCanvas state={state} onSelect={setSelection} selected={selection} />
<button
type="button"
onClick={resetMapView}
className={`absolute top-3 right-3 ${buttonClass("secondary", "sm")} bg-space-900/80 backdrop-blur`}
>
Reset view
</button>
{selection?.kind === "location" && ( {selection?.kind === "location" && (
<LocationPanel locationId={selection.id} onClose={() => setSelection(null)} /> <LocationPanel locationId={selection.id} onClose={close} />
)} )}
{selection?.kind === "flight" && ( {selection?.kind === "flight" && (
<FlightPanel dispatchId={selection.dispatchId} onClose={() => setSelection(null)} /> <FlightPanel dispatchId={selection.dispatchId} onClose={close} />
)} )}
</section> </>
); );
} }
@ -68,7 +56,7 @@ function Panel({
<div <div
role="dialog" role="dialog"
aria-label={title} aria-label={title}
className="absolute inset-x-2 bottom-2 animate-sheet rounded-2xl border border-cyan-400/40 bg-space-900/90 p-4 text-sm shadow-panel backdrop-blur-xl md:inset-x-auto md:right-4 md:bottom-4 md:w-96" className="pointer-events-auto animate-sheet rounded-3xl border border-glass-line bg-glass p-4 text-sm shadow-panel md:backdrop-blur-xl"
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{art} {art}

View File

@ -27,7 +27,7 @@ export function Market() {
const [buying, setBuying] = useState<ShipModel | null>(null); const [buying, setBuying] = useState<ShipModel | null>(null);
return ( return (
<Screen className="max-w-5xl"> <Screen>
<ScreenHeader <ScreenHeader
title="Market" title="Market"
eyebrow="Shipyard" eyebrow="Shipyard"
@ -48,7 +48,7 @@ export function Market() {
{buy.data.ship.name} is waiting at {locationName(buy.data.ship.locationId ?? "")}. {buy.data.ship.name} is waiting at {locationName(buy.data.ship.locationId ?? "")}.
</p> </p>
)} )}
<ul className="grid gap-4 md:grid-cols-3"> <ul className="grid gap-4 @2xl:grid-cols-3">
{MODELS.map((model) => { {MODELS.map((model) => {
const affordable = state.company.credits >= model.price; const affordable = state.company.credits >= model.price;
return ( return (

View File

@ -28,7 +28,7 @@ export function levelProgress(xp: number, level: number) {
export function Pilots() { export function Pilots() {
const state = useLoadedPlayer(); const state = useLoadedPlayer();
return ( return (
<Screen className="max-w-4xl"> <Screen>
<ScreenHeader title="Pilots" eyebrow={`${state.pilots.length} on the roster`} icon="pilots" /> <ScreenHeader title="Pilots" eyebrow={`${state.pilots.length} on the roster`} icon="pilots" />
<HirePilot /> <HirePilot />
{!state.pilots.some((p) => !p.isAvatar) && ( {!state.pilots.some((p) => !p.isAvatar) && (
@ -36,7 +36,7 @@ export function Pilots() {
No hired pilots yet. Hire one at a bay to crew another ship. No hired pilots yet. Hire one at a bay to crew another ship.
</p> </p>
)} )}
<ul className="grid gap-3 md:grid-cols-2"> <ul className="grid gap-3 @2xl:grid-cols-2">
{state.pilots.map((pilot) => ( {state.pilots.map((pilot) => (
<PilotCard key={pilot.id} pilot={pilot} /> <PilotCard key={pilot.id} pilot={pilot} />
))} ))}

View File

@ -81,7 +81,7 @@ export function TutorialBoard() {
{board.data.contracts.length === 0 && ( {board.data.contracts.length === 0 && (
<p className="text-sm text-ink-400">No company runs left this period. Check back soon.</p> <p className="text-sm text-ink-400">No company runs left this period. Check back soon.</p>
)} )}
<ul className="grid gap-3 sm:grid-cols-2"> <ul className="grid gap-3 @2xl:grid-cols-2">
{board.data.contracts.map((contract, index) => { {board.data.contracts.map((contract, index) => {
// On the first run, suggest the lightest (fastest) contract. // On the first run, suggest the lightest (fastest) contract.
const suggested = firstRun && index === 0; const suggested = firstRun && index === 0;

View File

@ -1,9 +1,9 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
/** A scrolling screen's content column: centred, padded, and rising in on open. */ /** A screen's content inside its panel (decision 0.5.166): padded, and rising in on open. */
export function Screen({ children, className = "" }: { children: ReactNode; className?: string }) { export function Screen({ children, className = "" }: { children: ReactNode; className?: string }) {
return ( return (
<section className={`mx-auto w-full max-w-3xl animate-rise space-y-4 p-4 md:p-6 ${className}`}> <section className={`w-full animate-rise space-y-4 px-4 pt-1 pb-5 md:pt-4 ${className}`}>
{children} {children}
</section> </section>
); );

View File

@ -9,10 +9,14 @@ import { buildScene } from "../src/map/scene";
export function MapCanvas({ export function MapCanvas({
state, state,
onSelect, onSelect,
focused = true,
}: { }: {
state: PlayerState; state: PlayerState;
onSelect?: (selection: MapSelection | null) => void; onSelect?: (selection: MapSelection | null) => void;
focused?: boolean;
}) { }) {
// Behind a panel, the screen's own controls are what tests look for.
if (!focused) return <div data-testid="map-canvas" />;
const scene = buildScene(state, Date.now()); const scene = buildScene(state, Date.now());
return ( return (
<div data-testid="map-canvas"> <div data-testid="map-canvas">