ui: WORLD MAP tab renders live world state with jump buttons (real data from /api/world)
This commit is contained in:
@@ -5,17 +5,18 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { NodeGraph, type NodeGraphEdge, type NodeGraphNode } from '@/components/ui/node-graph'
|
||||
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { StatCard } from '@/components/ui/stat-card'
|
||||
import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
|
||||
import { WorldMapPanel } from '@/components/world-map'
|
||||
|
||||
// Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control
|
||||
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui), with
|
||||
// placeholder SpaceWanderer content — no game logic yet. Single player, no auth, by design.
|
||||
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). The
|
||||
// WORLD MAP tab renders live data from /api/world; the other tabs keep placeholder content
|
||||
// until their own issues land. Single player, no auth, by design.
|
||||
|
||||
function useNarrow(threshold = 900): boolean {
|
||||
const [narrow, setNarrow] = useState(
|
||||
@@ -43,33 +44,6 @@ const shipSystems: SystemEntry[] = [
|
||||
{ name: 'COMMS', status: 'ACTIVE' },
|
||||
]
|
||||
|
||||
// WORLD MAP — every node is a solar system, edges are jump routes.
|
||||
const worldMapNodes: NodeGraphNode[] = [
|
||||
{ id: 'kepler', label: 'Kepler Drift', x: 50, y: 48, status: 'ACTIVE', sublabel: 'CURRENT' },
|
||||
{ id: 'vesper', label: 'Vesper Reach', x: 22, y: 26, status: 'NEUTRAL', sublabel: '3 WORLDS' },
|
||||
{ id: 'hollow', label: 'Hollow Gate', x: 74, y: 20, status: 'NEUTRAL', sublabel: '5 WORLDS' },
|
||||
{ id: 'cinder', label: 'Cinder Field', x: 86, y: 56, status: 'WARNING', sublabel: 'RADIATION' },
|
||||
{ id: 'lantern', label: 'Lantern Void', x: 30, y: 74, status: 'NEUTRAL', sublabel: '2 WORLDS' },
|
||||
{ id: 'sable', label: 'Sable Reach', x: 8, y: 46, status: 'OFFLINE', sublabel: 'UNKNOWN' },
|
||||
{ id: 'orpheus', label: 'Orpheus Deep', x: 52, y: 10, status: 'NEUTRAL', sublabel: '7 WORLDS' },
|
||||
{ id: 'mire', label: 'Mire Station', x: 68, y: 84, status: 'ACTIVE', sublabel: 'OUTPOST' },
|
||||
{ id: 'thalassa', label: 'Thalassa Bend', x: 12, y: 86, status: 'NEUTRAL', sublabel: '4 WORLDS' },
|
||||
{ id: 'kuiper', label: 'Kuiper Shard', x: 90, y: 30, status: 'CRITICAL', sublabel: 'WARZONE' },
|
||||
]
|
||||
|
||||
const worldMapEdges: NodeGraphEdge[] = [
|
||||
{ from: 'kepler', to: 'vesper', animated: true },
|
||||
{ from: 'kepler', to: 'hollow', label: 'JUMP-2' },
|
||||
{ from: 'kepler', to: 'lantern', animated: true },
|
||||
{ from: 'kepler', to: 'mire' },
|
||||
{ from: 'vesper', to: 'orpheus' },
|
||||
{ from: 'hollow', to: 'kuiper', label: 'JUMP-1' },
|
||||
{ from: 'cinder', to: 'kuiper' },
|
||||
{ from: 'lantern', to: 'thalassa' },
|
||||
{ from: 'sable', to: 'vesper' },
|
||||
{ from: 'mire', to: 'cinder' },
|
||||
]
|
||||
|
||||
// LOCAL SYSTEM — explorable worlds of the current system (regenerated per save after each jump, later).
|
||||
const explorablePlanets = [
|
||||
{
|
||||
@@ -360,16 +334,9 @@ export function MissionControl() {
|
||||
<TabsTrigger value="event-log">EVENT LOG</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* WORLD MAP TAB */}
|
||||
{/* WORLD MAP TAB — live data from /api/world */}
|
||||
<TabsContent value="world-map" style={{ margin: 0, padding: '1rem' }}>
|
||||
<NodeGraph
|
||||
nodes={worldMapNodes}
|
||||
edges={worldMapEdges}
|
||||
variant="ACTIVE"
|
||||
title="GALACTIC CHART // JUMP ROUTES (PLACEHOLDER)"
|
||||
height={380}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<WorldMapPanel height={380} />
|
||||
</TabsContent>
|
||||
|
||||
{/* LOCAL SYSTEM TAB */}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user