feat(ui): render WORLD MAP as a force-directed d3 graph with pan + zoom (#28)
CI / build + unit + e2e (pull_request) Successful in 2m50s

This commit is contained in:
2026-09-29 11:24:02 +00:00
parent a57c590bae
commit 978a1d119d
7 changed files with 256 additions and 346 deletions
+205 -32
View File
@@ -1,12 +1,16 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { NodeGraph } from '@/components/ui/node-graph'
import { useCallback, useEffect, useRef, useState } from 'react'
import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation } from 'd3-force'
import type { SimulationNodeDatum } from 'd3-force'
import { select } from 'd3-selection'
import { zoom, zoomIdentity } from 'd3-zoom'
import type { WorldState } from '@/lib/game/save'
// WORLD MAP tab — renders real world state from /api/world. Clicking a system adjacent to the
// current one on the galactic chart jumps there (same POST /api/jump flow as before); the
// current and unreachable systems are dimmed and not clickable. Turn-based: fetch on mount,
// WORLD MAP tab — real world state from /api/world on a force-directed chart (d3-force) with
// pan + zoom (d3-zoom), per #28. Clicking a system adjacent to the current one jumps there
// (same POST /api/jump flow as before); the current and unreachable systems are dimmed and not
// clickable, and every node is locked while a jump is in flight. Turn-based: fetch on mount,
// refetch via the jump response — no polling.
interface WorldMapPanelProps {
@@ -14,10 +18,37 @@ interface WorldMapPanelProps {
onJumped?: () => void // #30 — let the parent refresh /api/system after a successful jump
}
type NodeStatus = 'current' | 'jumpable' | 'offline'
// d3-force mutates these in place; the whole scene is rebuilt from scratch on every world change.
interface ChartNode extends SimulationNodeDatum {
id: number
name: string // already uppercased — doubles as the accessible label
status: NodeStatus
sublabel: string
}
// forceLink replaces the numeric ids with node references once initialized.
interface ChartLink {
source: number | ChartNode
target: number | ChartNode
}
const NODE_W = 100
const NODE_H = 36
// Palette carried over from the old hand-rolled chart.
const STATUS_COLOR: Record<NodeStatus, string> = {
current: 'var(--color-green)',
jumpable: 'var(--text-muted)',
offline: 'var(--border)',
}
export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
const [world, setWorld] = useState<WorldState | null>(null)
const [error, setError] = useState<string | null>(null)
const [jumpingId, setJumpingId] = useState<number | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
let cancelled = false
@@ -37,7 +68,12 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
}
}, [])
// Ref guard so a double-click can't start two jumps before the re-render locks the nodes.
const jumpingRef = useRef(false)
const jump = useCallback(async (systemId: number) => {
if (jumpingRef.current) return
jumpingRef.current = true
setJumpingId(systemId)
try {
const res = await fetch('/api/jump', {
@@ -52,10 +88,156 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err))
} finally {
jumpingRef.current = false
setJumpingId(null)
}
}, [onJumped])
// Rebuild the chart on every world/lock change — the force layout re-settles from the API's
// coordinates, so nodes visibly rearrange after each jump (#28).
useEffect(() => {
const container = containerRef.current
if (!container || !world) return
const W = container.clientWidth || 800
const current = world.systems.find((s) => s.current)!
// Systems one jump away — the only clickable chart fields (the same set as before).
const reachableIds = new Set(
world.edges.filter(([a, b]) => a === current.id || b === current.id).map(([a, b]) => (a === current.id ? b : a))
)
const nodes: ChartNode[] = world.systems.map((s) => ({
id: s.id,
name: s.name.toUpperCase(),
status: s.current ? 'current' : reachableIds.has(s.id) ? 'jumpable' : 'offline',
sublabel: s.current
? 'CURRENT'
: reachableIds.has(s.id)
? (s.discovered ? 'DISCOVERED // JUMP' : 'UNKNOWN // JUMP')
: s.discovered ? 'DISCOVERED' : 'UNKNOWN',
x: (s.x / 100) * W, // seed from the API coordinates so the layout settles near the old chart
y: (s.y / 100) * height,
}))
const links: ChartLink[] = world.edges.map(([a, b]) => ({ source: a, target: b }))
const svg = select(container)
.append('svg')
.attr('width', '100%')
.attr('height', height)
.attr('viewBox', `0 0 ${W} ${height}`)
.style('display', 'block')
const root = svg.append('g')
const edgeLayer = root.append('g')
const nodeLayer = root.append('g')
// Pan + zoom (#28): wheel/drag on the chart, transform applied to the scene root. d3-zoom does
// NOT swallow the trailing click after a drag, so track real pans and ignore node clicks that
// end one (mousedown on a node, tiny drag, release over the same node).
let panned = false
const zoomBehavior = zoom<SVGSVGElement, unknown>()
.scaleExtent([0.15, 5])
.on('start', () => {
panned = false
})
.on('zoom', (event) => {
root.attr('transform', event.transform.toString())
// Any user gesture (drag or trackpad pan); a clean click's own mousedown re-fires
// 'start' and resets this before its click lands. Programmatic fits have no sourceEvent.
if (event.sourceEvent) panned = true
})
svg.call(zoomBehavior)
// The settled force layout is bigger than the chart box for large saves, so once the
// simulation stops, zoom-to-fit: frame every node inside the container with padding.
const fitToNodes = () => {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const n of nodes) {
minX = Math.min(minX, (n.x ?? 0) - NODE_W / 2)
minY = Math.min(minY, (n.y ?? 0) - NODE_H / 2)
maxX = Math.max(maxX, (n.x ?? 0) + NODE_W / 2)
maxY = Math.max(maxY, (n.y ?? 0) + NODE_H / 2)
}
const pad = 16
const scale = Math.min(W / (maxX - minX + pad * 2), height / (maxY - minY + pad * 2))
const tx = W / 2 - scale * ((minX + maxX) / 2)
const ty = height / 2 - scale * ((minY + maxY) / 2)
svg.call(zoomBehavior.transform, zoomIdentity.translate(tx, ty).scale(scale))
}
const edgeSel = edgeLayer.selectAll('line').data(links).enter().append('line')
.attr('stroke', 'var(--color-green)')
.attr('stroke-width', 1)
.attr('opacity', 0.5)
const nodeSel = nodeLayer.selectAll<SVGGElement, ChartNode>('g').data(nodes).enter().append('g')
// Same accessible contract as the old chart: only jumpable nodes are role=button with their
// name as the accessible label (e2e clicks them by role/name); all locked while a jump is in flight.
nodeSel.filter((d) => d.status === 'jumpable' && jumpingId === null)
.attr('role', 'button')
.attr('tabindex', 0)
.attr('aria-label', (d) => d.name)
.style('cursor', 'pointer')
.on('click', (event: MouseEvent, d) => {
if (!panned) void jump(d.id) // a real pan ends with a click too — don't jump on that one
})
.on('keydown', (event: KeyboardEvent, d) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
void jump(d.id)
}
})
nodeSel.each(function (d) {
const g = select(this)
const color = STATUS_COLOR[d.status]
g.append('rect')
.attr('x', -NODE_W / 2).attr('y', -NODE_H / 2)
.attr('width', NODE_W).attr('height', NODE_H)
.attr('fill', 'var(--surface-raised)')
g.append('rect')
.attr('x', -NODE_W / 2).attr('y', -NODE_H / 2)
.attr('width', NODE_W).attr('height', NODE_H)
.attr('fill', 'none').attr('stroke', color).attr('stroke-width', 1)
g.append('text')
.attr('x', 0).attr('y', -5)
.attr('text-anchor', 'middle').attr('dominant-baseline', 'middle')
.attr('font-size', 10).attr('fill', color)
.style('font-family', 'var(--font-mono)').style('font-weight', '600')
.text(d.name)
g.append('text')
.attr('x', 0).attr('y', 8)
.attr('text-anchor', 'middle').attr('dominant-baseline', 'middle')
.attr('font-size', 8).attr('fill', 'var(--text-muted)')
.style('font-family', 'var(--font-mono)')
.text(d.sublabel)
})
const simulation = forceSimulation(nodes)
.force('link', forceLink<ChartNode, ChartLink>(links).id((d) => d.id).distance(130).strength(0.25))
.force('charge', forceManyBody().strength(-420))
.force('center', forceCenter(W / 2, height / 2))
.force('collide', forceCollide(60))
.on('end', fitToNodes)
.on('tick', () => {
edgeSel
.attr('x1', (d) => (d.source as ChartNode).x ?? 0)
.attr('y1', (d) => (d.source as ChartNode).y ?? 0)
.attr('x2', (d) => (d.target as ChartNode).x ?? 0)
.attr('y2', (d) => (d.target as ChartNode).y ?? 0)
nodeSel.attr('transform', (d) => `translate(${d.x ?? 0},${d.y ?? 0})`)
})
return () => {
simulation.stop()
svg.remove()
}
}, [world, jumpingId, jump, height])
if (error && !world) {
return (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
@@ -69,39 +251,30 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
if (!world) {
return (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>SCANNING CHART…</span>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>SCANNING CHART…</span>
</div>
)
}
const current = world.systems.find((s) => s.current)!
// 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<NodeGraph
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))}
/>
{/* Same wrapper + title bar as the old hand-rolled chart; d3 owns everything inside the box. */}
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', fontFamily: 'var(--font-mono)', 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',
}}
>
GALACTIC CHART // JUMP ROUTES — TURN {world.turn}
</div>
<div ref={containerRef} style={{ width: '100%', height: `${height}px` }} />
</div>
{error && (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>