Files
SpaceWanderer/components/world-map.tsx

139 lines
4.8 KiB
TypeScript

'use client'
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { NodeGraph } from '@/components/ui/node-graph'
import type { WorldState } from '@/lib/game/save'
// WORLD MAP tab — the first live game panel: renders real world state from /api/world and
// offers a jump button per system adjacent to the current one (NodeGraph nodes are not
// clickable upstream, so the affordance lives in this list). Turn-based: fetch on mount,
// refetch via the jump response — no polling.
interface WorldMapPanelProps {
height?: number
}
export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
const [world, setWorld] = useState<WorldState | null>(null)
const [error, setError] = useState<string | null>(null)
const [jumpingId, setJumpingId] = useState<number | null>(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
}
}, [])
const jump = useCallback(async (systemId: number) => {
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
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setJumpingId(null)
}
}, [])
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.12em' }}>SCANNING CHART…</span>
</div>
)
}
const current = world.systems.find((s) => 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)!)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<NodeGraph
nodes={world.systems.map((s) => ({
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',
}))}
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%' }}
/>
<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 && (
<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>
)
}