'use client' 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 — 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. In wide mode (#36) the chart fills the whole tab // pane (no internal scrollbar); narrow keeps a fixed fallback height since the page scrolls naturally there. interface WorldMapPanelProps { height?: number // fallback chart height when the pane gives no definite space (narrow mode) narrow?: boolean 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 = { current: 'var(--color-green)', jumpable: 'var(--text-muted)', offline: 'var(--border)', } export function WorldMapPanel({ height = 380, narrow = false, onJumped }: WorldMapPanelProps) { // Wide mode: the flex chain (Tabs → TabsContent → this panel) provides a definite pane size to fill (#36). const filling = !narrow const [world, setWorld] = useState(null) const [error, setError] = useState(null) const [jumpingId, setJumpingId] = useState(null) const containerRef = useRef(null) useEffect(() => { let cancelled = false fetch('/api/world') .then(async (res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`) return (await res.json()) as WorldState }) .then((state) => { if (!cancelled) setWorld(state) }) .catch((err: unknown) => { if (!cancelled) setError(err instanceof Error ? err.message : String(err)) }) return () => { cancelled = true } }, []) // 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', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ systemId }), }) const body = (await res.json()) as WorldState & { error?: string } if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`) setWorld(body) // fresh state straight from the response — no refetch needed onJumped?.() // #30 — parent re-fetches /api/system (LOCAL SYSTEM + CARGO HOLD mirror it) } 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 // Measured pane size (#36): the flex chain gives a definite box in wide mode; `height` is the // fallback when the container has no resolved size yet (narrow flow, or tab hidden on load). const W = container.clientWidth || 800 const H = container.clientHeight || height 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) * H, })) const links: ChartLink[] = world.edges.map(([a, b]) => ({ source: a, target: b })) // width/height 100% + fixed viewBox: the scene scales with the pane on window resizes without // rebuilding (pan/zoom stay accurate — d3 maps pointers through the SVG transform matrix). const svg = select(container) .append('svg') .attr('width', '100%') .attr('height', '100%') .attr('viewBox', `0 0 ${W} ${H}`) .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() .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), H / (maxY - minY + pad * 2)) const tx = W / 2 - scale * ((minX + maxX) / 2) const ty = H / 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('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(links).id((d) => d.id).distance(130).strength(0.25)) .force('charge', forceManyBody().strength(-420)) .force('center', forceCenter(W / 2, H / 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 (
CHART OFFLINE // {error.toUpperCase()}
) } if (!world) { return (
SCANNING CHART…
) } return (
{/* Wrapper + title bar; in wide mode the box flexes to fill the whole pane (#36). d3 owns everything inside it. */}
GALACTIC CHART // JUMP ROUTES — TURN {world.turn}
{error && (
JUMP FAILED // {error.toUpperCase()}
)}
) }