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
+135 -103
View File
@@ -18,6 +18,12 @@ interface Props {
onChat: (to: string, message: string) => void;
onAdvisor: (prompt?: string) => 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
* 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 [action, setAction] = useState('');
const [target, setTarget] = useState('');
@@ -82,110 +97,127 @@ export default function ActionPanel({ game, busy, advisors, onAction, onChat, on
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 (
<Card className="gap-4" data-testid="action-panel">
<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>
<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>
{header}
<CardContent>{body}</CardContent>
</Card>
);
}
+27 -13
View File
@@ -31,6 +31,12 @@ const EVENT_ICONS: Record<EventType, LucideIcon> = {
interface Props {
events: EventEntry[];
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
* + 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 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 (
<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"
>
<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>
{header}
<ScrollArea className="mx-3 mb-3 min-h-0 flex-1">
<div className="space-y-2 pr-1" data-testid="event-feed">
<ScrollArea className={inWindow ? 'min-h-0 flex-1' : 'mx-3 mb-3 min-h-0 flex-1'}>
<div className="space-y-2 p-3 pr-1" data-testid="event-feed">
{latestFirst.length === 0 && (
<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.
+324
View File
@@ -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>
);
}
+32
View File
@@ -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
View File
@@ -19,6 +19,11 @@ interface Props {
nations: Record<string, Nation>;
/** True when this is the nation the player is leading. */
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
* 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 relations = Object.entries(nation.relations).sort(([a], [b]) => a.localeCompare(b));
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 (
<Card data-testid="nation-panel" className={isPlayer ? 'border-primary/40' : undefined}>
<CardHeader className="space-y-0">
<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>
</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>
<CardHeader className="space-y-0">{identity}</CardHeader>
<CardContent>{sections}</CardContent>
</Card>
);
}
+48 -35
View File
@@ -124,32 +124,11 @@ function clickHtml(countryName: string, region?: RegionOwner, nation?: Nation):
</div>`;
}
/** Small bottom-left legend mapping each nation on the map to its stable color. */
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="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>
);
/** Minimal map API exposed to the floating zoom control (and any other UI). */
export interface MapApi {
zoomIn: () => void;
zoomOut: () => void;
reset: () => void;
}
interface Props {
@@ -162,16 +141,31 @@ interface Props {
className?: string;
/** Called when a region is clicked, so the dossier can focus its owner. */
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
* nation. The choropleth is the star: a near-black basemap, per-nation stable
* fills, subtle borders, a brighter/thicker border for the player, hover + click
* popups with owner + stats, a transient flash on territory changes, a
* bottom-left legend, and a per-scenario initial camera.
* nation. Rendered as a fixed, full-viewport background layer (z-0) behind the
* floating HUD windows: a near-black basemap, per-nation stable fills, subtle
* borders, a brighter/thicker border for the player, hover + click popups with
* 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 mapRef = useRef<maplibregl.Map | 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 presetRef = useRef(presetId);
const onFocusNationRef = useRef(onFocusNation);
const onMapReadyRef = useRef(onMapReady);
regionsRef.current = regions;
nationsRef.current = nations;
playerRef.current = playerNationId;
presetRef.current = presetId;
onFocusNationRef.current = onFocusNation;
onMapReadyRef.current = onMapReady;
// Diffing state for the transient territory-change flash (owned by this component
// 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;
// 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 clickPopup = new maplibregl.Popup({ closeButton: true, closeOnClick: true, offset: 12 });
let lastHoverId: string | null = null;
@@ -399,6 +406,7 @@ export default function WorldMap({ regions, playerNationId, nations, presetId, c
cancelled = true;
cancelAnimationFrame(flashRaf);
flashApiRef.current = null;
onMapReadyRef.current?.(null);
map.remove();
mapRef.current = null;
};
@@ -429,10 +437,15 @@ export default function WorldMap({ regions, playerNationId, nations, presetId, c
}
}, [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 (
<div className={cn('relative h-full w-full', className)}>
<div ref={containerRef} data-testid="world-map" className="h-full w-full" />
<MapLegend regions={regions} />
</div>
<div
ref={containerRef}
data-testid="world-map"
className={cn('h-full w-full', className)}
style={{ position: 'fixed', inset: 0, zIndex: 0 }}
/>
);
}