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
+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 }}
/>
);
}