feat(ui): team builder — lead + helpers with live duration preview, wired into the action panels (#44)
This commit is contained in:
+81
-18
@@ -1,16 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, 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 { TeamBuilder } from '@/components/team-builder'
|
||||
import type { CrewState, 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,
|
||||
// pan + zoom (d3-zoom), per #28. Clicking a system within jump range of the current one ARMS
|
||||
// it (#44): the crew team builder mounts as a floating card over the chart, and its DISPATCH
|
||||
// POSTs /api/jump with the team in the body (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
|
||||
@@ -55,6 +57,9 @@ export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJ
|
||||
const [world, setWorld] = useState<WorldState | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [jumpingId, setJumpingId] = useState<number | null>(null)
|
||||
// #44 — the team builder: the clicked node is the armed target; its DISPATCH performs the jump.
|
||||
const [crew, setCrew] = useState<CrewState | null>(null)
|
||||
const [armedId, setArmedId] = useState<number | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -75,53 +80,85 @@ export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJ
|
||||
}
|
||||
}, [])
|
||||
|
||||
// JC-14 — the roster + queue the builder mirrors: fetched on mount and after every
|
||||
// successful dispatch (the jump response updates the world state, not the roster).
|
||||
// Keep-old-on-failure, the house .catch(() => {}) pattern.
|
||||
const fetchCrew = useCallback(() => {
|
||||
fetch('/api/crew')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as CrewState
|
||||
})
|
||||
.then((c) => setCrew(c))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchCrew()
|
||||
}, [fetchCrew])
|
||||
|
||||
// #35 — the jump-reach map: one hop away, or two once NAVIGATION AI is researched. The chart
|
||||
// marks its fields clickable from it, and the team builder previews the armed target's
|
||||
// duration from its hop count (the same logic /api/jump recomputes).
|
||||
const hopMap = useMemo(() => {
|
||||
if (!world) return null
|
||||
const current = world.systems.find((s) => s.current)
|
||||
return current ? reachableWithin(world.edges, current.id, navAi ? 2 : 1) : null
|
||||
}, [world, navAi])
|
||||
|
||||
// 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) => {
|
||||
// #44 — a node click ARMS the target instead of jumping: the builder over the chart picks the
|
||||
// team, and its DISPATCH performs the fetch (below). Clicking another jumpable node retargets.
|
||||
const arm = useCallback((systemId: number) => {
|
||||
if (jumpingRef.current) return
|
||||
setArmedId(systemId)
|
||||
}, [])
|
||||
|
||||
const jump = useCallback(async (systemId: number, leadCrewId: number, helperCrewIds: number[]) => {
|
||||
if (jumpingRef.current) return
|
||||
jumpingRef.current = true
|
||||
setJumpingId(systemId)
|
||||
setArmedId(null)
|
||||
try {
|
||||
const res = await fetch('/api/jump', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ systemId }),
|
||||
body: JSON.stringify({ systemId, leadCrewId, helperCrewIds }),
|
||||
})
|
||||
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)
|
||||
fetchCrew() // JC-14 — the dispatch committed a team; refresh the roster + queue for the builder
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
jumpingRef.current = false
|
||||
setJumpingId(null)
|
||||
}
|
||||
}, [onJumped])
|
||||
}, [onJumped, fetchCrew])
|
||||
|
||||
// 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
|
||||
if (!container || !world || !hopMap) 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',
|
||||
status: s.current ? 'current' : hopMap.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' : ''}`
|
||||
: hopMap.has(s.id)
|
||||
? `${s.discovered ? 'DISCOVERED' : 'UNKNOWN'} // JUMP${(hopMap.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,
|
||||
@@ -187,18 +224,19 @@ export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJ
|
||||
|
||||
// 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.
|
||||
// A click ARMS the node (#44) — the team builder over the chart performs the jump.
|
||||
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
|
||||
if (!panned) arm(d.id) // a real pan ends with a click too — don't arm on that one
|
||||
})
|
||||
.on('keydown', (event: KeyboardEvent, d) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
void jump(d.id)
|
||||
arm(d.id)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -246,7 +284,7 @@ export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJ
|
||||
simulation.stop()
|
||||
svg.remove()
|
||||
}
|
||||
}, [world, jumpingId, jump, height, navAi])
|
||||
}, [world, jumpingId, arm, height, navAi, hopMap])
|
||||
|
||||
if (error && !world) {
|
||||
return (
|
||||
@@ -269,13 +307,15 @@ export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJ
|
||||
// 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. */}
|
||||
{/* Wrapper + title bar; in wide mode the box flexes to fill the whole pane (#36). d3 owns the
|
||||
chart container inside it; the armed target's team builder floats over the chart (#44). */}
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
...(filling ? { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } : {}),
|
||||
}}
|
||||
>
|
||||
@@ -293,6 +333,29 @@ export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJ
|
||||
GALACTIC CHART // JUMP ROUTES — TURN {world.turn}
|
||||
</div>
|
||||
<div ref={containerRef} style={{ width: '100%', ...(filling ? { flex: 1, minHeight: 0 } : { height: `${height}px` }) }} />
|
||||
|
||||
{/* #44 — the armed target's crew picker, floating over the chart (JC-13: mounted only
|
||||
while a target is armed); its DISPATCH performs the jump with the picked team. */}
|
||||
{armedId !== null && crew !== null && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 'min(24rem, calc(100% - 1.5rem))',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<TeamBuilder
|
||||
type="JUMP"
|
||||
hopCount={hopMap?.get(armedId) ?? 0}
|
||||
roster={crew.crew}
|
||||
tasks={crew.tasks}
|
||||
onQueue={(leadCrewId, helperCrewIds) => void jump(armedId, leadCrewId, helperCrewIds)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
Reference in New Issue
Block a user