From ef1d282770c49b6107f735e0a58a431dfc17f279 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 29 Sep 2026 08:00:44 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20world=20map=20=E2=80=94=20jump=20by?= =?UTF-8?q?=20clicking=20chart=20fields=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the bottom JUMP ROUTES button list; systems adjacent to the current one become clickable galactic-chart nodes (same POST /api/jump flow, same state refreshes). Current + unreachable systems are dimmed and not clickable. NodeGraph gains optional onNodeClick + per-node disabled (backward-compatible); two smoke tests updated to click chart fields. --- components/ui/node-graph.tsx | 13 +++++++- components/world-map.tsx | 65 +++++++++++------------------------- e2e/smoke.spec.ts | 16 ++++++--- 3 files changed, 43 insertions(+), 51 deletions(-) diff --git a/components/ui/node-graph.tsx b/components/ui/node-graph.tsx index 9c64ba6..8cee4b7 100644 --- a/components/ui/node-graph.tsx +++ b/components/ui/node-graph.tsx @@ -13,6 +13,7 @@ export interface NodeGraphNode { y: number status?: NodeStatus sublabel?: string + disabled?: boolean // not clickable even when onNodeClick is set (e.g. the current system) } export interface NodeGraphEdge { @@ -29,6 +30,7 @@ export interface NodeGraphProps extends React.HTMLAttributes { title?: string height?: number directed?: boolean + onNodeClick?: (id: string) => void // makes non-disabled nodes clickable buttons } const VARIANT_COLORS: Record = { @@ -59,6 +61,7 @@ const NodeGraph = React.forwardRef( title, height = 320, directed = false, + onNodeClick, style, ...props }, @@ -219,13 +222,21 @@ const NodeGraph = React.forwardRef( const cy = nodePy(node) const isHovered = hovered === node.id const statusColor = node.status ? STATUS_COLOR[node.status] : color + const clickable = !!onNodeClick && !node.disabled return ( setHovered(node.id)} onMouseLeave={() => setHovered(null)} - style={{ cursor: 'default' }} + onClick={clickable ? () => onNodeClick(node.id) : undefined} + onKeyDown={clickable + ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onNodeClick(node.id) } } + : undefined} + role={clickable ? 'button' : undefined} + tabIndex={clickable ? 0 : undefined} + aria-label={clickable ? node.label.toUpperCase() : undefined} + style={{ cursor: clickable ? 'pointer' : 'default' }} > {/* Background */} s.current)! - const reachable = world.edges - .filter(([a, b]) => a === current.id || b === current.id) - .map(([a, b]) => (a === current.id ? b : a)) - .map((id) => world.systems.find((s) => s.id === id)!) + // Systems one jump away — the only clickable chart fields (the same set as the old JUMP ROUTES list). + const reachableIds = new Set( + world.edges.filter(([a, b]) => a === current.id || b === current.id).map(([a, b]) => (a === current.id ? b : a)) + ) return (
({ - id: String(s.id), - label: s.name, - x: s.x, - y: s.y, - status: s.current ? 'ACTIVE' : s.discovered ? 'NEUTRAL' : 'OFFLINE', - sublabel: s.current ? 'CURRENT' : s.discovered ? 'DISCOVERED' : 'UNKNOWN', - }))} + nodes={world.systems.map((s) => { + const jumpable = !s.current && reachableIds.has(s.id) + return { + id: String(s.id), + label: s.name, + x: s.x, + y: s.y, + status: s.current ? 'ACTIVE' : jumpable ? 'NEUTRAL' : 'OFFLINE', + sublabel: s.current ? 'CURRENT' : jumpable ? (s.discovered ? 'DISCOVERED // JUMP' : 'UNKNOWN // JUMP') : s.discovered ? 'DISCOVERED' : 'UNKNOWN', + disabled: !jumpable || jumpingId !== null, // dimmed + not clickable; all locked while a jump is in flight + } + })} edges={world.edges.map(([a, b]) => ({ from: String(a), to: String(b) }))} variant="ACTIVE" title={`GALACTIC CHART // JUMP ROUTES — TURN ${world.turn}`} height={height} style={{ width: '100%' }} + onNodeClick={(id) => void jump(Number(id))} /> -
-
- JUMP ROUTES // FROM {current.name.toUpperCase()} -
-
- {reachable.map((s) => ( - - ))} -
-
- {error && (
diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index fc7d4e0..b7ceb22 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -71,12 +71,17 @@ test('tabs switch panels client-side (event log terminal)', async ({ page }) => }); test('world map shows the generated starting system and its neighbours', async ({ page }) => { + const world = (await (await page.request.get('/api/world')).json()) as WorldState; + await page.goto('/'); - // Live chart title carries the turn counter; a fresh save sits at the start with six jump routes. + // Live chart title carries the turn counter. await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible(); - const jumpButtons = page.getByRole('button', { name: /\/\/ JUMP$/ }); - await expect(jumpButtons.first()).toBeVisible(); - await expect(jumpButtons).toHaveCount(6); + // The current system is marked, and every adjacent system is a clickable chart field (role=button). + const current = world.systems.find((s) => s.current)!; + await expect(page.getByText('CURRENT', { exact: true })).toBeVisible(); + const panel = page.getByRole('tabpanel'); + const adjacent = world.edges.filter(([a, b]) => a === current.id || b === current.id); // six on a fresh save + await expect(panel.getByRole('button')).toHaveCount(adjacent.length); }); test('jumping to an adjacent system updates the world state and turn counter', async ({ page }) => { @@ -87,7 +92,8 @@ test('jumping to an adjacent system updates the world state and turn counter', a const targetName = before.systems.find((s) => s.id === targetId)!.name; await page.goto('/'); - await page.getByRole('button', { name: `${targetName.toUpperCase()} // JUMP` }).click(); + // Click the target's chart field — clickable nodes are role=button with their name as accessible label. + await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click(); const after = (await (await page.request.get('/api/world')).json()) as WorldState; expect(after.turn).toBe(before.turn + 1);