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:
+3
-1
@@ -40,7 +40,9 @@ const presetsDir = path.join(root, 'presets');
|
|||||||
const clientDir = process.env.CLIENT_DIST ?? path.join(root, 'dist');
|
const clientDir = process.env.CLIENT_DIST ?? path.join(root, 'dist');
|
||||||
const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
|
const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT ?? '4321', 10);
|
// PORT wins; HISTORY_API_PORT is the override used by the dev proxy (vite) and
|
||||||
|
// the e2e config, so honor it as a fallback to keep all three in agreement.
|
||||||
|
const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10);
|
||||||
const presetStore = createPresetStore(presetsDir);
|
const presetStore = createPresetStore(presetsDir);
|
||||||
const db: Db = openDb(path.join(dataDir, 'history.db'));
|
const db: Db = openDb(path.join(dataDir, 'history.db'));
|
||||||
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
|
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
|
||||||
|
|||||||
+292
-105
@@ -1,13 +1,26 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
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 { Toaster } from './components/ui/sonner';
|
||||||
import { Button } from './components/ui/button';
|
import { Button } from './components/ui/button';
|
||||||
|
import { Badge } from './components/ui/badge';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './components/ui/select';
|
||||||
import { cn } from './lib/utils';
|
import { cn } from './lib/utils';
|
||||||
import PresetPicker from './components/PresetPicker';
|
import PresetPicker from './components/PresetPicker';
|
||||||
import NationSelect from './components/NationSelect';
|
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 NationPanel from './components/NationPanel';
|
||||||
import EventFeed from './components/EventFeed';
|
import EventFeed from './components/EventFeed';
|
||||||
import ActionPanel from './components/ActionPanel';
|
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() {
|
export default function App() {
|
||||||
const [screen, setScreen] = useState<Screen>({ name: 'home' });
|
const [screen, setScreen] = useState<Screen>({ name: 'home' });
|
||||||
const [presets, setPresets] = useState<PresetMeta[]>([]);
|
const [presets, setPresets] = useState<PresetMeta[]>([]);
|
||||||
@@ -216,11 +262,15 @@ interface GameScreenProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The main play view — an operations console. A top status bar carries the
|
* The main play view — a full-bleed world map with a floating HUD. The MapLibre
|
||||||
* scenario, the current turn + date, the player nation (color-chipped), and a
|
* map is a fixed, full-viewport background (z-0); over it float draggable,
|
||||||
* live idle/simulating indicator. Below it, a three-column grid: the nation
|
* collapsible windows: a slim header bar (scenario, turn + date, player nation,
|
||||||
* dossier (left), the dominant map (center), and the command deck + event feed
|
* live status, "Focus map" collapse-all) at the top, the nation dossier
|
||||||
* (right). On small screens these stack map-first.
|
* (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({
|
function GameScreen({
|
||||||
game,
|
game,
|
||||||
@@ -239,115 +289,252 @@ function GameScreen({
|
|||||||
const viewed = game.nations[viewingId] ?? player;
|
const viewed = game.nations[viewingId] ?? player;
|
||||||
const nations = Object.values(game.nations).sort((a, b) => a.name.localeCompare(b.name));
|
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 (
|
return (
|
||||||
<div className="mx-auto flex min-h-screen max-w-[1600px] flex-col px-3 py-4 sm:px-4 lg:px-6">
|
<div className="min-h-screen">
|
||||||
<header
|
{/* Full-viewport background map, behind every floating window. */}
|
||||||
data-testid="game-header"
|
<WorldMap
|
||||||
className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-3 rounded-xl border bg-card/60 px-4 py-3"
|
regions={regions}
|
||||||
>
|
playerNationId={game.playerNationId}
|
||||||
<Button variant="ghost" size="sm" onClick={onHome}>
|
nations={game.nations}
|
||||||
← Scenarios
|
presetId={game.presetId}
|
||||||
</Button>
|
onFocusNation={setViewingId}
|
||||||
<div className="flex items-center gap-2.5">
|
onMapReady={handleMapReady}
|
||||||
<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="ml-auto flex flex-wrap items-center gap-x-4 gap-y-2">
|
{/* Slim floating header: brand, turn + date, player nation, live status, Focus map. */}
|
||||||
<div className="text-right leading-none">
|
<FloatingWindow
|
||||||
<div className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">Turn</div>
|
id="header"
|
||||||
<div className="mt-1 text-2xl font-bold tabular-nums" data-testid="turn-count">
|
anchor="top-center"
|
||||||
{game.turn}
|
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>
|
</div>
|
||||||
<div className="hidden h-9 w-px bg-border sm:block" />
|
}
|
||||||
<div className="text-right leading-none">
|
titleExtra={
|
||||||
<div className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">Date</div>
|
<div className="flex shrink-0 flex-wrap items-center gap-x-4 gap-y-1">
|
||||||
<div className="mt-1 text-sm font-semibold tabular-nums" data-testid="game-date">
|
<div className="text-right leading-none">
|
||||||
{game.date}
|
<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>
|
|
||||||
<div className="hidden h-9 w-px bg-border sm:block" />
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
<span
|
||||||
className="inline-block size-3.5 rounded-full ring-2 ring-white/10"
|
data-testid="status-indicator"
|
||||||
style={{ background: nationColor(game.playerNationId) }}
|
className={cn(
|
||||||
/>
|
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium',
|
||||||
<span className="text-sm font-medium" data-testid="player-nation">
|
busy
|
||||||
{player?.name ?? game.playerNationId}
|
? '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>
|
</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>
|
</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',
|
{/* Nation dossier, top-left. */}
|
||||||
busy ? 'border-primary/40 bg-primary/10 text-primary' : 'border-border bg-muted/40 text-muted-foreground',
|
<FloatingWindow
|
||||||
)}
|
id="dossier"
|
||||||
>
|
anchor="top-left"
|
||||||
{busy ? (
|
width={300}
|
||||||
<Loader2 className="size-3 animate-spin" />
|
title="Dossier"
|
||||||
) : (
|
titleIcon={<BookOpen className="size-4" />}
|
||||||
<CircleDot className="size-3" />
|
collapsed={!!collapsed.dossier}
|
||||||
)}
|
onCollapsedChange={(c) => setCollapsedFor('dossier', c)}
|
||||||
{busy ? 'Simulating…' : 'Idle'}
|
bodyClassName="max-h-[56vh] overflow-y-auto"
|
||||||
</span>
|
>
|
||||||
|
<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>
|
</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]">
|
{/* Event feed, right side. */}
|
||||||
{/* Left: nation dossier (mobile order 3). */}
|
<FloatingWindow
|
||||||
<section className="order-3 min-w-0 lg:order-1">
|
id="feed"
|
||||||
<div className="mb-2 flex items-center gap-2">
|
anchor="right"
|
||||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Inspect</span>
|
width={330}
|
||||||
<Select value={viewingId} onValueChange={setViewingId}>
|
title="Event feed"
|
||||||
<SelectTrigger className="w-full" data-testid="inspect-select">
|
titleIcon={<Activity className="size-4" />}
|
||||||
<SelectValue />
|
titleExtra={
|
||||||
</SelectTrigger>
|
<Badge variant="secondary" className="shrink-0 text-xs tabular-nums">
|
||||||
<SelectContent>
|
{game.events.length}
|
||||||
{nations.map((n) => (
|
</Badge>
|
||||||
<SelectItem key={n.id} value={n.id}>
|
}
|
||||||
{n.name}
|
collapsed={!!collapsed.feed}
|
||||||
</SelectItem>
|
onCollapsedChange={(c) => setCollapsedFor('feed', c)}
|
||||||
))}
|
bodyClassName="h-[52vh]"
|
||||||
</SelectContent>
|
>
|
||||||
</Select>
|
<EventFeed events={game.events} nations={game.nations} inWindow />
|
||||||
</div>
|
</FloatingWindow>
|
||||||
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Center: the dominant map (mobile order 1). */}
|
{/* Command deck ("Your move"), bottom-center — the prominent typing spot. */}
|
||||||
<section className="order-1 min-w-0 lg:order-2">
|
<FloatingWindow
|
||||||
<div className="relative h-[58vh] min-h-[420px] overflow-hidden rounded-xl border lg:h-[80vh]">
|
id="action"
|
||||||
<WorldMap
|
anchor="bottom-center"
|
||||||
regions={regions}
|
width={370}
|
||||||
playerNationId={game.playerNationId}
|
title="Your move"
|
||||||
nations={game.nations}
|
titleIcon={<Radar className="size-4 text-primary" />}
|
||||||
presetId={game.presetId}
|
titleExtra={
|
||||||
onFocusNation={setViewingId}
|
<Button
|
||||||
className="h-full w-full"
|
variant="secondary"
|
||||||
/>
|
size="sm"
|
||||||
</div>
|
onClick={onJump}
|
||||||
</section>
|
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). */}
|
{/* Nation-color legend, bottom-left. */}
|
||||||
<aside className="order-2 flex min-h-0 min-w-0 flex-col gap-4 lg:order-3">
|
<FloatingWindow
|
||||||
<ActionPanel
|
id="legend"
|
||||||
game={game}
|
anchor="bottom-left"
|
||||||
busy={busy}
|
width={170}
|
||||||
advisors={advisors}
|
title="Legend"
|
||||||
onAction={onAction}
|
titleIcon={<ListTree className="size-4" />}
|
||||||
onChat={onChat}
|
collapsed={!!collapsed.legend}
|
||||||
onAdvisor={onAdvisor}
|
onCollapsedChange={(c) => setCollapsedFor('legend', c)}
|
||||||
onJump={onJump}
|
>
|
||||||
/>
|
<MapLegend regions={regions} />
|
||||||
<div className="min-h-[360px] flex-1 lg:min-h-0" data-testid="event-feed-slot">
|
</FloatingWindow>
|
||||||
<EventFeed events={game.events} nations={game.nations} />
|
|
||||||
</div>
|
{/* Tiny floating zoom control, bottom-right (clear of the attribution). */}
|
||||||
</aside>
|
<FloatingWindow
|
||||||
</main>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+135
-103
@@ -18,6 +18,12 @@ interface Props {
|
|||||||
onChat: (to: string, message: string) => void;
|
onChat: (to: string, message: string) => void;
|
||||||
onAdvisor: (prompt?: string) => void;
|
onAdvisor: (prompt?: string) => void;
|
||||||
onJump: () => void;
|
onJump: () => void;
|
||||||
|
/**
|
||||||
|
* Render without the outer card chrome (the surrounding floating window
|
||||||
|
* provides the border, the "Your move" title, and the "Jump forward" button
|
||||||
|
* in its title bar). Same content, same testids.
|
||||||
|
*/
|
||||||
|
inWindow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,7 +34,16 @@ interface Props {
|
|||||||
* that turns take a few seconds. A "Jump forward" button auto-simulates the
|
* that turns take a few seconds. A "Jump forward" button auto-simulates the
|
||||||
* world with no player move.
|
* world with no player move.
|
||||||
*/
|
*/
|
||||||
export default function ActionPanel({ game, busy, advisors, onAction, onChat, onAdvisor, onJump }: Props) {
|
export default function ActionPanel({
|
||||||
|
game,
|
||||||
|
busy,
|
||||||
|
advisors,
|
||||||
|
onAction,
|
||||||
|
onChat,
|
||||||
|
onAdvisor,
|
||||||
|
onJump,
|
||||||
|
inWindow = false,
|
||||||
|
}: Props) {
|
||||||
const [tab, setTab] = useState('action');
|
const [tab, setTab] = useState('action');
|
||||||
const [action, setAction] = useState('');
|
const [action, setAction] = useState('');
|
||||||
const [target, setTarget] = useState('');
|
const [target, setTarget] = useState('');
|
||||||
@@ -82,110 +97,127 @@ export default function ActionPanel({ game, busy, advisors, onAction, onChat, on
|
|||||||
else onAdvisor(prompt.trim() || undefined);
|
else onAdvisor(prompt.trim() || undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// In window mode the surrounding FloatingWindow shows "Your move" and the
|
||||||
|
// "Jump forward" button in its own title bar, so omit both here.
|
||||||
|
const header = inWindow ? null : (
|
||||||
|
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Radar className="size-4 text-primary" />
|
||||||
|
<CardTitle className="text-base">Your move</CardTitle>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" size="sm" onClick={onJump} disabled={busy} data-testid="jump-forward">
|
||||||
|
{busy ? 'Simulating…' : 'Jump forward'}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<Tabs value={tab} onValueChange={setTab} className="gap-3">
|
||||||
|
<TabsList className="grid w-full grid-cols-3" data-testid="action-tabs">
|
||||||
|
<TabsTrigger value="action" data-testid="tab-action">
|
||||||
|
Action
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="diplo" data-testid="tab-diplo">
|
||||||
|
Diplomacy
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="advisor" data-testid="tab-advisor">
|
||||||
|
Advisor
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="action" className="space-y-2">
|
||||||
|
<Textarea
|
||||||
|
value={action}
|
||||||
|
onChange={(e) => setAction(e.target.value)}
|
||||||
|
placeholder="Describe your move — e.g. “I mobilize reserves and fortify the western border.”"
|
||||||
|
rows={3}
|
||||||
|
data-testid="action-textarea"
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="diplo" className="space-y-2">
|
||||||
|
<Select value={target} onValueChange={setTarget}>
|
||||||
|
<SelectTrigger className="w-full" data-testid="chat-target">
|
||||||
|
<SelectValue placeholder="Choose a nation to address…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{others.map(([id, n]) => (
|
||||||
|
<SelectItem key={id} value={id}>
|
||||||
|
{n.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Textarea
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder="Your diplomatic message…"
|
||||||
|
rows={3}
|
||||||
|
data-testid="chat-message"
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="advisor" className="space-y-2">
|
||||||
|
<Input
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder="Optional focus — e.g. “the nuclear standoff”"
|
||||||
|
data-testid="advisor-prompt"
|
||||||
|
/>
|
||||||
|
{advisors.length > 0 && (
|
||||||
|
<ul className="space-y-1.5" data-testid="advisor-list">
|
||||||
|
{advisors.map((a, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex items-start gap-2 rounded-md border bg-card/50 p-2 text-sm"
|
||||||
|
data-testid="advisor-item"
|
||||||
|
>
|
||||||
|
<span className="flex-1 leading-snug">{a}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => applyIdea(a)}>
|
||||||
|
Use
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<Button className="h-11 w-full" onClick={send} disabled={sendDisabled} data-testid="action-submit">
|
||||||
|
{busy ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="size-4" />
|
||||||
|
)}
|
||||||
|
{sendLabel}
|
||||||
|
</Button>
|
||||||
|
{busy && (
|
||||||
|
<p
|
||||||
|
className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground"
|
||||||
|
data-testid="sim-hint"
|
||||||
|
>
|
||||||
|
<span className="sim-pulse inline-block size-1.5 rounded-full bg-primary" />
|
||||||
|
The world is thinking — turns can take a few seconds.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (inWindow) {
|
||||||
|
return (
|
||||||
|
<div data-testid="action-panel" className="p-4">
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="gap-4" data-testid="action-panel">
|
<Card className="gap-4" data-testid="action-panel">
|
||||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
{header}
|
||||||
<div className="flex items-center gap-2">
|
<CardContent>{body}</CardContent>
|
||||||
<Radar className="size-4 text-primary" />
|
|
||||||
<CardTitle className="text-base">Your move</CardTitle>
|
|
||||||
</div>
|
|
||||||
<Button variant="secondary" size="sm" onClick={onJump} disabled={busy} data-testid="jump-forward">
|
|
||||||
{busy ? 'Simulating…' : 'Jump forward'}
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<Tabs value={tab} onValueChange={setTab} className="gap-3">
|
|
||||||
<TabsList className="grid w-full grid-cols-3" data-testid="action-tabs">
|
|
||||||
<TabsTrigger value="action" data-testid="tab-action">
|
|
||||||
Action
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="diplo" data-testid="tab-diplo">
|
|
||||||
Diplomacy
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="advisor" data-testid="tab-advisor">
|
|
||||||
Advisor
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="action" className="space-y-2">
|
|
||||||
<Textarea
|
|
||||||
value={action}
|
|
||||||
onChange={(e) => setAction(e.target.value)}
|
|
||||||
placeholder="Describe your move — e.g. “I mobilize reserves and fortify the western border.”"
|
|
||||||
rows={3}
|
|
||||||
data-testid="action-textarea"
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="diplo" className="space-y-2">
|
|
||||||
<Select value={target} onValueChange={setTarget}>
|
|
||||||
<SelectTrigger className="w-full" data-testid="chat-target">
|
|
||||||
<SelectValue placeholder="Choose a nation to address…" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{others.map(([id, n]) => (
|
|
||||||
<SelectItem key={id} value={id}>
|
|
||||||
{n.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Textarea
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
placeholder="Your diplomatic message…"
|
|
||||||
rows={3}
|
|
||||||
data-testid="chat-message"
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="advisor" className="space-y-2">
|
|
||||||
<Input
|
|
||||||
value={prompt}
|
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
|
||||||
placeholder="Optional focus — e.g. “the nuclear standoff”"
|
|
||||||
data-testid="advisor-prompt"
|
|
||||||
/>
|
|
||||||
{advisors.length > 0 && (
|
|
||||||
<ul className="space-y-1.5" data-testid="advisor-list">
|
|
||||||
{advisors.map((a, i) => (
|
|
||||||
<li
|
|
||||||
key={i}
|
|
||||||
className="flex items-start gap-2 rounded-md border bg-card/50 p-2 text-sm"
|
|
||||||
data-testid="advisor-item"
|
|
||||||
>
|
|
||||||
<span className="flex-1 leading-snug">{a}</span>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => applyIdea(a)}>
|
|
||||||
Use
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
<Button className="h-11 w-full" onClick={send} disabled={sendDisabled} data-testid="action-submit">
|
|
||||||
{busy ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Send className="size-4" />
|
|
||||||
)}
|
|
||||||
{sendLabel}
|
|
||||||
</Button>
|
|
||||||
{busy && (
|
|
||||||
<p
|
|
||||||
className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground"
|
|
||||||
data-testid="sim-hint"
|
|
||||||
>
|
|
||||||
<span className="sim-pulse inline-block size-1.5 rounded-full bg-primary" />
|
|
||||||
The world is thinking — turns can take a few seconds.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ const EVENT_ICONS: Record<EventType, LucideIcon> = {
|
|||||||
interface Props {
|
interface Props {
|
||||||
events: EventEntry[];
|
events: EventEntry[];
|
||||||
nations: Record<string, Nation>;
|
nations: Record<string, Nation>;
|
||||||
|
/**
|
||||||
|
* Render without the outer chrome and the built-in header (the surrounding
|
||||||
|
* floating window provides the border, the "Event feed" title, and the count
|
||||||
|
* badge). Same content, same testids.
|
||||||
|
*/
|
||||||
|
inWindow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,26 +44,34 @@ interface Props {
|
|||||||
* icon, a color-coded outcome badge, a nation-colored accent edge, and the turn
|
* icon, a color-coded outcome badge, a nation-colored accent edge, and the turn
|
||||||
* + date it resolved on. New events slide in and flash briefly at the top.
|
* + date it resolved on. New events slide in and flash briefly at the top.
|
||||||
*/
|
*/
|
||||||
export default function EventFeed({ events, nations }: Props) {
|
export default function EventFeed({ events, nations, inWindow = false }: Props) {
|
||||||
const latestFirst = [...events].reverse();
|
const latestFirst = [...events].reverse();
|
||||||
|
|
||||||
|
const header = inWindow ? null : (
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Radar className="size-4 text-primary" />
|
||||||
|
<h2 className="text-sm font-semibold tracking-wide">Event feed</h2>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="text-xs tabular-nums">
|
||||||
|
{events.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-full min-h-0 flex-col rounded-xl border bg-card"
|
className={
|
||||||
|
inWindow
|
||||||
|
? 'flex h-full min-h-0 flex-col'
|
||||||
|
: 'flex h-full min-h-0 flex-col rounded-xl border bg-card'
|
||||||
|
}
|
||||||
data-testid="event-feed-panel"
|
data-testid="event-feed-panel"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
{header}
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Radar className="size-4 text-primary" />
|
|
||||||
<h2 className="text-sm font-semibold tracking-wide">Event feed</h2>
|
|
||||||
</div>
|
|
||||||
<Badge variant="secondary" className="text-xs tabular-nums">
|
|
||||||
{events.length}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea className="mx-3 mb-3 min-h-0 flex-1">
|
<ScrollArea className={inWindow ? 'min-h-0 flex-1' : 'mx-3 mb-3 min-h-0 flex-1'}>
|
||||||
<div className="space-y-2 pr-1" data-testid="event-feed">
|
<div className="space-y-2 p-3 pr-1" data-testid="event-feed">
|
||||||
{latestFirst.length === 0 && (
|
{latestFirst.length === 0 && (
|
||||||
<div className="mt-3 rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
<div className="mt-3 rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||||
No events yet. Take an action, send a message, or jump forward to move the world.
|
No events yet. Take an action, send a message, or jump forward to move the world.
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { Minus, Plus } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/** Edge anchor used to place a window on first run (before the user drags it). */
|
||||||
|
export type FloatAnchor =
|
||||||
|
| 'top-left'
|
||||||
|
| 'top-center'
|
||||||
|
| 'top-right'
|
||||||
|
| 'right'
|
||||||
|
| 'bottom-left'
|
||||||
|
| 'bottom-center'
|
||||||
|
| 'bottom-right';
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'history.floatwin.v1.';
|
||||||
|
|
||||||
|
// Module-level z-index bookkeeping so sibling windows stack in click order:
|
||||||
|
// every window starts at the base (10); each time a *different* window is
|
||||||
|
// raised, the shared counter advances, so the newest on top always wins.
|
||||||
|
const Z_BASE = 10;
|
||||||
|
const Z_STEP = 10;
|
||||||
|
let zSeq = 0;
|
||||||
|
let zTopId: string | null = null;
|
||||||
|
/** Returns the new z-index to use, or -1 if this window is already front-most. */
|
||||||
|
function nextZ(id: string): number {
|
||||||
|
if (zTopId === id) return -1;
|
||||||
|
zSeq += 1;
|
||||||
|
zTopId = id;
|
||||||
|
return Z_BASE + zSeq * Z_STEP;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pos {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a persisted position; any missing/corrupt storage falls back to null. */
|
||||||
|
function loadPos(id: string): Pos | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_PREFIX + 'pos.' + id);
|
||||||
|
if (raw == null) return null;
|
||||||
|
const v = JSON.parse(raw) as Partial<Pos>;
|
||||||
|
if (
|
||||||
|
typeof v.x !== 'number' ||
|
||||||
|
!Number.isFinite(v.x) ||
|
||||||
|
typeof v.y !== 'number' ||
|
||||||
|
!Number.isFinite(v.y)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { x: v.x, y: v.y };
|
||||||
|
} catch {
|
||||||
|
return null; // storage unavailable or unparseable — first-run placement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePos(id: string, x: number, y: number): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_PREFIX + 'pos.' + id, JSON.stringify({ x, y }));
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable (e.g. private mode) — dragging still works, just won't persist */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDGE = 12;
|
||||||
|
|
||||||
|
/** Resolve an anchor to a concrete top-left position for a window of w×h. */
|
||||||
|
function anchorToPos(anchor: FloatAnchor, w: number, h: number): Pos {
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
switch (anchor) {
|
||||||
|
case 'top-left':
|
||||||
|
return { x: EDGE, y: 72 };
|
||||||
|
case 'top-center':
|
||||||
|
return { x: Math.round((vw - w) / 2), y: 8 };
|
||||||
|
case 'top-right':
|
||||||
|
case 'right':
|
||||||
|
return { x: vw - w - EDGE, y: 72 };
|
||||||
|
case 'bottom-left':
|
||||||
|
return { x: EDGE, y: Math.max(EDGE, vh - h - EDGE) };
|
||||||
|
case 'bottom-center':
|
||||||
|
return { x: Math.round((vw - w) / 2), y: Math.max(64, vh - h - EDGE) };
|
||||||
|
case 'bottom-right':
|
||||||
|
return { x: vw - w - EDGE, y: Math.max(64, vh - h - EDGE) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp a top-left position so the window can never be dragged fully
|
||||||
|
* off-screen: at least MIN_W px of width and MIN_H px (the title bar) of
|
||||||
|
* height always remain inside the viewport.
|
||||||
|
*/
|
||||||
|
function clampPos(x: number, y: number, w: number, h: number): Pos {
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const MIN_W = 96;
|
||||||
|
const MIN_H = 36;
|
||||||
|
let minX = MIN_W - w;
|
||||||
|
let maxX = vw - MIN_W;
|
||||||
|
if (minX > maxX) {
|
||||||
|
minX = maxX = Math.round((minX + maxX) / 2);
|
||||||
|
}
|
||||||
|
let minY = MIN_H - h;
|
||||||
|
let maxY = vh - MIN_H;
|
||||||
|
if (minY > maxY) {
|
||||||
|
minY = maxY = Math.round((minY + maxY) / 2);
|
||||||
|
}
|
||||||
|
return { x: Math.min(Math.max(x, minX), maxX), y: Math.min(Math.max(y, minY), maxY) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FloatingWindowProps {
|
||||||
|
/** Stable id — used for the localStorage key and as a data-attribute. */
|
||||||
|
id: string;
|
||||||
|
/** First-run placement. The last dragged position (persisted) takes over. */
|
||||||
|
anchor: FloatAnchor;
|
||||||
|
/** Fixed window width in px (content is sized to it). Omit to fit content. */
|
||||||
|
width?: number;
|
||||||
|
/** Extra px nudged away from the anchor position (e.g. clear the map attribution). */
|
||||||
|
offset?: { x?: number; y?: number };
|
||||||
|
/** Title-bar content (text or node). The whole bar is the drag handle. */
|
||||||
|
title?: ReactNode;
|
||||||
|
/** Optional icon before the title. */
|
||||||
|
titleIcon?: ReactNode;
|
||||||
|
/** Right-side title-bar content, rendered before the collapse button. */
|
||||||
|
titleExtra?: ReactNode;
|
||||||
|
/** Render the collapse toggle (default true). */
|
||||||
|
collapsible?: boolean;
|
||||||
|
/** Controlled collapsed state — the parent persists/restores it. */
|
||||||
|
collapsed?: boolean;
|
||||||
|
onCollapsedChange?: (collapsed: boolean) => void;
|
||||||
|
/** Extra classes for the inner card (border/background). */
|
||||||
|
className?: string;
|
||||||
|
/** Extra classes for the body wrapper (max-height, scrolling, …). */
|
||||||
|
bodyClassName?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A draggable, collapsible, position-persisted window that floats over the
|
||||||
|
* full-screen map. Dragging is limited to the title bar (via pointer capture)
|
||||||
|
* so inputs and buttons in the body keep working; the window is clamped so it
|
||||||
|
* can never be dragged fully off-screen, its position is remembered in
|
||||||
|
* localStorage, clicking any part of it raises it above siblings, and the
|
||||||
|
* collapse toggle shrinks it to just its title bar.
|
||||||
|
*/
|
||||||
|
export default function FloatingWindow({
|
||||||
|
id,
|
||||||
|
anchor,
|
||||||
|
width,
|
||||||
|
offset,
|
||||||
|
title,
|
||||||
|
titleIcon,
|
||||||
|
titleExtra,
|
||||||
|
collapsible: collapsibleProp = true,
|
||||||
|
collapsed = false,
|
||||||
|
onCollapsedChange,
|
||||||
|
className,
|
||||||
|
bodyClassName,
|
||||||
|
children,
|
||||||
|
}: FloatingWindowProps) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sizeRef = useRef<{ w: number; h: number } | null>(null);
|
||||||
|
const dragRef = useRef<{ dx: number; dy: number; pointerId: number } | null>(null);
|
||||||
|
const posRef = useRef<Pos | null>(null);
|
||||||
|
const [pos, setPosState] = useState<Pos | null>(null);
|
||||||
|
const [z, setZ] = useState(Z_BASE);
|
||||||
|
|
||||||
|
const setPos = (p: Pos): void => {
|
||||||
|
posRef.current = p;
|
||||||
|
setPosState(p);
|
||||||
|
};
|
||||||
|
|
||||||
|
// First paint: measure, then place (restored position or anchor) and clamp.
|
||||||
|
// Runs before paint (useLayoutEffect), so there is no visible flash.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (posRef.current) return;
|
||||||
|
const el = rootRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0) return;
|
||||||
|
sizeRef.current = { w: rect.width, h: rect.height };
|
||||||
|
const base = loadPos(id) ?? anchorToPos(anchor, rect.width, rect.height);
|
||||||
|
const p = clampPos(
|
||||||
|
base.x + (offset?.x ?? 0),
|
||||||
|
base.y + (offset?.y ?? 0),
|
||||||
|
rect.width,
|
||||||
|
rect.height,
|
||||||
|
);
|
||||||
|
posRef.current = p;
|
||||||
|
setPosState(p);
|
||||||
|
}, [id, anchor]);
|
||||||
|
|
||||||
|
// Re-measure + re-clamp whenever the body toggles hidden (expand can push a
|
||||||
|
// bottom-anchored window off the bottom edge).
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = rootRef.current;
|
||||||
|
const p = posRef.current;
|
||||||
|
if (!el || !p) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0) return;
|
||||||
|
sizeRef.current = { w: rect.width, h: rect.height };
|
||||||
|
const c = clampPos(p.x, p.y, rect.width, rect.height);
|
||||||
|
if (c.x !== p.x || c.y !== p.y) setPos(c);
|
||||||
|
}, [collapsed]);
|
||||||
|
|
||||||
|
// Keep the window on-screen when the viewport shrinks.
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = () => {
|
||||||
|
const p = posRef.current;
|
||||||
|
const s = sizeRef.current;
|
||||||
|
if (!p || !s) return;
|
||||||
|
const c = clampPos(p.x, p.y, s.w, s.h);
|
||||||
|
if (c.x !== p.x || c.y !== p.y) setPos(c);
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
return () => window.removeEventListener('resize', onResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onHandleDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
// Never start a drag from an interactive element (buttons, inputs, …).
|
||||||
|
const t = e.target as HTMLElement | null;
|
||||||
|
if (t?.closest('button, a, input, textarea, select, [data-no-drag]')) return;
|
||||||
|
const el = rootRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
sizeRef.current = { w: r.width, h: r.height };
|
||||||
|
dragRef.current = { dx: e.clientX - r.left, dy: e.clientY - r.top, pointerId: e.pointerId };
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onHandleMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d || d.pointerId !== e.pointerId) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const s = sizeRef.current ?? { w: 200, h: 100 };
|
||||||
|
// Fixed positioning: moving this window never reflows the rest of the page.
|
||||||
|
setPos(clampPos(e.clientX - d.dx, e.clientY - d.dy, s.w, s.h));
|
||||||
|
};
|
||||||
|
|
||||||
|
const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d || d.pointerId !== e.pointerId) return;
|
||||||
|
dragRef.current = null;
|
||||||
|
try {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* pointer already released */
|
||||||
|
}
|
||||||
|
const p = posRef.current;
|
||||||
|
if (p) savePos(id, p.x, p.y);
|
||||||
|
};
|
||||||
|
|
||||||
|
const style: CSSProperties = {
|
||||||
|
zIndex: z,
|
||||||
|
...(pos ? { left: pos.x, top: pos.y } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
data-float-window={id}
|
||||||
|
style={style}
|
||||||
|
className={cn('fixed', !pos && 'pointer-events-none opacity-0')}
|
||||||
|
onPointerDown={() => {
|
||||||
|
const nz = nextZ(id);
|
||||||
|
if (nz > 0) setZ(nz);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'overflow-hidden rounded-xl border border-border/70 bg-slate-900/80 shadow-2xl shadow-black/50 backdrop-blur-md',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
width != null
|
||||||
|
? { width, maxWidth: '96vw' }
|
||||||
|
: { width: 'fit-content', maxWidth: '96vw' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{title != null && (
|
||||||
|
<div
|
||||||
|
className="flex cursor-grab touch-none select-none items-center gap-2 border-b border-border/60 bg-white/[0.03] px-2.5 py-1.5 active:cursor-grabbing"
|
||||||
|
onPointerDown={onHandleDown}
|
||||||
|
onPointerMove={onHandleMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
>
|
||||||
|
{titleIcon && (
|
||||||
|
<span className="shrink-0 text-muted-foreground" aria-hidden>
|
||||||
|
{titleIcon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-sm font-semibold tracking-wide">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{titleExtra}
|
||||||
|
{collapsibleProp && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={collapsed ? 'Expand window' : 'Collapse window'}
|
||||||
|
title={collapsed ? 'Expand' : 'Collapse'}
|
||||||
|
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
|
||||||
|
onClick={() => onCollapsedChange?.(!collapsed)}
|
||||||
|
>
|
||||||
|
{collapsed ? <Plus className="size-3.5" /> : <Minus className="size-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children != null && (
|
||||||
|
<div className={cn('min-h-0', collapsed && 'hidden', bodyClassName)}>{children}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { RegionMap } from '@shared/protocol';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small bottom-left legend mapping each nation on the map to its stable color.
|
||||||
|
* Rendered by the game screen as a minimal floating window over the map.
|
||||||
|
*/
|
||||||
|
export default function MapLegend({ regions }: { regions: RegionMap }) {
|
||||||
|
const owners = new Map<string, { color: string; owner: string }>();
|
||||||
|
for (const r of Object.values(regions)) {
|
||||||
|
if (r.ownerId && !owners.has(r.ownerId)) owners.set(r.ownerId, { color: r.color, owner: r.owner });
|
||||||
|
}
|
||||||
|
const list = [...owners.entries()].sort((a, b) => a[1].owner.localeCompare(b[1].owner));
|
||||||
|
if (list.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="map-legend"
|
||||||
|
className="max-h-[52vh] overflow-y-auto p-2.5"
|
||||||
|
>
|
||||||
|
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
Nations
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{list.map(([id, o]) => (
|
||||||
|
<li key={id} data-testid="map-legend-item" className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="inline-block size-2.5 shrink-0 rounded-sm" style={{ background: o.color }} />
|
||||||
|
<span className="truncate">{o.owner}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+123
-104
@@ -19,6 +19,11 @@ interface Props {
|
|||||||
nations: Record<string, Nation>;
|
nations: Record<string, Nation>;
|
||||||
/** True when this is the nation the player is leading. */
|
/** True when this is the nation the player is leading. */
|
||||||
isPlayer: boolean;
|
isPlayer: boolean;
|
||||||
|
/**
|
||||||
|
* Render without the outer card chrome (the surrounding floating window
|
||||||
|
* provides the border, backdrop, and title bar). Same content, same testids.
|
||||||
|
*/
|
||||||
|
inWindow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,116 +33,130 @@ interface Props {
|
|||||||
* relation (ally = green, rival = amber, war = red, neutral = slate). Used for
|
* relation (ally = green, rival = amber, war = red, neutral = slate). Used for
|
||||||
* the player (highlighted) or any inspected nation.
|
* the player (highlighted) or any inspected nation.
|
||||||
*/
|
*/
|
||||||
export default function NationPanel({ nation, nations, isPlayer }: Props) {
|
export default function NationPanel({ nation, nations, isPlayer, inWindow = false }: Props) {
|
||||||
const color = nationColor(nation.id);
|
const color = nationColor(nation.id);
|
||||||
const relations = Object.entries(nation.relations).sort(([a], [b]) => a.localeCompare(b));
|
const relations = Object.entries(nation.relations).sort(([a], [b]) => a.localeCompare(b));
|
||||||
const regions = nation.territory;
|
const regions = nation.territory;
|
||||||
|
|
||||||
|
const identity = (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className="inline-block size-4 shrink-0 rounded-md ring-2 ring-white/10"
|
||||||
|
style={{ background: color }}
|
||||||
|
data-testid="nation-color-chip"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1 space-y-0.5">
|
||||||
|
<CardTitle className="truncate text-lg">{nation.name}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{nation.type} · {regions.length} region{regions.length === 1 ? '' : 's'}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{isPlayer && (
|
||||||
|
<Badge variant="default" className="shrink-0">
|
||||||
|
Your nation
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const sections = (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Status
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<StatBar
|
||||||
|
label="Military"
|
||||||
|
value={nation.stats.military}
|
||||||
|
testId={isPlayer ? 'stat-military' : undefined}
|
||||||
|
/>
|
||||||
|
<StatBar
|
||||||
|
label="Economy"
|
||||||
|
value={nation.stats.economy}
|
||||||
|
testId={isPlayer ? 'stat-economy' : undefined}
|
||||||
|
/>
|
||||||
|
<StatBar
|
||||||
|
label="Stability"
|
||||||
|
value={nation.stats.stability}
|
||||||
|
testId={isPlayer ? 'stat-stability' : undefined}
|
||||||
|
/>
|
||||||
|
<StatBar
|
||||||
|
label="Population"
|
||||||
|
value={nation.stats.population}
|
||||||
|
testId={isPlayer ? 'stat-population' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Territory
|
||||||
|
</h3>
|
||||||
|
{regions.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{regions.map((code) => (
|
||||||
|
<span
|
||||||
|
key={code}
|
||||||
|
className="rounded border px-1.5 py-0.5 text-xs tabular-nums"
|
||||||
|
style={{ borderColor: `${color}66`, color, background: `${color}12` }}
|
||||||
|
>
|
||||||
|
{code}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No territory.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Diplomacy
|
||||||
|
</h3>
|
||||||
|
{relations.length > 0 ? (
|
||||||
|
<ul className="space-y-1.5" data-testid="relations-list">
|
||||||
|
{relations.map(([id, status]) => {
|
||||||
|
const other = nations[id];
|
||||||
|
const c = RELATION_COLORS[status] ?? '#94a3b8';
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={id}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
data-testid="relation-row"
|
||||||
|
data-status={status}
|
||||||
|
>
|
||||||
|
<span className="inline-block size-2.5 shrink-0 rounded-full" style={{ background: c }} />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-foreground/85">
|
||||||
|
{other?.name ?? id}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs font-medium" style={{ color: c }}>
|
||||||
|
{RELATION_LABEL[status] ?? status}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No diplomatic relations yet.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (inWindow) {
|
||||||
|
return (
|
||||||
|
<div data-testid="nation-panel" className="space-y-4 p-4">
|
||||||
|
{identity}
|
||||||
|
{sections}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card data-testid="nation-panel" className={isPlayer ? 'border-primary/40' : undefined}>
|
<Card data-testid="nation-panel" className={isPlayer ? 'border-primary/40' : undefined}>
|
||||||
<CardHeader className="space-y-0">
|
<CardHeader className="space-y-0">{identity}</CardHeader>
|
||||||
<div className="flex items-center gap-3">
|
<CardContent>{sections}</CardContent>
|
||||||
<span
|
|
||||||
className="inline-block size-4 shrink-0 rounded-md ring-2 ring-white/10"
|
|
||||||
style={{ background: color }}
|
|
||||||
data-testid="nation-color-chip"
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1 space-y-0.5">
|
|
||||||
<CardTitle className="truncate text-lg">{nation.name}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{nation.type} · {regions.length} region{regions.length === 1 ? '' : 's'}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
{isPlayer && (
|
|
||||||
<Badge variant="default" className="shrink-0">
|
|
||||||
Your nation
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-5">
|
|
||||||
<section>
|
|
||||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
||||||
Status
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<StatBar
|
|
||||||
label="Military"
|
|
||||||
value={nation.stats.military}
|
|
||||||
testId={isPlayer ? 'stat-military' : undefined}
|
|
||||||
/>
|
|
||||||
<StatBar
|
|
||||||
label="Economy"
|
|
||||||
value={nation.stats.economy}
|
|
||||||
testId={isPlayer ? 'stat-economy' : undefined}
|
|
||||||
/>
|
|
||||||
<StatBar
|
|
||||||
label="Stability"
|
|
||||||
value={nation.stats.stability}
|
|
||||||
testId={isPlayer ? 'stat-stability' : undefined}
|
|
||||||
/>
|
|
||||||
<StatBar
|
|
||||||
label="Population"
|
|
||||||
value={nation.stats.population}
|
|
||||||
testId={isPlayer ? 'stat-population' : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
||||||
Territory
|
|
||||||
</h3>
|
|
||||||
{regions.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{regions.map((code) => (
|
|
||||||
<span
|
|
||||||
key={code}
|
|
||||||
className="rounded border px-1.5 py-0.5 text-xs tabular-nums"
|
|
||||||
style={{ borderColor: `${color}66`, color, background: `${color}12` }}
|
|
||||||
>
|
|
||||||
{code}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">No territory.</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
||||||
Diplomacy
|
|
||||||
</h3>
|
|
||||||
{relations.length > 0 ? (
|
|
||||||
<ul className="space-y-1.5" data-testid="relations-list">
|
|
||||||
{relations.map(([id, status]) => {
|
|
||||||
const other = nations[id];
|
|
||||||
const c = RELATION_COLORS[status] ?? '#94a3b8';
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={id}
|
|
||||||
className="flex items-center gap-2 text-sm"
|
|
||||||
data-testid="relation-row"
|
|
||||||
data-status={status}
|
|
||||||
>
|
|
||||||
<span className="inline-block size-2.5 shrink-0 rounded-full" style={{ background: c }} />
|
|
||||||
<span className="min-w-0 flex-1 truncate text-foreground/85">
|
|
||||||
{other?.name ?? id}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-xs font-medium" style={{ color: c }}>
|
|
||||||
{RELATION_LABEL[status] ?? status}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">No diplomatic relations yet.</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-35
@@ -124,32 +124,11 @@ function clickHtml(countryName: string, region?: RegionOwner, nation?: Nation):
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Small bottom-left legend mapping each nation on the map to its stable color. */
|
/** Minimal map API exposed to the floating zoom control (and any other UI). */
|
||||||
function MapLegend({ regions }: { regions: RegionMap }) {
|
export interface MapApi {
|
||||||
const owners = new Map<string, { color: string; owner: string }>();
|
zoomIn: () => void;
|
||||||
for (const r of Object.values(regions)) {
|
zoomOut: () => void;
|
||||||
if (r.ownerId && !owners.has(r.ownerId)) owners.set(r.ownerId, { color: r.color, owner: r.owner });
|
reset: () => void;
|
||||||
}
|
|
||||||
const list = [...owners.entries()].sort((a, b) => a[1].owner.localeCompare(b[1].owner));
|
|
||||||
if (list.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-testid="map-legend"
|
|
||||||
className="pointer-events-none absolute bottom-3 left-3 z-10 max-h-[62%] overflow-hidden rounded-lg border bg-card/85 p-2.5 backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
||||||
Nations
|
|
||||||
</div>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{list.map(([id, o]) => (
|
|
||||||
<li key={id} data-testid="map-legend-item" className="flex items-center gap-1.5 text-xs">
|
|
||||||
<span className="inline-block size-2.5 shrink-0 rounded-sm" style={{ background: o.color }} />
|
|
||||||
<span className="truncate">{o.owner}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -162,16 +141,31 @@ interface Props {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/** Called when a region is clicked, so the dossier can focus its owner. */
|
/** Called when a region is clicked, so the dossier can focus its owner. */
|
||||||
onFocusNation?: (id: string) => void;
|
onFocusNation?: (id: string) => void;
|
||||||
|
/**
|
||||||
|
* Called with a minimal zoom/pan API once the map is ready (and with `null`
|
||||||
|
* on unmount), so the floating zoom control can drive it.
|
||||||
|
*/
|
||||||
|
onMapReady?: (api: MapApi | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* World basemap (MapLibre GL) + a country choropleth colored by the owning
|
* World basemap (MapLibre GL) + a country choropleth colored by the owning
|
||||||
* nation. The choropleth is the star: a near-black basemap, per-nation stable
|
* nation. Rendered as a fixed, full-viewport background layer (z-0) behind the
|
||||||
* fills, subtle borders, a brighter/thicker border for the player, hover + click
|
* floating HUD windows: a near-black basemap, per-nation stable fills, subtle
|
||||||
* popups with owner + stats, a transient flash on territory changes, a
|
* borders, a brighter/thicker border for the player, hover + click popups with
|
||||||
* bottom-left legend, and a per-scenario initial camera.
|
* owner + stats, a transient flash on territory changes, and a
|
||||||
|
* per-scenario initial camera. It exposes a tiny zoom/pan API so the floating
|
||||||
|
* zoom control can drive it (no MapLibre navigation controls are shown).
|
||||||
*/
|
*/
|
||||||
export default function WorldMap({ regions, playerNationId, nations, presetId, className, onFocusNation }: Props) {
|
export default function WorldMap({
|
||||||
|
regions,
|
||||||
|
playerNationId,
|
||||||
|
nations,
|
||||||
|
presetId,
|
||||||
|
className,
|
||||||
|
onFocusNation,
|
||||||
|
onMapReady,
|
||||||
|
}: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
const baseGeoRef = useRef<FeatureCollection | null>(null);
|
const baseGeoRef = useRef<FeatureCollection | null>(null);
|
||||||
@@ -181,11 +175,13 @@ export default function WorldMap({ regions, playerNationId, nations, presetId, c
|
|||||||
const playerRef = useRef(playerNationId);
|
const playerRef = useRef(playerNationId);
|
||||||
const presetRef = useRef(presetId);
|
const presetRef = useRef(presetId);
|
||||||
const onFocusNationRef = useRef(onFocusNation);
|
const onFocusNationRef = useRef(onFocusNation);
|
||||||
|
const onMapReadyRef = useRef(onMapReady);
|
||||||
regionsRef.current = regions;
|
regionsRef.current = regions;
|
||||||
nationsRef.current = nations;
|
nationsRef.current = nations;
|
||||||
playerRef.current = playerNationId;
|
playerRef.current = playerNationId;
|
||||||
presetRef.current = presetId;
|
presetRef.current = presetId;
|
||||||
onFocusNationRef.current = onFocusNation;
|
onFocusNationRef.current = onFocusNation;
|
||||||
|
onMapReadyRef.current = onMapReady;
|
||||||
|
|
||||||
// Diffing state for the transient territory-change flash (owned by this component
|
// Diffing state for the transient territory-change flash (owned by this component
|
||||||
// so a resync with identical data never re-flashes).
|
// so a resync with identical data never re-flashes).
|
||||||
@@ -210,6 +206,17 @@ export default function WorldMap({ regions, playerNationId, nations, presetId, c
|
|||||||
});
|
});
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|
||||||
|
// Expose a tiny zoom/pan API to the floating control (null on unmount).
|
||||||
|
const api: MapApi = {
|
||||||
|
zoomIn: () => map.zoomIn(),
|
||||||
|
zoomOut: () => map.zoomOut(),
|
||||||
|
reset: () => {
|
||||||
|
const c = cameraForPreset(presetRef.current);
|
||||||
|
map.easeTo({ center: c.center, zoom: c.zoom, duration: 600 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
onMapReadyRef.current?.(api);
|
||||||
|
|
||||||
const hoverPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 10 });
|
const hoverPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 10 });
|
||||||
const clickPopup = new maplibregl.Popup({ closeButton: true, closeOnClick: true, offset: 12 });
|
const clickPopup = new maplibregl.Popup({ closeButton: true, closeOnClick: true, offset: 12 });
|
||||||
let lastHoverId: string | null = null;
|
let lastHoverId: string | null = null;
|
||||||
@@ -399,6 +406,7 @@ export default function WorldMap({ regions, playerNationId, nations, presetId, c
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
cancelAnimationFrame(flashRaf);
|
cancelAnimationFrame(flashRaf);
|
||||||
flashApiRef.current = null;
|
flashApiRef.current = null;
|
||||||
|
onMapReadyRef.current?.(null);
|
||||||
map.remove();
|
map.remove();
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
};
|
};
|
||||||
@@ -429,10 +437,15 @@ export default function WorldMap({ regions, playerNationId, nations, presetId, c
|
|||||||
}
|
}
|
||||||
}, [regions, playerNationId]);
|
}, [regions, playerNationId]);
|
||||||
|
|
||||||
|
// Full-viewport, edge-to-edge background layer. `position: fixed` is set
|
||||||
|
// inline because MapLibre's own stylesheet sets `.maplibregl-map` to
|
||||||
|
// `position: relative`, which would otherwise win over a Tailwind class.
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative h-full w-full', className)}>
|
<div
|
||||||
<div ref={containerRef} data-testid="world-map" className="h-full w-full" />
|
ref={containerRef}
|
||||||
<MapLegend regions={regions} />
|
data-testid="world-map"
|
||||||
</div>
|
className={cn('h-full w-full', className)}
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 0 }}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -95,9 +95,17 @@
|
|||||||
"Helvetica Neue", Arial, sans-serif;
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
/* MapLibre canvas: give the map pane a solid dark base behind the tiles. */
|
/* MapLibre canvas: give the map pane a solid dark base behind the tiles.
|
||||||
|
`!important` keeps it from being overridden by maplibre-gl's own
|
||||||
|
stylesheet (which is imported later in the bundle). */
|
||||||
.maplibregl-canvas {
|
.maplibregl-canvas {
|
||||||
background-color: var(--background);
|
background-color: var(--background) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Radix portals (select dropdowns, tooltips) are rendered on <body>; keep
|
||||||
|
them above the floating HUD windows, whose z-index grows on every raise. */
|
||||||
|
[data-radix-popper-content-wrapper] {
|
||||||
|
z-index: 5000 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user