308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
'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'
|
||
import { reachableWithin } from '@/lib/game/world'
|
||
|
||
// 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 within jump range of the current one jumps
|
||
// there (same POST /api/jump flow as before; two hops away once NAVIGATION AI is researched,
|
||
// #35); 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
|
||
navAi?: boolean // #35 — NAVIGATION AI researched: two-hop systems are jumpable fields too
|
||
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, narrow = false, navAi = 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<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
|
||
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)!
|
||
// Clickable chart fields: one hop away, or two hops once NAVIGATION AI is researched (#35).
|
||
const hops = reachableWithin(world.edges, current.id, navAi ? 2 : 1)
|
||
|
||
const nodes: ChartNode[] = world.systems.map((s) => ({
|
||
id: s.id,
|
||
name: s.name.toUpperCase(),
|
||
status: s.current ? 'current' : hops.has(s.id) ? 'jumpable' : 'offline',
|
||
sublabel: s.current
|
||
? 'CURRENT'
|
||
: hops.has(s.id)
|
||
? `${s.discovered ? 'DISCOVERED' : 'UNKNOWN'} // JUMP${(hops.get(s.id) ?? 1) > 1 ? ' ×2' : ''}`
|
||
: 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<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), 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<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, 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, navAi])
|
||
|
||
if (error && !world) {
|
||
return (
|
||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
|
||
CHART OFFLINE // {error.toUpperCase()}
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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.08em' }}>SCANNING CHART…</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// flex (not height:'100%'): the pane chain is sized by min-height, where percentages don't resolve
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', ...(filling ? { flex: 1, minHeight: 0 } : {}) }}>
|
||
{/* Wrapper + title bar; in wide mode the box flexes to fill the whole pane (#36). d3 owns everything inside it. */}
|
||
<div
|
||
style={{
|
||
border: '1px solid var(--border)',
|
||
background: 'var(--surface)',
|
||
fontFamily: 'var(--font-mono)',
|
||
overflow: 'hidden',
|
||
...(filling ? { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } : {}),
|
||
}}
|
||
>
|
||
<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%', ...(filling ? { flex: 1, minHeight: 0 } : { height: `${height}px` }) }} />
|
||
</div>
|
||
|
||
{error && (
|
||
<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' }}>
|
||
JUMP FAILED // {error.toUpperCase()}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|