feat(ui): world map — jump by clicking chart fields (#19)
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.
This commit is contained in:
@@ -13,6 +13,7 @@ export interface NodeGraphNode {
|
|||||||
y: number
|
y: number
|
||||||
status?: NodeStatus
|
status?: NodeStatus
|
||||||
sublabel?: string
|
sublabel?: string
|
||||||
|
disabled?: boolean // not clickable even when onNodeClick is set (e.g. the current system)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeGraphEdge {
|
export interface NodeGraphEdge {
|
||||||
@@ -29,6 +30,7 @@ export interface NodeGraphProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
title?: string
|
title?: string
|
||||||
height?: number
|
height?: number
|
||||||
directed?: boolean
|
directed?: boolean
|
||||||
|
onNodeClick?: (id: string) => void // makes non-disabled nodes clickable buttons
|
||||||
}
|
}
|
||||||
|
|
||||||
const VARIANT_COLORS: Record<NodeGraphVariant, string> = {
|
const VARIANT_COLORS: Record<NodeGraphVariant, string> = {
|
||||||
@@ -59,6 +61,7 @@ const NodeGraph = React.forwardRef<HTMLDivElement, NodeGraphProps>(
|
|||||||
title,
|
title,
|
||||||
height = 320,
|
height = 320,
|
||||||
directed = false,
|
directed = false,
|
||||||
|
onNodeClick,
|
||||||
style,
|
style,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
@@ -219,13 +222,21 @@ const NodeGraph = React.forwardRef<HTMLDivElement, NodeGraphProps>(
|
|||||||
const cy = nodePy(node)
|
const cy = nodePy(node)
|
||||||
const isHovered = hovered === node.id
|
const isHovered = hovered === node.id
|
||||||
const statusColor = node.status ? STATUS_COLOR[node.status] : color
|
const statusColor = node.status ? STATUS_COLOR[node.status] : color
|
||||||
|
const clickable = !!onNodeClick && !node.disabled
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
key={node.id}
|
key={node.id}
|
||||||
onMouseEnter={() => setHovered(node.id)}
|
onMouseEnter={() => setHovered(node.id)}
|
||||||
onMouseLeave={() => setHovered(null)}
|
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 */}
|
{/* Background */}
|
||||||
<rect
|
<rect
|
||||||
|
|||||||
+20
-45
@@ -1,13 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { NodeGraph } from '@/components/ui/node-graph'
|
import { NodeGraph } from '@/components/ui/node-graph'
|
||||||
import type { WorldState } from '@/lib/game/save'
|
import type { WorldState } from '@/lib/game/save'
|
||||||
|
|
||||||
// WORLD MAP tab — the first live game panel: renders real world state from /api/world and
|
// WORLD MAP tab — renders real world state from /api/world. Clicking a system adjacent to the
|
||||||
// offers a jump button per system adjacent to the current one (NodeGraph nodes are not
|
// current one on the galactic chart jumps there (same POST /api/jump flow as before); the
|
||||||
// clickable upstream, so the affordance lives in this list). Turn-based: fetch on mount,
|
// current and unreachable systems are dimmed and not clickable. Turn-based: fetch on mount,
|
||||||
// refetch via the jump response — no polling.
|
// refetch via the jump response — no polling.
|
||||||
|
|
||||||
interface WorldMapPanelProps {
|
interface WorldMapPanelProps {
|
||||||
@@ -74,58 +73,34 @@ export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const current = world.systems.find((s) => s.current)!
|
const current = world.systems.find((s) => s.current)!
|
||||||
const reachable = world.edges
|
// Systems one jump away — the only clickable chart fields (the same set as the old JUMP ROUTES list).
|
||||||
.filter(([a, b]) => a === current.id || b === current.id)
|
const reachableIds = new Set(
|
||||||
.map(([a, b]) => (a === current.id ? b : a))
|
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)!)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
<NodeGraph
|
<NodeGraph
|
||||||
nodes={world.systems.map((s) => ({
|
nodes={world.systems.map((s) => {
|
||||||
id: String(s.id),
|
const jumpable = !s.current && reachableIds.has(s.id)
|
||||||
label: s.name,
|
return {
|
||||||
x: s.x,
|
id: String(s.id),
|
||||||
y: s.y,
|
label: s.name,
|
||||||
status: s.current ? 'ACTIVE' : s.discovered ? 'NEUTRAL' : 'OFFLINE',
|
x: s.x,
|
||||||
sublabel: s.current ? 'CURRENT' : s.discovered ? 'DISCOVERED' : 'UNKNOWN',
|
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) }))}
|
edges={world.edges.map(([a, b]) => ({ from: String(a), to: String(b) }))}
|
||||||
variant="ACTIVE"
|
variant="ACTIVE"
|
||||||
title={`GALACTIC CHART // JUMP ROUTES — TURN ${world.turn}`}
|
title={`GALACTIC CHART // JUMP ROUTES — TURN ${world.turn}`}
|
||||||
height={height}
|
height={height}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
|
onNodeClick={(id) => void jump(Number(id))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', overflow: 'hidden' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '0.4rem 0.75rem',
|
|
||||||
borderBottom: '1px solid var(--border)',
|
|
||||||
background: 'var(--surface-raised)',
|
|
||||||
fontSize: '0.6rem',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
letterSpacing: '0.12em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
JUMP ROUTES // FROM {current.name.toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', padding: '0.6rem 0.75rem' }}>
|
|
||||||
{reachable.map((s) => (
|
|
||||||
<Button
|
|
||||||
key={s.id}
|
|
||||||
variant="OUTLINE"
|
|
||||||
size="SM"
|
|
||||||
disabled={jumpingId !== null}
|
|
||||||
onClick={() => void jump(s.id)}
|
|
||||||
>
|
|
||||||
{jumpingId === s.id ? 'JUMPING…' : `${s.name.toUpperCase()} // JUMP`}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
|
||||||
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
|
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
|
||||||
|
|||||||
+11
-5
@@ -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 }) => {
|
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('/');
|
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();
|
await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible();
|
||||||
const jumpButtons = page.getByRole('button', { name: /\/\/ JUMP$/ });
|
// The current system is marked, and every adjacent system is a clickable chart field (role=button).
|
||||||
await expect(jumpButtons.first()).toBeVisible();
|
const current = world.systems.find((s) => s.current)!;
|
||||||
await expect(jumpButtons).toHaveCount(6);
|
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 }) => {
|
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;
|
const targetName = before.systems.find((s) => s.id === targetId)!.name;
|
||||||
|
|
||||||
await page.goto('/');
|
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;
|
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
expect(after.turn).toBe(before.turn + 1);
|
expect(after.turn).toBe(before.turn + 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user