Full-screen map with draggable, collapsible floating HUD windows

- MapLibre map is now a fixed, full-viewport background layer (z-0)
- New FloatingWindow: drag by title bar (pointer capture, viewport-clamped,
  position persisted to localStorage), collapsible to title bar, bring-to-front
  z-index; semi-transparent slate window with amber/cyan accents
- Game screen re-laid out as floating windows over the map: slim top header
  (scenario, turn + date, player nation chip, Idle/Simulating status, Focus map
  collapse-all), dossier (top-left), event feed (right), Your move deck
  (bottom-center), legend (bottom-left), tiny zoom control (bottom-right)
- MapLibre default nav controls removed; custom floating zoom-in/out control
- Panels gain an inWindow mode (no card chrome, same testids)
- server: honor HISTORY_API_PORT as PORT fallback (dev proxy/e2e agreement)
- css: keep maplibre canvas base color and Radix portals above HUD windows
This commit is contained in:
2026-10-10 22:47:08 +00:00
parent e327e9aea0
commit 913d094e11
9 changed files with 994 additions and 363 deletions
+292 -105
View File
@@ -1,13 +1,26 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { CircleDot, Loader2, Radar } from 'lucide-react';
import {
Activity,
BookOpen,
CircleDot,
ListTree,
Loader2,
Map as MapIcon,
Minus,
Plus,
Radar,
} from 'lucide-react';
import { Toaster } from './components/ui/sonner';
import { Button } from './components/ui/button';
import { Badge } from './components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './components/ui/select';
import { cn } from './lib/utils';
import PresetPicker from './components/PresetPicker';
import NationSelect from './components/NationSelect';
import WorldMap from './components/WorldMap';
import WorldMap, { type MapApi } from './components/WorldMap';
import FloatingWindow from './components/FloatingWindow';
import MapLegend from './components/MapLegend';
import NationPanel from './components/NationPanel';
import EventFeed from './components/EventFeed';
import ActionPanel from './components/ActionPanel';
@@ -32,6 +45,39 @@ function toastError(e: unknown): void {
}
}
// ---------------------------------------------------------------------------
// Floating-window collapse persistence (positions live in FloatingWindow itself).
// ---------------------------------------------------------------------------
const FLOAT_COLLAPSED_KEY = 'history.floatwin.v1.collapsed';
/** Every collapsible HUD panel (the slim header is never collapsed). */
const PANEL_WINDOW_IDS = ['dossier', 'feed', 'action', 'legend', 'zoom'] as const;
function loadCollapsedMap(): Record<string, boolean> {
try {
const raw = localStorage.getItem(FLOAT_COLLAPSED_KEY);
if (raw == null) return {};
const v: unknown = JSON.parse(raw);
if (!v || typeof v !== 'object' || Array.isArray(v)) return {};
const out: Record<string, boolean> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
if (typeof val === 'boolean') out[k] = val;
}
return out;
} catch {
return {};
}
}
function persistCollapsedMap(m: Record<string, boolean>): void {
try {
localStorage.setItem(FLOAT_COLLAPSED_KEY, JSON.stringify(m));
} catch {
/* storage unavailable — collapse state just won't persist */
}
}
export default function App() {
const [screen, setScreen] = useState<Screen>({ name: 'home' });
const [presets, setPresets] = useState<PresetMeta[]>([]);
@@ -216,11 +262,15 @@ interface GameScreenProps {
}
/**
* The main play view — an operations console. A top status bar carries the
* scenario, the current turn + date, the player nation (color-chipped), and a
* live idle/simulating indicator. Below it, a three-column grid: the nation
* dossier (left), the dominant map (center), and the command deck + event feed
* (right). On small screens these stack map-first.
* The main play view — a full-bleed world map with a floating HUD. The MapLibre
* map is a fixed, full-viewport background (z-0); over it float draggable,
* collapsible windows: a slim header bar (scenario, turn + date, player nation,
* live status, "Focus map" collapse-all) at the top, the nation dossier
* (top-left), the event feed (right), the "Your move" command deck
* (bottom-center), the nation-color legend (bottom-left), and a tiny map zoom
* control (bottom-right). Each window remembers its dragged position, and
* clicking one raises it above its siblings. Pan/zoom/click on the map stays
* fully available in the gaps between windows.
*/
function GameScreen({
game,
@@ -239,115 +289,252 @@ function GameScreen({
const viewed = game.nations[viewingId] ?? player;
const nations = Object.values(game.nations).sort((a, b) => a.name.localeCompare(b.name));
// Collapse state for the HUD panels, persisted per id (survives reloads).
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsedMap);
const setCollapsedFor = useCallback((id: string, c: boolean) => {
setCollapsed((prev) => {
const next = { ...prev, [id]: c };
persistCollapsedMap(next);
return next;
});
}, []);
const allCollapsed = PANEL_WINDOW_IDS.every((id) => collapsed[id]);
// "Focus map" collapses every panel at once to reveal the bare map; the same
// button (now "Show panels") restores them.
const toggleAllPanels = useCallback(() => {
setCollapsed((prev) => {
const target = !PANEL_WINDOW_IDS.every((id) => prev[id]);
const next = { ...prev };
for (const id of PANEL_WINDOW_IDS) next[id] = target;
persistCollapsedMap(next);
return next;
});
}, []);
// Tiny map API (zoom in/out) exposed by WorldMap for the floating control.
const mapApiRef = useRef<MapApi | null>(null);
const handleMapReady = useCallback((api: MapApi | null) => {
mapApiRef.current = api;
}, []);
return (
<div className="mx-auto flex min-h-screen max-w-[1600px] flex-col px-3 py-4 sm:px-4 lg:px-6">
<header
data-testid="game-header"
className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-3 rounded-xl border bg-card/60 px-4 py-3"
>
<Button variant="ghost" size="sm" onClick={onHome}>
← Scenarios
</Button>
<div className="flex items-center gap-2.5">
<Radar className="size-5 text-primary" />
<div className="leading-tight">
<h1 className="text-lg font-bold tracking-tight">History</h1>
<p className="text-[11px] text-muted-foreground">{game.presetTitle}</p>
</div>
</div>
<div className="min-h-screen">
{/* Full-viewport background map, behind every floating window. */}
<WorldMap
regions={regions}
playerNationId={game.playerNationId}
nations={game.nations}
presetId={game.presetId}
onFocusNation={setViewingId}
onMapReady={handleMapReady}
/>
<div className="ml-auto flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="text-right leading-none">
<div className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">Turn</div>
<div className="mt-1 text-2xl font-bold tabular-nums" data-testid="turn-count">
{game.turn}
{/* Slim floating header: brand, turn + date, player nation, live status, Focus map. */}
<FloatingWindow
id="header"
anchor="top-center"
collapsible={false}
title={
<div className="flex min-w-0 items-center gap-3">
<Button variant="ghost" size="sm" onClick={onHome} className="shrink-0">
← Scenarios
</Button>
<div className="flex min-w-0 items-center gap-2.5">
<Radar className="size-5 shrink-0 text-primary" />
<div className="min-w-0 leading-tight">
<div className="truncate text-base font-bold tracking-tight">History</div>
<div className="truncate text-[11px] font-normal text-muted-foreground">
{game.presetTitle}
</div>
</div>
</div>
</div>
<div className="hidden h-9 w-px bg-border sm:block" />
<div className="text-right leading-none">
<div className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">Date</div>
<div className="mt-1 text-sm font-semibold tabular-nums" data-testid="game-date">
{game.date}
}
titleExtra={
<div className="flex shrink-0 flex-wrap items-center gap-x-4 gap-y-1">
<div className="text-right leading-none">
<div className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">
Turn
</div>
<div className="mt-0.5 text-xl font-bold tabular-nums" data-testid="turn-count">
{game.turn}
</div>
</div>
<div className="hidden h-8 w-px bg-border sm:block" />
<div className="text-right leading-none">
<div className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">
Date
</div>
<div className="mt-1 text-sm font-semibold tabular-nums" data-testid="game-date">
{game.date}
</div>
</div>
<div className="hidden h-8 w-px bg-border sm:block" />
<div className="flex items-center gap-2">
<span
className="inline-block size-3.5 rounded-full ring-2 ring-white/10"
style={{ background: nationColor(game.playerNationId) }}
/>
<span className="text-sm font-medium" data-testid="player-nation">
{player?.name ?? game.playerNationId}
</span>
</div>
</div>
<div className="hidden h-9 w-px bg-border sm:block" />
<div className="flex items-center gap-2">
<span
className="inline-block size-3.5 rounded-full ring-2 ring-white/10"
style={{ background: nationColor(game.playerNationId) }}
/>
<span className="text-sm font-medium" data-testid="player-nation">
{player?.name ?? game.playerNationId}
data-testid="status-indicator"
className={cn(
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium',
busy
? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border bg-muted/40 text-muted-foreground',
)}
>
{busy ? <Loader2 className="size-3 animate-spin" /> : <CircleDot className="size-3" />}
{busy ? 'Simulating…' : 'Idle'}
</span>
<Button
variant="secondary"
size="sm"
onClick={toggleAllPanels}
data-testid="focus-map"
title={allCollapsed ? 'Show all panels' : 'Collapse all panels to reveal the map'}
>
<MapIcon className="size-3.5" />
{allCollapsed ? 'Show panels' : 'Focus map'}
</Button>
</div>
<span
data-testid="status-indicator"
className={cn(
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium',
busy ? 'border-primary/40 bg-primary/10 text-primary' : 'border-border bg-muted/40 text-muted-foreground',
)}
>
{busy ? (
<Loader2 className="size-3 animate-spin" />
) : (
<CircleDot className="size-3" />
)}
{busy ? 'Simulating…' : 'Idle'}
</span>
}
/>
{/* Nation dossier, top-left. */}
<FloatingWindow
id="dossier"
anchor="top-left"
width={300}
title="Dossier"
titleIcon={<BookOpen className="size-4" />}
collapsed={!!collapsed.dossier}
onCollapsedChange={(c) => setCollapsedFor('dossier', c)}
bodyClassName="max-h-[56vh] overflow-y-auto"
>
<div className="border-b border-border/60 p-3">
<div className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Inspect
</div>
<Select value={viewingId} onValueChange={setViewingId}>
<SelectTrigger className="w-full" data-testid="inspect-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
{nations.map((n) => (
<SelectItem key={n.id} value={n.id}>
{n.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</header>
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} inWindow />
</FloatingWindow>
<main className="grid flex-1 gap-4 lg:grid-cols-[300px_minmax(0,1fr)_340px]">
{/* Left: nation dossier (mobile order 3). */}
<section className="order-3 min-w-0 lg:order-1">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Inspect</span>
<Select value={viewingId} onValueChange={setViewingId}>
<SelectTrigger className="w-full" data-testid="inspect-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
{nations.map((n) => (
<SelectItem key={n.id} value={n.id}>
{n.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} />
</section>
{/* Event feed, right side. */}
<FloatingWindow
id="feed"
anchor="right"
width={330}
title="Event feed"
titleIcon={<Activity className="size-4" />}
titleExtra={
<Badge variant="secondary" className="shrink-0 text-xs tabular-nums">
{game.events.length}
</Badge>
}
collapsed={!!collapsed.feed}
onCollapsedChange={(c) => setCollapsedFor('feed', c)}
bodyClassName="h-[52vh]"
>
<EventFeed events={game.events} nations={game.nations} inWindow />
</FloatingWindow>
{/* Center: the dominant map (mobile order 1). */}
<section className="order-1 min-w-0 lg:order-2">
<div className="relative h-[58vh] min-h-[420px] overflow-hidden rounded-xl border lg:h-[80vh]">
<WorldMap
regions={regions}
playerNationId={game.playerNationId}
nations={game.nations}
presetId={game.presetId}
onFocusNation={setViewingId}
className="h-full w-full"
/>
</div>
</section>
{/* Command deck ("Your move"), bottom-center — the prominent typing spot. */}
<FloatingWindow
id="action"
anchor="bottom-center"
width={370}
title="Your move"
titleIcon={<Radar className="size-4 text-primary" />}
titleExtra={
<Button
variant="secondary"
size="sm"
onClick={onJump}
disabled={busy}
data-testid="jump-forward"
className="shrink-0"
>
{busy ? 'Simulating…' : 'Jump forward'}
</Button>
}
collapsed={!!collapsed.action}
onCollapsedChange={(c) => setCollapsedFor('action', c)}
bodyClassName="max-h-[72vh] overflow-y-auto"
>
<ActionPanel
game={game}
busy={busy}
advisors={advisors}
onAction={onAction}
onChat={onChat}
onAdvisor={onAdvisor}
onJump={onJump}
inWindow
/>
</FloatingWindow>
{/* Right: command deck + event feed (mobile order 2). */}
<aside className="order-2 flex min-h-0 min-w-0 flex-col gap-4 lg:order-3">
<ActionPanel
game={game}
busy={busy}
advisors={advisors}
onAction={onAction}
onChat={onChat}
onAdvisor={onAdvisor}
onJump={onJump}
/>
<div className="min-h-[360px] flex-1 lg:min-h-0" data-testid="event-feed-slot">
<EventFeed events={game.events} nations={game.nations} />
</div>
</aside>
</main>
{/* Nation-color legend, bottom-left. */}
<FloatingWindow
id="legend"
anchor="bottom-left"
width={170}
title="Legend"
titleIcon={<ListTree className="size-4" />}
collapsed={!!collapsed.legend}
onCollapsedChange={(c) => setCollapsedFor('legend', c)}
>
<MapLegend regions={regions} />
</FloatingWindow>
{/* Tiny floating zoom control, bottom-right (clear of the attribution). */}
<FloatingWindow
id="zoom"
anchor="bottom-right"
width={88}
title="Map"
titleIcon={<MapIcon className="size-4" />}
offset={{ y: -28 }}
collapsed={!!collapsed.zoom}
onCollapsedChange={(c) => setCollapsedFor('zoom', c)}
>
<div className="flex items-center justify-center gap-1.5 p-2.5">
<Button
variant="secondary"
size="sm"
onClick={() => mapApiRef.current?.zoomIn()}
aria-label="Zoom in"
data-testid="zoom-in"
>
<Plus className="size-4" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => mapApiRef.current?.zoomOut()}
aria-label="Zoom out"
data-testid="zoom-out"
>
<Minus className="size-4" />
</Button>
</div>
</FloatingWindow>
</div>
);
}