From 37c38004794950365d130e59a28e20bd786b7574 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 21:32:48 +0000 Subject: [PATCH] ui: WORLD MAP tab renders live world state with jump buttons (real data from /api/world) --- components/mission-control.tsx | 45 ++--------- components/world-map.tsx | 138 +++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 39 deletions(-) create mode 100644 components/world-map.tsx diff --git a/components/mission-control.tsx b/components/mission-control.tsx index b034ade..605cc5a 100644 --- a/components/mission-control.tsx +++ b/components/mission-control.tsx @@ -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() { EVENT LOG - {/* WORLD MAP TAB */} + {/* WORLD MAP TAB — live data from /api/world */} - + {/* LOCAL SYSTEM TAB */} diff --git a/components/world-map.tsx b/components/world-map.tsx new file mode 100644 index 0000000..7395be3 --- /dev/null +++ b/components/world-map.tsx @@ -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(null) + const [error, setError] = useState(null) + const [jumpingId, setJumpingId] = useState(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 ( +
+ + CHART OFFLINE // {error.toUpperCase()} + +
+ ) + } + + if (!world) { + return ( +
+ SCANNING CHART… +
+ ) + } + + 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 ( +
+ ({ + 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%' }} + /> + +
+
+ JUMP ROUTES // FROM {current.name.toUpperCase()} +
+
+ {reachable.map((s) => ( + + ))} +
+
+ + {error && ( +
+ + JUMP FAILED // {error.toUpperCase()} + +
+ )} +
+ ) +}