v0.5.0 — HUD chrome: remove Map window, pin top bar, dockable left/right panels #17
@@ -76,6 +76,7 @@ history/
|
||||
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
|
||||
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward"
|
||||
├── EventFeed.tsx # scrolling event log (newest first)
|
||||
├── FloatingWindow.tsx # the HUD window primitive — free-floating (draggable, position persisted), or `pinned` (fixed top-center bar), or `dock`ed (full-height left/right column); renders dock controls when `onDockChange` is set
|
||||
├── StatBar.tsx
|
||||
└── ui/ # 14 shadcn/ui primitives
|
||||
```
|
||||
@@ -142,6 +143,15 @@ history/
|
||||
the player is allied with the first other nation (by key order in the `nations` record), and the
|
||||
date advances 2 days. Unit + e2e assertions rely on these exact values, so keep the stub
|
||||
deterministic.
|
||||
- **HUD layout.** The game screen is a full-viewport map (z-0) with `FloatingWindow` overlays. The
|
||||
top bar is **pinned** — a fixed, non-movable bar centered at the top a few px down (it re-centers
|
||||
on resize and always floats above the other windows). The **dossier / event-feed / action**
|
||||
panels are free-floating by default (the same first-run positions as before) but can each be
|
||||
**docked** into a full-height left or right pane; the choice is persisted in `localStorage`
|
||||
(`history.floatwin.v1.dock`) and restored on reload, so the default "float" reproduces the
|
||||
original layout until the user docks something. The **legend** stays a small floating widget (not
|
||||
dockable). There is **no "Map" window** — the map is zoomed/panned directly (wheel/trackpad +
|
||||
drag).
|
||||
- Styling: Tailwind utility classes + shadcn/ui only; dark "command-center" theme (slate bg, amber
|
||||
primary, cyan accent). No CSS beyond the Tailwind import in `src/index.css`.
|
||||
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
All notable changes to **History** are listed here, newest first.
|
||||
|
||||
## v0.5.0 — 2026-10-11 17:36
|
||||
|
||||
- **HUD revamp** — removed the "Map" window (the full-screen map is directly interactive via scroll/trackpad; no floating zoom control, and the now-dead `MapApi`/`onMapReady` map plumbing is removed from `WorldMap`); the top bar is now a fixed, non-movable element pinned to top-center a few px from the top (a new `pinned` mode on `FloatingWindow` — viewport-centered, no drag, no persisted position, always above other windows); and the dossier / event-feed / action panels can each be docked into a left or right full-height pane (a new `dock`/`onDockChange` mode on `FloatingWindow`, with small left/right/float controls in each title bar, choice persisted in `localStorage` and reproduced on reload; the default "float" reproduces the current first-run layout exactly). The legend stays a small floating widget. Fixes #4, Fixes #5.
|
||||
- **`FloatingWindow`** — new optional props: `pinned` (top-center fixed bar), `dock` (`'left'`/`'right'` full-height columns) and `onDockChange` (renders the dock controls and reports the mode), plus `dataTestId` (a `data-testid` on the window root for e2e). `anchor` is now optional (defaults to `top-center`).
|
||||
- **New `data-testid`s** — `top-bar` (pinned header), `panel-dossier` / `panel-feed` / `panel-action` (per-window roots), and `dock-left` / `dock-right` / `dock-float` (per-window dock controls).
|
||||
- **New localStorage key** — `history.floatwin.v1.dock` (per-id dock mode); mirrors the existing `history.floatwin.v1.collapsed`.
|
||||
- **E2E** — new "HUD revamp" suite: asserts the Map window and its zoom buttons are gone, the top bar is pinned and horizontally centered a few px from the top (and doesn't move under a drag attempt), the dossier docks to the left edge (full-height) and the feed to the right edge, and a dock choice survives a page reload — all deterministic under `LLM_STUB=1` (11 tests, up from 9).
|
||||
|
||||
## v0.4.0 — 2026-10-11 16:02
|
||||
|
||||
- **Fix: queued items disappearing from the pending list (#10).** Root cause: SSE `state` events carried no freshness information, and the client's `onState`/`onTurn` handlers applied any incoming snapshot unconditionally. A stale snapshot — e.g. the SSE on-connect resync taken just before an add was saved, or an EventSource auto-reconnect — could arrive *after* the add's REST response and clobber it, so "add one: nothing happens, add another: both appear". Fix: every mutation now bumps a monotonic `GameState.version` (persisted in SQLite, started at 1 on create, bumped by `queueRoundItem`, the new `removeRoundItem`, and `applyTurnResult`; legacy games without the field are treated as version 0), and the client routes *every* state ingestion (SSE events, add/remove/jump responses, game loads) through a single "never go backwards" rule: a different game id is always adopted, the same game only when the incoming version is not older than the current one (compared inside the functional `setGame` updater, so it is stale-closure-proof).
|
||||
|
||||
@@ -419,3 +419,101 @@ test.describe('History — URL deep-linking (#/game/<id>)', () => {
|
||||
await waitHash(page, '#/');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* HUD revamp (issues #4 and #5). The floating "Map" zoom window is gone — the
|
||||
* full-bleed map is driven directly by wheel/trackpad + drag. The top bar is a
|
||||
* *pinned* element: fixed, non-movable, centered at the top a few px down. The
|
||||
* dossier / feed / action panels can each be docked into a full-height left or
|
||||
* right pane (persisted in `localStorage`); the legend stays a small floating
|
||||
* widget.
|
||||
*/
|
||||
test.describe('History — HUD revamp (pinned top bar, dockable panels, no Map window)', () => {
|
||||
test('Map window is gone; top bar is pinned top-center; panels dock left/right', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90000);
|
||||
await createUkGame(page);
|
||||
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
|
||||
const vw = page.viewportSize()!.width;
|
||||
const vh = page.viewportSize()!.height;
|
||||
|
||||
// --- #4: the "Map" zoom window is gone --------------------------------
|
||||
// No zoom window element, and no zoom-in/zoom-out buttons anywhere on the
|
||||
// game screen.
|
||||
await expect(page.locator('[data-float-window="zoom"]')).toHaveCount(0);
|
||||
await expect(page.getByTestId('zoom-in')).toHaveCount(0);
|
||||
await expect(page.getByTestId('zoom-out')).toHaveCount(0);
|
||||
|
||||
// --- #5a: the top bar is pinned, centered at the top ------------------
|
||||
const topBar = page.getByTestId('top-bar');
|
||||
await expect(topBar).toBeVisible();
|
||||
const barBox = (await topBar.boundingBox())!;
|
||||
// Horizontally centered: x ≈ (viewportWidth − width) / 2 …
|
||||
expect(Math.abs(barBox.x - (vw - barBox.width) / 2)).toBeLessThanOrEqual(3);
|
||||
// …and a few px down from the top edge (y ≈ 8).
|
||||
expect(barBox.y).toBeGreaterThanOrEqual(6);
|
||||
expect(barBox.y).toBeLessThanOrEqual(10);
|
||||
|
||||
// A pointer-drag attempted on the pinned bar must not move it (it has no
|
||||
// drag handlers; the map behind it never receives a mousedown).
|
||||
await page.mouse.move(barBox.x + barBox.width / 2, barBox.y + barBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(barBox.x + barBox.width / 2 + 60, barBox.y + barBox.height / 2 + 40, {
|
||||
steps: 6,
|
||||
});
|
||||
await page.mouse.up();
|
||||
const barBoxAfter = (await topBar.boundingBox())!;
|
||||
expect(Math.abs(barBoxAfter.x - barBox.x)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(barBoxAfter.y - barBox.y)).toBeLessThanOrEqual(1);
|
||||
|
||||
// --- #5b: the dossier docks into a full-height left pane --------------
|
||||
const dossier = page.getByTestId('panel-dossier');
|
||||
await dossier.getByTestId('dock-left').click();
|
||||
const dBox = (await dossier.boundingBox())!;
|
||||
// Pinned to the left edge, filling the viewport height.
|
||||
expect(dBox.x).toBeLessThanOrEqual(2);
|
||||
expect(dBox.y).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(dBox.height - vh)).toBeLessThanOrEqual(3);
|
||||
expect(Math.abs(dBox.width - 300)).toBeLessThanOrEqual(3);
|
||||
|
||||
// --- #5b: the event feed docks into a full-height right pane ----------
|
||||
const feed = page.getByTestId('panel-feed');
|
||||
await feed.getByTestId('dock-right').click();
|
||||
const fBox = (await feed.boundingBox())!;
|
||||
// Pinned to the right edge: its right side sits on the viewport edge.
|
||||
expect(Math.abs(fBox.x + fBox.width - vw)).toBeLessThanOrEqual(3);
|
||||
expect(Math.abs(fBox.height - vh)).toBeLessThanOrEqual(3);
|
||||
|
||||
// The "Your move" deck is still a floating panel (we never docked it).
|
||||
await expect(page.getByTestId('panel-action')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a dock choice survives a page reload (persisted in localStorage)', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90000);
|
||||
await createUkGame(page);
|
||||
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||
const vh = page.viewportSize()!.height;
|
||||
|
||||
// Dock the dossier into the left pane.
|
||||
const dossier = page.getByTestId('panel-dossier');
|
||||
await dossier.getByTestId('dock-left').click();
|
||||
const before = (await dossier.boundingBox())!;
|
||||
expect(before.x).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(before.height - vh)).toBeLessThanOrEqual(3);
|
||||
|
||||
// Reload: the persisted dock choice must restore the docked layout, and the
|
||||
// deep link restores the game.
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1', { timeout: 20000 });
|
||||
const after = (await page.getByTestId('panel-dossier').boundingBox())!;
|
||||
expect(after.x).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(after.height - vh)).toBeLessThanOrEqual(3);
|
||||
expect(Math.abs(after.width - 300)).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "history",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||
|
||||
+81
-57
@@ -7,8 +7,6 @@ import {
|
||||
ListTree,
|
||||
Loader2,
|
||||
Map as MapIcon,
|
||||
Minus,
|
||||
Plus,
|
||||
Radar,
|
||||
} from 'lucide-react';
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
@@ -18,7 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
|
||||
import { cn } from './lib/utils';
|
||||
import PresetPicker from './components/PresetPicker';
|
||||
import NationSelect from './components/NationSelect';
|
||||
import WorldMap, { type MapApi } from './components/WorldMap';
|
||||
import WorldMap from './components/WorldMap';
|
||||
import FloatingWindow from './components/FloatingWindow';
|
||||
import MapLegend from './components/MapLegend';
|
||||
import NationPanel from './components/NationPanel';
|
||||
@@ -46,13 +44,20 @@ function toastError(e: unknown): void {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Floating-window collapse persistence (positions live in FloatingWindow itself).
|
||||
// Floating-window collapse + dock persistence.
|
||||
// (Free-floating *positions* live in FloatingWindow itself, per id.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FLOAT_COLLAPSED_KEY = 'history.floatwin.v1.collapsed';
|
||||
const FLOAT_DOCK_KEY = 'history.floatwin.v1.dock';
|
||||
|
||||
/** Every collapsible HUD panel (the slim header is never collapsed). */
|
||||
const PANEL_WINDOW_IDS = ['dossier', 'feed', 'action', 'legend', 'zoom'] as const;
|
||||
/**
|
||||
* Every collapsible HUD panel (the slim, pinned header is never collapsed).
|
||||
* The three substantive panels (dossier / feed / action) are also dockable;
|
||||
* the legend stays a small floating widget, and there is no longer a "Map"
|
||||
* window.
|
||||
*/
|
||||
const PANEL_WINDOW_IDS = ['dossier', 'feed', 'action', 'legend'] as const;
|
||||
|
||||
function loadCollapsedMap(): Record<string, boolean> {
|
||||
try {
|
||||
@@ -78,6 +83,33 @@ function persistCollapsedMap(m: Record<string, boolean>): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** A dock mode: a docked edge, or 'float' (free-floating, the default). */
|
||||
type DockMode = 'left' | 'right' | 'float';
|
||||
|
||||
function loadDockMap(): Record<string, DockMode> {
|
||||
try {
|
||||
const raw = localStorage.getItem(FLOAT_DOCK_KEY);
|
||||
if (raw == null) return {};
|
||||
const v: unknown = JSON.parse(raw);
|
||||
if (!v || typeof v !== 'object' || Array.isArray(v)) return {};
|
||||
const out: Record<string, DockMode> = {};
|
||||
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
||||
if (val === 'left' || val === 'right' || val === 'float') out[k] = val;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function persistDockMap(m: Record<string, DockMode>): void {
|
||||
try {
|
||||
localStorage.setItem(FLOAT_DOCK_KEY, JSON.stringify(m));
|
||||
} catch {
|
||||
/* storage unavailable — dock choices just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active-game persistence. The id of the game the user is (or was last) playing
|
||||
// is remembered in localStorage, so a page refresh drops them straight back
|
||||
@@ -485,14 +517,17 @@ interface GameScreenProps {
|
||||
|
||||
/**
|
||||
* The main play view — a full-bleed world map with a floating HUD. The MapLibre
|
||||
* map is a fixed, full-viewport background (z-0); over it float draggable,
|
||||
* collapsible windows: a slim header bar (scenario, turn + date, player nation,
|
||||
* live status, "Focus map" collapse-all) at the top, the nation dossier
|
||||
* (top-left), the event feed (right), the "Your move" command deck
|
||||
* (bottom-center), the nation-color legend (bottom-left), and a tiny map zoom
|
||||
* control (bottom-right). Each window remembers its dragged position, and
|
||||
* clicking one raises it above its siblings. Pan/zoom/click on the map stays
|
||||
* fully available in the gaps between windows.
|
||||
* map is a fixed, full-viewport background (z-0) that the user drives directly
|
||||
* (wheel/trackpad zoom + drag pan — there is no floating zoom control). Over it:
|
||||
* a slim **pinned** header bar (fixed, non-movable, centered at the top a few
|
||||
* px down; scenario, turn + date, player nation, live status, "Focus map"
|
||||
* collapse-all); the nation dossier, the event feed, and the "Your move"
|
||||
* command deck — each free-floating by default (at the same first-run positions
|
||||
* as before) but **dockable** into a full-height left or right pane, the choice
|
||||
* being remembered; and the nation-color legend, which stays a small floating
|
||||
* widget (not dockable). Each floating window remembers its dragged position,
|
||||
* and clicking one raises it above its siblings. Pan/zoom/click on the map
|
||||
* stays fully available in the gaps between windows.
|
||||
*/
|
||||
function GameScreen({
|
||||
game,
|
||||
@@ -534,11 +569,23 @@ function GameScreen({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 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;
|
||||
// Dock state for the three dockable panels (dossier / feed / action),
|
||||
// persisted per id (survives reloads). The default is 'float' — the current
|
||||
// first-run positions — so nothing visually changes until the user docks
|
||||
// something. The legend is not dockable; the header is pinned.
|
||||
const [dock, setDock] = useState<Record<string, DockMode>>(loadDockMap);
|
||||
const setDockFor = useCallback((id: string, mode: DockMode) => {
|
||||
setDock((prev) => {
|
||||
const next = { ...prev, [id]: mode };
|
||||
persistDockMap(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
// The `dock` prop expects a concrete edge, or `undefined` to free-float.
|
||||
const dockOf = (id: string) => {
|
||||
const m = dock[id];
|
||||
return m === 'left' || m === 'right' ? m : undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
@@ -549,13 +596,14 @@ function GameScreen({
|
||||
nations={game.nations}
|
||||
presetId={game.presetId}
|
||||
onFocusNation={setViewingId}
|
||||
onMapReady={handleMapReady}
|
||||
/>
|
||||
|
||||
{/* Slim floating header: brand, turn + date, player nation, live status, Focus map. */}
|
||||
{/* Pinned top bar: brand, turn + date, player nation, live status, Focus map.
|
||||
Fixed and non-movable, centered at the top a few px from the top edge. */}
|
||||
<FloatingWindow
|
||||
id="header"
|
||||
anchor="top-center"
|
||||
dataTestId="top-bar"
|
||||
pinned
|
||||
collapsible={false}
|
||||
title={
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
@@ -628,13 +676,16 @@ function GameScreen({
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Nation dossier, top-left. */}
|
||||
{/* Nation dossier, top-left by default; dockable into a left/right pane. */}
|
||||
<FloatingWindow
|
||||
id="dossier"
|
||||
dataTestId="panel-dossier"
|
||||
anchor="top-left"
|
||||
width={300}
|
||||
title="Dossier"
|
||||
titleIcon={<BookOpen className="size-4" />}
|
||||
dock={dockOf('dossier')}
|
||||
onDockChange={(m) => setDockFor('dossier', m)}
|
||||
collapsed={!!collapsed.dossier}
|
||||
onCollapsedChange={(c) => setCollapsedFor('dossier', c)}
|
||||
bodyClassName="max-h-[56vh] overflow-y-auto"
|
||||
@@ -659,9 +710,10 @@ function GameScreen({
|
||||
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} inWindow />
|
||||
</FloatingWindow>
|
||||
|
||||
{/* Event feed, right side. */}
|
||||
{/* Event feed, right side by default; dockable into a left/right pane. */}
|
||||
<FloatingWindow
|
||||
id="feed"
|
||||
dataTestId="panel-feed"
|
||||
anchor="right"
|
||||
width={330}
|
||||
title="Event feed"
|
||||
@@ -671,6 +723,8 @@ function GameScreen({
|
||||
{game.events.length}
|
||||
</Badge>
|
||||
}
|
||||
dock={dockOf('feed')}
|
||||
onDockChange={(m) => setDockFor('feed', m)}
|
||||
collapsed={!!collapsed.feed}
|
||||
onCollapsedChange={(c) => setCollapsedFor('feed', c)}
|
||||
bodyClassName="h-[52vh]"
|
||||
@@ -678,9 +732,10 @@ function GameScreen({
|
||||
<EventFeed events={game.events} nations={game.nations} inWindow />
|
||||
</FloatingWindow>
|
||||
|
||||
{/* Command deck ("Your move"), bottom-center — queue items, then commit. */}
|
||||
{/* Command deck ("Your move"), bottom-center by default; dockable. */}
|
||||
<FloatingWindow
|
||||
id="action"
|
||||
dataTestId="panel-action"
|
||||
anchor="bottom-center"
|
||||
width={370}
|
||||
title="Your move"
|
||||
@@ -692,6 +747,8 @@ function GameScreen({
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
dock={dockOf('action')}
|
||||
onDockChange={(m) => setDockFor('action', m)}
|
||||
collapsed={!!collapsed.action}
|
||||
onCollapsedChange={(c) => setCollapsedFor('action', c)}
|
||||
bodyClassName="max-h-[72vh] overflow-y-auto"
|
||||
@@ -721,39 +778,6 @@ function GameScreen({
|
||||
>
|
||||
<MapLegend regions={regions} />
|
||||
</FloatingWindow>
|
||||
|
||||
{/* Tiny floating zoom control, bottom-right (clear of the attribution). */}
|
||||
<FloatingWindow
|
||||
id="zoom"
|
||||
anchor="bottom-right"
|
||||
width={88}
|
||||
title="Map"
|
||||
titleIcon={<MapIcon className="size-4" />}
|
||||
offset={{ y: -28 }}
|
||||
collapsed={!!collapsed.zoom}
|
||||
onCollapsedChange={(c) => setCollapsedFor('zoom', c)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1.5 p-2.5">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => mapApiRef.current?.zoomIn()}
|
||||
aria-label="Zoom in"
|
||||
data-testid="zoom-in"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => mapApiRef.current?.zoomOut()}
|
||||
aria-label="Zoom out"
|
||||
data-testid="zoom-out"
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { Minus, Plus } from 'lucide-react';
|
||||
import { AlignLeft, AlignRight, Maximize2, 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). */
|
||||
@@ -19,6 +19,11 @@ export type FloatAnchor =
|
||||
| 'bottom-center'
|
||||
| 'bottom-right';
|
||||
|
||||
/** A docked column edge: pinned to a screen edge and filling the viewport height. */
|
||||
export type FloatDock = 'left' | 'right';
|
||||
/** A dock mode: a docked edge, or `'float'` (free-floating, the default). */
|
||||
export type FloatDockMode = FloatDock | 'float';
|
||||
|
||||
const STORAGE_PREFIX = 'history.floatwin.v1.';
|
||||
|
||||
// Module-level z-index bookkeeping so sibling windows stack in click order:
|
||||
@@ -26,6 +31,9 @@ const STORAGE_PREFIX = 'history.floatwin.v1.';
|
||||
// raised, the shared counter advances, so the newest on top always wins.
|
||||
const Z_BASE = 10;
|
||||
const Z_STEP = 10;
|
||||
// A pinned window always floats above every docked/floating window, no matter
|
||||
// how many times those are raised.
|
||||
const Z_PINNED = 100000;
|
||||
let zSeq = 0;
|
||||
let zTopId: string | null = null;
|
||||
/** Returns the new z-index to use, or -1 if this window is already front-most. */
|
||||
@@ -118,8 +126,31 @@ function clampPos(x: number, y: number, w: number, h: number): Pos {
|
||||
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;
|
||||
/** Optional `data-testid` on the window root, for e2e targeting. */
|
||||
dataTestId?: string;
|
||||
/**
|
||||
* First-run placement for a free-floating window; the last dragged position
|
||||
* (persisted) takes over. Ignored when `pinned` or `dock` is set.
|
||||
*/
|
||||
anchor?: FloatAnchor;
|
||||
/**
|
||||
* Pin the window to the top-center of the viewport: a fixed, non-movable bar
|
||||
* a few px from the top that re-centers automatically on resize, always
|
||||
* floats above docked/floating panels, and never reads/writes a persisted
|
||||
* position. Used for the slim header.
|
||||
*/
|
||||
pinned?: boolean;
|
||||
/**
|
||||
* Dock the window into a full-height left or right column: non-movable,
|
||||
* body scrolls, never reads/writes the persisted free-floating position.
|
||||
* Absent (`undefined`) = free-floating as before.
|
||||
*/
|
||||
dock?: FloatDock;
|
||||
/**
|
||||
* When provided, render dock controls (left / right / float) in the title bar
|
||||
* and report the chosen mode back to the parent, which persists it.
|
||||
*/
|
||||
onDockChange?: (mode: FloatDockMode) => void;
|
||||
/** 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). */
|
||||
@@ -128,7 +159,7 @@ export interface FloatingWindowProps {
|
||||
title?: ReactNode;
|
||||
/** Optional icon before the title. */
|
||||
titleIcon?: ReactNode;
|
||||
/** Right-side title-bar content, rendered before the collapse button. */
|
||||
/** Right-side title-bar content, rendered before the dock/collapse controls. */
|
||||
titleExtra?: ReactNode;
|
||||
/** Render the collapse toggle (default true). */
|
||||
collapsible?: boolean;
|
||||
@@ -143,16 +174,27 @@ export interface FloatingWindowProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A window that floats over the full-screen map. Three mutually-exclusive
|
||||
* modes, set via props (absent = free-floating):
|
||||
* • **free-floating** (default) — draggable by the title bar (pointer
|
||||
* capture), clamped on-screen, position persisted in localStorage, raised
|
||||
* above siblings on click, collapsible.
|
||||
* • **pinned** — a fixed, non-movable bar centered at the top of the
|
||||
* viewport a few px down; re-centers on resize; always above other
|
||||
* windows; no position persisted.
|
||||
* • **docked** (`left`/`right`) — a non-movable, full-height column pinned
|
||||
* to a screen edge whose body scrolls; the persisted free-floating
|
||||
* position is neither read nor written while docked.
|
||||
* When `onDockChange` is provided, small dock/left/right/float controls render
|
||||
* in the title bar and report the chosen mode back to the parent.
|
||||
*/
|
||||
export default function FloatingWindow({
|
||||
id,
|
||||
anchor,
|
||||
dataTestId,
|
||||
anchor = 'top-center',
|
||||
pinned = false,
|
||||
dock,
|
||||
onDockChange,
|
||||
width,
|
||||
offset,
|
||||
title,
|
||||
@@ -165,6 +207,12 @@ export default function FloatingWindow({
|
||||
bodyClassName,
|
||||
children,
|
||||
}: FloatingWindowProps) {
|
||||
const isPinned = pinned;
|
||||
const isDocked = dock === 'left' || dock === 'right';
|
||||
// Free-floating is the only mode that drags, persists a position, and needs
|
||||
// viewport measurement/clamping. Pinned and docked are CSS-positioned.
|
||||
const isFloating = !isPinned && !isDocked;
|
||||
|
||||
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);
|
||||
@@ -177,9 +225,11 @@ export default function FloatingWindow({
|
||||
setPosState(p);
|
||||
};
|
||||
|
||||
// First paint: measure, then place (restored position or anchor) and clamp.
|
||||
// Runs before paint (useLayoutEffect), so there is no visible flash.
|
||||
// First paint (free-floating only): measure, then place (restored position
|
||||
// or anchor) and clamp. Runs before paint (useLayoutEffect), so no flash.
|
||||
// Re-runs when the mode changes so a window returning from docked re-places.
|
||||
useLayoutEffect(() => {
|
||||
if (!isFloating) return;
|
||||
if (posRef.current) return;
|
||||
const el = rootRef.current;
|
||||
if (!el) return;
|
||||
@@ -195,11 +245,12 @@ export default function FloatingWindow({
|
||||
);
|
||||
posRef.current = p;
|
||||
setPosState(p);
|
||||
}, [id, anchor]);
|
||||
}, [id, anchor, offset, isFloating]);
|
||||
|
||||
// Re-measure + re-clamp whenever the body toggles hidden (expand can push a
|
||||
// bottom-anchored window off the bottom edge).
|
||||
// bottom-anchored window off the bottom edge). Free-floating only.
|
||||
useLayoutEffect(() => {
|
||||
if (!isFloating) return;
|
||||
const el = rootRef.current;
|
||||
const p = posRef.current;
|
||||
if (!el || !p) return;
|
||||
@@ -208,10 +259,11 @@ export default function FloatingWindow({
|
||||
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]);
|
||||
}, [collapsed, isFloating]);
|
||||
|
||||
// Keep the window on-screen when the viewport shrinks.
|
||||
// Keep a free-floating window on-screen when the viewport shrinks.
|
||||
useEffect(() => {
|
||||
if (!isFloating) return;
|
||||
const onResize = () => {
|
||||
const p = posRef.current;
|
||||
const s = sizeRef.current;
|
||||
@@ -221,7 +273,7 @@ export default function FloatingWindow({
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
}, [isFloating]);
|
||||
|
||||
const onHandleDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
@@ -258,18 +310,44 @@ export default function FloatingWindow({
|
||||
if (p) savePos(id, p.x, p.y);
|
||||
};
|
||||
|
||||
const style: CSSProperties = {
|
||||
// Root placement: pinned → centered top bar; docked → full-height edge
|
||||
// column; free-floating → last/initial pixel position (hidden until measured).
|
||||
const rootStyle: CSSProperties = isPinned
|
||||
? { zIndex: Z_PINNED, top: 8, left: '50%', transform: 'translateX(-50%)' }
|
||||
: isDocked
|
||||
? {
|
||||
zIndex: z,
|
||||
...(pos ? { left: pos.x, top: pos.y } : {}),
|
||||
};
|
||||
top: 0,
|
||||
height: '100vh',
|
||||
...(dock === 'left' ? { left: 0 } : { right: 0 }),
|
||||
}
|
||||
: { zIndex: z, ...(pos ? { left: pos.x, top: pos.y } : {}) };
|
||||
|
||||
// The title bar is the drag handle only in free-floating mode.
|
||||
const titleBarHandlers = isFloating
|
||||
? {
|
||||
onPointerDown: onHandleDown,
|
||||
onPointerMove: onHandleMove,
|
||||
onPointerUp: endDrag,
|
||||
onPointerCancel: endDrag,
|
||||
}
|
||||
: {};
|
||||
|
||||
// Docked: the body fills the remaining column height and scrolls, overriding
|
||||
// any max-height the caller set for the floating layout.
|
||||
const bodyClass = isDocked
|
||||
? cn('min-h-0 flex-1 overflow-y-auto', collapsed && 'hidden')
|
||||
: cn('min-h-0', collapsed && 'hidden', bodyClassName);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-float-window={id}
|
||||
style={style}
|
||||
className={cn('fixed', !pos && 'pointer-events-none opacity-0')}
|
||||
data-testid={dataTestId}
|
||||
style={rootStyle}
|
||||
className={cn('fixed', isFloating && !pos && 'pointer-events-none opacity-0')}
|
||||
onPointerDown={() => {
|
||||
if (isPinned) return;
|
||||
const nz = nextZ(id);
|
||||
if (nz > 0) setZ(nz);
|
||||
}}
|
||||
@@ -277,21 +355,24 @@ export default function FloatingWindow({
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-xl border border-border/70 bg-slate-900/80 shadow-2xl shadow-black/50 backdrop-blur-md',
|
||||
isDocked && 'flex h-full flex-col',
|
||||
className,
|
||||
)}
|
||||
style={
|
||||
width != null
|
||||
isDocked
|
||||
? { width: width ?? 'fit-content', maxWidth: '96vw' }
|
||||
: 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}
|
||||
className={cn(
|
||||
'flex shrink-0 touch-none select-none items-center gap-2 border-b border-border/60 bg-white/[0.03] px-2.5 py-1.5',
|
||||
isFloating && 'cursor-grab active:cursor-grabbing',
|
||||
)}
|
||||
{...titleBarHandlers}
|
||||
>
|
||||
{titleIcon && (
|
||||
<span className="shrink-0 text-muted-foreground" aria-hidden>
|
||||
@@ -302,6 +383,49 @@ export default function FloatingWindow({
|
||||
{title}
|
||||
</span>
|
||||
{titleExtra}
|
||||
{onDockChange && (
|
||||
<span className="flex shrink-0 items-center gap-0.5" data-no-drag>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dock-left"
|
||||
aria-label="Dock to the left edge"
|
||||
title="Dock to the left edge"
|
||||
className={cn(
|
||||
'rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground',
|
||||
dock === 'left' && 'bg-white/10 text-foreground',
|
||||
)}
|
||||
onClick={() => onDockChange('left')}
|
||||
>
|
||||
<AlignLeft className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dock-right"
|
||||
aria-label="Dock to the right edge"
|
||||
title="Dock to the right edge"
|
||||
className={cn(
|
||||
'rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground',
|
||||
dock === 'right' && 'bg-white/10 text-foreground',
|
||||
)}
|
||||
onClick={() => onDockChange('right')}
|
||||
>
|
||||
<AlignRight className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dock-float"
|
||||
aria-label="Float the window"
|
||||
title="Float (undock)"
|
||||
className={cn(
|
||||
'rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground',
|
||||
!isDocked && 'bg-white/10 text-foreground',
|
||||
)}
|
||||
onClick={() => onDockChange('float')}
|
||||
>
|
||||
<Maximize2 className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{collapsibleProp && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -315,9 +439,7 @@ export default function FloatingWindow({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children != null && (
|
||||
<div className={cn('min-h-0', collapsed && 'hidden', bodyClassName)}>{children}</div>
|
||||
)}
|
||||
{children != null && <div className={bodyClass}>{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -170,13 +170,6 @@ function clickHtml(countryName: string, region?: RegionOwner, nation?: Nation):
|
||||
</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 {
|
||||
regions: RegionMap;
|
||||
playerNationId: string;
|
||||
@@ -187,11 +180,6 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,8 +189,8 @@ interface Props {
|
||||
* stable fills (the choropleth is the star), thin 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).
|
||||
* per-scenario initial camera. Zoom and pan are driven directly by the user
|
||||
* (MapLibre wheel/trackpad + drag) — no floating control and no external API.
|
||||
*/
|
||||
export default function WorldMap({
|
||||
regions,
|
||||
@@ -211,7 +199,6 @@ export default function WorldMap({
|
||||
presetId,
|
||||
className,
|
||||
onFocusNation,
|
||||
onMapReady,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
@@ -222,13 +209,11 @@ export default function WorldMap({
|
||||
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).
|
||||
@@ -257,17 +242,6 @@ export default function WorldMap({
|
||||
// Purely for testability; has no effect on rendering or behavior.
|
||||
(containerRef.current as unknown as { __map?: maplibregl.Map }).__map = 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;
|
||||
@@ -482,7 +456,6 @@ export default function WorldMap({
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(flashRaf);
|
||||
flashApiRef.current = null;
|
||||
onMapReadyRef.current?.(null);
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
if (containerRef.current) {
|
||||
|
||||
Reference in New Issue
Block a user