Merge pull request 'v0.9.10 — world map fills entire tab area, no scrollbar (#36)' (#37) from world-map-full-area into master
CI / build + unit + e2e (push) Failing after 13m40s
CI / build + unit + e2e (push) Failing after 13m40s
This commit was merged in pull request #37.
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.9.10 — 2026-09-29 12:57
|
||||||
|
- The WORLD MAP now fills the entire available area of its tab (#36): in wide mode (pinned viewport, #21) the OPERATIONS Tabs are a full-height flex column and the world-map panel flexes to the bottom of the pane — the chart SVG sizes to the measured container instead of the old fixed 380px height, so there is no internal scrollbar on this tab at any window size. The scene keeps its viewBox and scales with the pane on resizes (pan/zoom stay accurate; re-fitting after a resize is intentionally not done). Narrow mode keeps the fixed fallback height since the page scrolls naturally there.
|
||||||
|
- e2e: the #31 spec no longer relies on WORLD MAP's overflow to force scrolling — it now switches to SHIP SYSTEMS, whose six always-present stacked system cards deterministically overflow the shrunk 1280×560 pane (waiting for the HULL card first), and asserts the same pinned tab bar.
|
||||||
|
|
||||||
## v0.9.9 — 2026-09-29 11:21
|
## v0.9.9 — 2026-09-29 11:21
|
||||||
- The WORLD MAP chart is now a force-directed graph (d3-force) with pan + zoom (d3-zoom), replacing the hand-rolled static NodeGraph render (#28): nodes settle into a dynamic layout seeded from the API coordinates and visibly rearrange after every jump, then auto zoom-to-fit so the whole network stays framed in the chart box. Wheel/drag pans and zooms the scene; only systems adjacent to the current one remain clickable (same `role=button` + name contract as before), all nodes lock while a jump is in flight, and a real pan's trailing click never triggers an accidental jump. Jump flow (`POST /api/jump`, turn counter, `onJumped` refresh from #30) is unchanged; the now-orphaned hand-rolled `NodeGraph` component (world map was its only consumer) is deleted.
|
- The WORLD MAP chart is now a force-directed graph (d3-force) with pan + zoom (d3-zoom), replacing the hand-rolled static NodeGraph render (#28): nodes settle into a dynamic layout seeded from the API coordinates and visibly rearrange after every jump, then auto zoom-to-fit so the whole network stays framed in the chart box. Wheel/drag pans and zooms the scene; only systems adjacent to the current one remain clickable (same `role=button` + name contract as before), all nodes lock while a jump is in flight, and a real pan's trailing click never triggers an accidental jump. Jump flow (`POST /api/jump`, turn counter, `onJumped` refresh from #30) is unchanged; the now-orphaned hand-rolled `NodeGraph` component (world map was its only consumer) is deleted.
|
||||||
|
|
||||||
|
|||||||
@@ -383,12 +383,15 @@ export function MissionControl() {
|
|||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
|
|
||||||
<PanelContent style={{ padding: 0, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
<PanelContent style={{ padding: 0, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
|
{/* Flex column at full height so a tab can fill the pane (#36); other tabs stay
|
||||||
|
content-sized and overflow into this same scroll area as before. */}
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab ?? 'world-map'}
|
value={activeTab ?? 'world-map'}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setActiveTab(v)
|
setActiveTab(v)
|
||||||
window.localStorage.setItem(ACTIVE_TAB_KEY, v) // #27 — keep the tab across refreshes
|
window.localStorage.setItem(ACTIVE_TAB_KEY, v) // #27 — keep the tab across refreshes
|
||||||
}}
|
}}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
|
||||||
>
|
>
|
||||||
<TabsList
|
<TabsList
|
||||||
style={{
|
style={{
|
||||||
@@ -409,9 +412,9 @@ export function MissionControl() {
|
|||||||
<TabsTrigger value="event-log">EVENT LOG</TabsTrigger>
|
<TabsTrigger value="event-log">EVENT LOG</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* WORLD MAP TAB — live data from /api/world */}
|
{/* WORLD MAP TAB — live data from /api/world; fills the whole pane (#36) */}
|
||||||
<TabsContent value="world-map" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="world-map" style={{ margin: 0, padding: '1rem', flex: 1, minHeight: 0 }}>
|
||||||
<WorldMapPanel height={380} onJumped={refreshSystem} />
|
<WorldMapPanel height={380} narrow={narrow} onJumped={refreshSystem} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* LOCAL SYSTEM TAB — live data (state owned by MissionControl; scans report up) */}
|
{/* LOCAL SYSTEM TAB — live data (state owned by MissionControl; scans report up) */}
|
||||||
|
|||||||
+30
-13
@@ -11,10 +11,12 @@ import type { WorldState } from '@/lib/game/save'
|
|||||||
// pan + zoom (d3-zoom), per #28. Clicking a system adjacent to the current one jumps there
|
// pan + zoom (d3-zoom), per #28. Clicking a system adjacent to the current one jumps there
|
||||||
// (same POST /api/jump flow as before); the current and unreachable systems are dimmed and not
|
// (same POST /api/jump flow as before); 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,
|
// clickable, and every node is locked while a jump is in flight. Turn-based: fetch on mount,
|
||||||
// refetch via the jump response — no polling.
|
// 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 {
|
interface WorldMapPanelProps {
|
||||||
height?: number
|
height?: number // fallback chart height when the pane gives no definite space (narrow mode)
|
||||||
|
narrow?: boolean
|
||||||
onJumped?: () => void // #30 — let the parent refresh /api/system after a successful jump
|
onJumped?: () => void // #30 — let the parent refresh /api/system after a successful jump
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +46,9 @@ const STATUS_COLOR: Record<NodeStatus, string> = {
|
|||||||
offline: 'var(--border)',
|
offline: 'var(--border)',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
|
export function WorldMapPanel({ height = 380, narrow = 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 [world, setWorld] = useState<WorldState | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [jumpingId, setJumpingId] = useState<number | null>(null)
|
const [jumpingId, setJumpingId] = useState<number | null>(null)
|
||||||
@@ -99,7 +103,10 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
|
|||||||
const container = containerRef.current
|
const container = containerRef.current
|
||||||
if (!container || !world) return
|
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 W = container.clientWidth || 800
|
||||||
|
const H = container.clientHeight || height
|
||||||
const current = world.systems.find((s) => s.current)!
|
const current = world.systems.find((s) => s.current)!
|
||||||
// Systems one jump away — the only clickable chart fields (the same set as before).
|
// Systems one jump away — the only clickable chart fields (the same set as before).
|
||||||
const reachableIds = new Set(
|
const reachableIds = new Set(
|
||||||
@@ -116,15 +123,17 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
|
|||||||
? (s.discovered ? 'DISCOVERED // JUMP' : 'UNKNOWN // JUMP')
|
? (s.discovered ? 'DISCOVERED // JUMP' : 'UNKNOWN // JUMP')
|
||||||
: s.discovered ? 'DISCOVERED' : 'UNKNOWN',
|
: s.discovered ? 'DISCOVERED' : 'UNKNOWN',
|
||||||
x: (s.x / 100) * W, // seed from the API coordinates so the layout settles near the old chart
|
x: (s.x / 100) * W, // seed from the API coordinates so the layout settles near the old chart
|
||||||
y: (s.y / 100) * height,
|
y: (s.y / 100) * H,
|
||||||
}))
|
}))
|
||||||
const links: ChartLink[] = world.edges.map(([a, b]) => ({ source: a, target: b }))
|
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)
|
const svg = select(container)
|
||||||
.append('svg')
|
.append('svg')
|
||||||
.attr('width', '100%')
|
.attr('width', '100%')
|
||||||
.attr('height', height)
|
.attr('height', '100%')
|
||||||
.attr('viewBox', `0 0 ${W} ${height}`)
|
.attr('viewBox', `0 0 ${W} ${H}`)
|
||||||
.style('display', 'block')
|
.style('display', 'block')
|
||||||
|
|
||||||
const root = svg.append('g')
|
const root = svg.append('g')
|
||||||
@@ -162,9 +171,9 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
|
|||||||
maxY = Math.max(maxY, (n.y ?? 0) + NODE_H / 2)
|
maxY = Math.max(maxY, (n.y ?? 0) + NODE_H / 2)
|
||||||
}
|
}
|
||||||
const pad = 16
|
const pad = 16
|
||||||
const scale = Math.min(W / (maxX - minX + pad * 2), height / (maxY - minY + pad * 2))
|
const scale = Math.min(W / (maxX - minX + pad * 2), H / (maxY - minY + pad * 2))
|
||||||
const tx = W / 2 - scale * ((minX + maxX) / 2)
|
const tx = W / 2 - scale * ((minX + maxX) / 2)
|
||||||
const ty = height / 2 - scale * ((minY + maxY) / 2)
|
const ty = H / 2 - scale * ((minY + maxY) / 2)
|
||||||
svg.call(zoomBehavior.transform, zoomIdentity.translate(tx, ty).scale(scale))
|
svg.call(zoomBehavior.transform, zoomIdentity.translate(tx, ty).scale(scale))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +229,7 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
|
|||||||
const simulation = forceSimulation(nodes)
|
const simulation = forceSimulation(nodes)
|
||||||
.force('link', forceLink<ChartNode, ChartLink>(links).id((d) => d.id).distance(130).strength(0.25))
|
.force('link', forceLink<ChartNode, ChartLink>(links).id((d) => d.id).distance(130).strength(0.25))
|
||||||
.force('charge', forceManyBody().strength(-420))
|
.force('charge', forceManyBody().strength(-420))
|
||||||
.force('center', forceCenter(W / 2, height / 2))
|
.force('center', forceCenter(W / 2, H / 2))
|
||||||
.force('collide', forceCollide(60))
|
.force('collide', forceCollide(60))
|
||||||
.on('end', fitToNodes)
|
.on('end', fitToNodes)
|
||||||
.on('tick', () => {
|
.on('tick', () => {
|
||||||
@@ -257,9 +266,17 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', ...(filling ? { height: '100%' } : {}) }}>
|
||||||
{/* Same wrapper + title bar as the old hand-rolled chart; d3 owns everything inside the box. */}
|
{/* 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' }}>
|
<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
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '0.4rem 0.75rem',
|
padding: '0.4rem 0.75rem',
|
||||||
@@ -273,7 +290,7 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
|
|||||||
>
|
>
|
||||||
GALACTIC CHART // JUMP ROUTES — TURN {world.turn}
|
GALACTIC CHART // JUMP ROUTES — TURN {world.turn}
|
||||||
</div>
|
</div>
|
||||||
<div ref={containerRef} style={{ width: '100%', height: `${height}px` }} />
|
<div ref={containerRef} style={{ width: '100%', ...(filling ? { flex: 1, minHeight: 0 } : { height: `${height}px` }) }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
+5
-2
@@ -87,8 +87,11 @@ test('tab bar stays pinned while panel content scrolls (#31)', async ({ page })
|
|||||||
// Shrink the viewport so the center pane's content overflows its scroll area regardless of save state.
|
// Shrink the viewport so the center pane's content overflows its scroll area regardless of save state.
|
||||||
await page.setViewportSize({ width: 1280, height: 560 });
|
await page.setViewportSize({ width: 1280, height: 560 });
|
||||||
|
|
||||||
// WORLD MAP is always tall enough to overflow (fixed-height chart) — deterministic across retries.
|
// SHIP SYSTEMS always renders six stacked system cards (seeded at start, never removed) — tall
|
||||||
await page.getByRole('tab', { name: 'WORLD MAP' }).click();
|
// enough to overflow the shrunk pane on any save state, deterministic across retries. (#36 made
|
||||||
|
// WORLD MAP fill its pane instead of overflowing it, so it can no longer force the scroll.)
|
||||||
|
await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'HULL' })).toBeVisible();
|
||||||
|
|
||||||
const tablist = page.getByRole('tablist');
|
const tablist = page.getByRole('tablist');
|
||||||
await expect(tablist).toBeVisible();
|
await expect(tablist).toBeVisible();
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "space-wanderer",
|
"name": "space-wanderer",
|
||||||
"version": "0.9.9",
|
"version": "0.9.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user