From 978a1d119d79d97aca81eff754c9b2c7bcb211a1 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 29 Sep 2026 11:24:02 +0000 Subject: [PATCH] feat(ui): render WORLD MAP as a force-directed d3 graph with pan + zoom (#28) --- CHANGELOG.md | 3 + bun.lock | 38 +++++ components/ui/node-graph.tsx | 310 ----------------------------------- components/world-map.tsx | 237 ++++++++++++++++++++++---- lib/db/schema.ts | 2 +- lib/game/world.ts | 2 +- package.json | 10 +- 7 files changed, 256 insertions(+), 346 deletions(-) delete mode 100644 components/ui/node-graph.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f00542..4f22965 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 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. + ## v0.9.8 — 2026-09-29 10:20 - The OPERATIONS tab bar now stays pinned while its content scrolls (#31): the TabsList is `position: sticky; top: 0` with a z-index above the panel content, so it pins to the top of the OPERATIONS scroll area in wide mode (where only the center pane scrolls, #21) and to the viewport when the body scrolls in narrow mode. Its existing opaque background (`--surface-raised`) keeps scrolled content from showing through; no layout restructure needed — #21 behavior is untouched. - e2e: new smoke spec — shrink the viewport so WORLD MAP's fixed-height chart overflows the pane, scroll the panel to the bottom (asserting a real offset), and check the tablist's bounding box hasn't moved. diff --git a/bun.lock b/bun.lock index 9d1f9ce..63798a5 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,9 @@ "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "d3-force": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", "drizzle-orm": "^0.45.2", "next": "^16.3.5", "react": "^19.3.0", @@ -20,6 +23,9 @@ "devDependencies": { "@playwright/test": "1.63.0", "@tailwindcss/postcss": "^4.2.0", + "@types/d3-force": "^3.0.10", + "@types/d3-selection": "^3.0.12", + "@types/d3-zoom": "^3.0.9", "@types/node": "^22.20.3", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", @@ -221,6 +227,16 @@ "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.12", "", {}, "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.9", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], "@types/emscripten": ["@types/emscripten@1.41.6", "", {}, "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg=="], @@ -295,6 +311,28 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "drizzle-orm": ["drizzle-orm@0.45.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@netlify/db", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA=="], diff --git a/components/ui/node-graph.tsx b/components/ui/node-graph.tsx deleted file mode 100644 index 8cee4b7..0000000 --- a/components/ui/node-graph.tsx +++ /dev/null @@ -1,310 +0,0 @@ -'use client' - -import * as React from 'react' -import { cn } from '@/lib/utils' - -export type NodeGraphVariant = 'DEFAULT' | 'ACTIVE' | 'WARNING' | 'CRITICAL' -export type NodeStatus = 'ACTIVE' | 'OFFLINE' | 'WARNING' | 'CRITICAL' | 'NEUTRAL' - -export interface NodeGraphNode { - id: string - label: string - x: number // 0–100 coordinate space - y: number - status?: NodeStatus - sublabel?: string - disabled?: boolean // not clickable even when onNodeClick is set (e.g. the current system) -} - -export interface NodeGraphEdge { - from: string - to: string - label?: string - animated?: boolean -} - -export interface NodeGraphProps extends React.HTMLAttributes { - nodes: NodeGraphNode[] - edges: NodeGraphEdge[] - variant?: NodeGraphVariant - title?: string - height?: number - directed?: boolean - onNodeClick?: (id: string) => void // makes non-disabled nodes clickable buttons -} - -const VARIANT_COLORS: Record = { - DEFAULT: 'var(--text-secondary)', - ACTIVE: 'var(--color-green)', - WARNING: 'var(--color-amber)', - CRITICAL: 'var(--color-red)', -} - -const STATUS_COLOR: Record = { - ACTIVE: 'var(--color-green)', - OFFLINE: 'var(--border)', - WARNING: 'var(--color-amber)', - CRITICAL: 'var(--color-red)', - NEUTRAL: 'var(--text-muted)', -} - -const NODE_W = 100 -const NODE_H = 36 - -const NodeGraph = React.forwardRef( - ( - { - className, - nodes, - edges, - variant = 'ACTIVE', - title, - height = 320, - directed = false, - onNodeClick, - style, - ...props - }, - ref - ) => { - const containerRef = React.useRef(null) - const [svgW, setSvgW] = React.useState(600) - const [hovered, setHovered] = React.useState(null) - - React.useEffect(() => { - if (!containerRef.current) return - const ro = new ResizeObserver(entries => { - setSvgW(entries[0].contentRect.width) - }) - ro.observe(containerRef.current) - return () => ro.disconnect() - }, []) - - const color = VARIANT_COLORS[variant] - const markerId = `arrow-${variant}` - - function nodePx(node: NodeGraphNode) { - return (node.x / 100) * svgW - } - - function nodePy(node: NodeGraphNode) { - return (node.y / 100) * height - } - - function edgeEndpoints(fromNode: NodeGraphNode, toNode: NodeGraphNode) { - const fx = nodePx(fromNode) - const fy = nodePy(fromNode) - const tx = nodePx(toNode) - const ty = nodePy(toNode) - const dx = tx - fx - const dy = ty - fy - const dist = Math.sqrt(dx * dx + dy * dy) - if (dist < 1) return { x1: fx, y1: fy, x2: tx, y2: ty } - - const ux = dx / dist - const uy = dy / dist - const hw = NODE_W / 2 - const hh = NODE_H / 2 - - const tW = Math.abs(ux) > 0.001 ? hw / Math.abs(ux) : Infinity - const tH = Math.abs(uy) > 0.001 ? hh / Math.abs(uy) : Infinity - const t = Math.min(tW, tH) - - return { - x1: fx + ux * t, - y1: fy + uy * t, - x2: tx - ux * t, - y2: ty - uy * t, - } - } - - const nodeMap = new Map(nodes.map(n => [n.id, n])) - const hasAnimatedEdge = edges.some(e => e.animated) - - return ( -
- {title && ( -
- {title} -
- )} - -
- - - {directed && ( - - - - )} - - - {hasAnimatedEdge && ( - - )} - - {/* Edges */} - {edges.map((edge, ei) => { - const fromNode = nodeMap.get(edge.from) - const toNode = nodeMap.get(edge.to) - if (!fromNode || !toNode) return null - - const { x1, y1, x2, y2 } = edgeEndpoints(fromNode, toNode) - const mx = (x1 + x2) / 2 - const my = (y1 + y2) / 2 - - return ( - - - {edge.label && ( - - {edge.label} - - )} - - ) - })} - - {/* Nodes */} - {nodes.map(node => { - const cx = nodePx(node) - const cy = nodePy(node) - const isHovered = hovered === node.id - const statusColor = node.status ? STATUS_COLOR[node.status] : color - const clickable = !!onNodeClick && !node.disabled - - return ( - setHovered(node.id)} - onMouseLeave={() => setHovered(null)} - onClick={clickable ? () => onNodeClick(node.id) : undefined} - onKeyDown={clickable - ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onNodeClick(node.id) } } - : undefined} - role={clickable ? 'button' : undefined} - tabIndex={clickable ? 0 : undefined} - aria-label={clickable ? node.label.toUpperCase() : undefined} - style={{ cursor: clickable ? 'pointer' : 'default' }} - > - {/* Background */} - - {/* Hover tint */} - {isHovered && ( - - )} - {/* Border */} - - - {/* Label */} - - {node.label.toUpperCase()} - - - {/* Sublabel */} - {node.sublabel && ( - - {node.sublabel} - - )} - - ) - })} - -
-
- ) - } -) -NodeGraph.displayName = 'NodeGraph' - -export { NodeGraph } diff --git a/components/world-map.tsx b/components/world-map.tsx index e1bf5e7..4b44978 100644 --- a/components/world-map.tsx +++ b/components/world-map.tsx @@ -1,12 +1,16 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' -import { NodeGraph } from '@/components/ui/node-graph' +import { useCallback, useEffect, 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' -// WORLD MAP tab — renders real world state from /api/world. Clicking a system adjacent to the -// current one on the galactic chart jumps there (same POST /api/jump flow as before); the -// current and unreachable systems are dimmed and not clickable. Turn-based: fetch on mount, +// 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 adjacent to the current one jumps there +// (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, // refetch via the jump response — no polling. interface WorldMapPanelProps { @@ -14,10 +18,37 @@ interface WorldMapPanelProps { onJumped?: () => void // #30 — let the parent refresh /api/system after a successful jump } +type NodeStatus = 'current' | 'jumpable' | 'offline' + +// d3-force mutates these in place; the whole scene is rebuilt from scratch on every world change. +interface ChartNode extends SimulationNodeDatum { + id: number + name: string // already uppercased — doubles as the accessible label + status: NodeStatus + sublabel: string +} + +// forceLink replaces the numeric ids with node references once initialized. +interface ChartLink { + source: number | ChartNode + target: number | ChartNode +} + +const NODE_W = 100 +const NODE_H = 36 + +// Palette carried over from the old hand-rolled chart. +const STATUS_COLOR: Record = { + current: 'var(--color-green)', + jumpable: 'var(--text-muted)', + offline: 'var(--border)', +} + export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) { const [world, setWorld] = useState(null) const [error, setError] = useState(null) const [jumpingId, setJumpingId] = useState(null) + const containerRef = useRef(null) useEffect(() => { let cancelled = false @@ -37,7 +68,12 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) { } }, []) + // 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) => { + if (jumpingRef.current) return + jumpingRef.current = true setJumpingId(systemId) try { const res = await fetch('/api/jump', { @@ -52,10 +88,156 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) { } catch (err: unknown) { setError(err instanceof Error ? err.message : String(err)) } finally { + jumpingRef.current = false setJumpingId(null) } }, [onJumped]) + // 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 + + const W = container.clientWidth || 800 + const current = world.systems.find((s) => s.current)! + // Systems one jump away — the only clickable chart fields (the same set as before). + const reachableIds = new Set( + world.edges.filter(([a, b]) => a === current.id || b === current.id).map(([a, b]) => (a === current.id ? b : a)) + ) + + const nodes: ChartNode[] = world.systems.map((s) => ({ + id: s.id, + name: s.name.toUpperCase(), + status: s.current ? 'current' : reachableIds.has(s.id) ? 'jumpable' : 'offline', + sublabel: s.current + ? 'CURRENT' + : reachableIds.has(s.id) + ? (s.discovered ? 'DISCOVERED // JUMP' : 'UNKNOWN // JUMP') + : 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) * height, + })) + const links: ChartLink[] = world.edges.map(([a, b]) => ({ source: a, target: b })) + + const svg = select(container) + .append('svg') + .attr('width', '100%') + .attr('height', height) + .attr('viewBox', `0 0 ${W} ${height}`) + .style('display', 'block') + + const root = svg.append('g') + const edgeLayer = root.append('g') + const nodeLayer = root.append('g') + + // Pan + zoom (#28): wheel/drag on the chart, transform applied to the scene root. d3-zoom does + // NOT swallow the trailing click after a drag, so track real pans and ignore node clicks that + // end one (mousedown on a node, tiny drag, release over the same node). + let panned = false + const zoomBehavior = zoom() + .scaleExtent([0.15, 5]) + .on('start', () => { + panned = false + }) + .on('zoom', (event) => { + root.attr('transform', event.transform.toString()) + // Any user gesture (drag or trackpad pan); a clean click's own mousedown re-fires + // 'start' and resets this before its click lands. Programmatic fits have no sourceEvent. + if (event.sourceEvent) panned = true + }) + svg.call(zoomBehavior) + + // The settled force layout is bigger than the chart box for large saves, so once the + // simulation stops, zoom-to-fit: frame every node inside the container with padding. + const fitToNodes = () => { + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + for (const n of nodes) { + minX = Math.min(minX, (n.x ?? 0) - NODE_W / 2) + minY = Math.min(minY, (n.y ?? 0) - NODE_H / 2) + maxX = Math.max(maxX, (n.x ?? 0) + NODE_W / 2) + maxY = Math.max(maxY, (n.y ?? 0) + NODE_H / 2) + } + const pad = 16 + const scale = Math.min(W / (maxX - minX + pad * 2), height / (maxY - minY + pad * 2)) + const tx = W / 2 - scale * ((minX + maxX) / 2) + const ty = height / 2 - scale * ((minY + maxY) / 2) + svg.call(zoomBehavior.transform, zoomIdentity.translate(tx, ty).scale(scale)) + } + + const edgeSel = edgeLayer.selectAll('line').data(links).enter().append('line') + .attr('stroke', 'var(--color-green)') + .attr('stroke-width', 1) + .attr('opacity', 0.5) + + const nodeSel = nodeLayer.selectAll('g').data(nodes).enter().append('g') + + // 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. + 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 + }) + .on('keydown', (event: KeyboardEvent, d) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + void jump(d.id) + } + }) + + nodeSel.each(function (d) { + const g = select(this) + const color = STATUS_COLOR[d.status] + g.append('rect') + .attr('x', -NODE_W / 2).attr('y', -NODE_H / 2) + .attr('width', NODE_W).attr('height', NODE_H) + .attr('fill', 'var(--surface-raised)') + g.append('rect') + .attr('x', -NODE_W / 2).attr('y', -NODE_H / 2) + .attr('width', NODE_W).attr('height', NODE_H) + .attr('fill', 'none').attr('stroke', color).attr('stroke-width', 1) + g.append('text') + .attr('x', 0).attr('y', -5) + .attr('text-anchor', 'middle').attr('dominant-baseline', 'middle') + .attr('font-size', 10).attr('fill', color) + .style('font-family', 'var(--font-mono)').style('font-weight', '600') + .text(d.name) + g.append('text') + .attr('x', 0).attr('y', 8) + .attr('text-anchor', 'middle').attr('dominant-baseline', 'middle') + .attr('font-size', 8).attr('fill', 'var(--text-muted)') + .style('font-family', 'var(--font-mono)') + .text(d.sublabel) + }) + + const simulation = forceSimulation(nodes) + .force('link', forceLink(links).id((d) => d.id).distance(130).strength(0.25)) + .force('charge', forceManyBody().strength(-420)) + .force('center', forceCenter(W / 2, height / 2)) + .force('collide', forceCollide(60)) + .on('end', fitToNodes) + .on('tick', () => { + edgeSel + .attr('x1', (d) => (d.source as ChartNode).x ?? 0) + .attr('y1', (d) => (d.source as ChartNode).y ?? 0) + .attr('x2', (d) => (d.target as ChartNode).x ?? 0) + .attr('y2', (d) => (d.target as ChartNode).y ?? 0) + nodeSel.attr('transform', (d) => `translate(${d.x ?? 0},${d.y ?? 0})`) + }) + + return () => { + simulation.stop() + svg.remove() + } + }, [world, jumpingId, jump, height]) + if (error && !world) { return (
@@ -69,39 +251,30 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) { if (!world) { return (
- SCANNING CHART… + SCANNING CHART…
) } - const current = world.systems.find((s) => s.current)! - // Systems one jump away — the only clickable chart fields (the same set as the old JUMP ROUTES list). - const reachableIds = new Set( - world.edges.filter(([a, b]) => a === current.id || b === current.id).map(([a, b]) => (a === current.id ? b : a)) - ) - return (
- { - const jumpable = !s.current && reachableIds.has(s.id) - return { - id: String(s.id), - label: s.name, - x: s.x, - y: s.y, - status: s.current ? 'ACTIVE' : jumpable ? 'NEUTRAL' : 'OFFLINE', - sublabel: s.current ? 'CURRENT' : jumpable ? (s.discovered ? 'DISCOVERED // JUMP' : 'UNKNOWN // JUMP') : s.discovered ? 'DISCOVERED' : 'UNKNOWN', - disabled: !jumpable || jumpingId !== null, // dimmed + not clickable; all locked while a jump is in flight - } - })} - 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%' }} - onNodeClick={(id) => void jump(Number(id))} - /> + {/* Same wrapper + title bar as the old hand-rolled chart; d3 owns everything inside the box. */} +
+
+ GALACTIC CHART // JUMP ROUTES — TURN {world.turn} +
+
+
{error && (
diff --git a/lib/db/schema.ts b/lib/db/schema.ts index fbc8018..9e63ab6 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -20,7 +20,7 @@ export const solarSystems = sqliteTable('solar_systems', { id: integer('id').primaryKey({ autoIncrement: true }), saveId: integer('save_id').notNull().references(() => saves.id), name: text('name').notNull(), - // 0–100 coordinate space, rendered directly by the NodeGraph component. + // 0–100 coordinate space; seeds the force-directed WORLD MAP layout (components/world-map.tsx). x: real('x').notNull(), y: real('y').notNull(), discovered: integer('discovered', { mode: 'boolean' }).notNull().default(false), diff --git a/lib/game/world.ts b/lib/game/world.ts index 42c37d1..89cb040 100644 --- a/lib/game/world.ts +++ b/lib/game/world.ts @@ -3,7 +3,7 @@ export interface WorldSystem { name: string - x: number // 0–100 coordinate space, rendered directly by NodeGraph + x: number // 0–100 coordinate space; seeds the force-directed WORLD MAP layout y: number } diff --git a/package.json b/package.json index 4fd69a5..47894d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "space-wanderer", - "version": "0.9.8", + "version": "0.9.9", "private": true, "scripts": { "dev": "next dev -p 3000", @@ -15,6 +15,9 @@ "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "d3-force": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", "drizzle-orm": "^0.45.2", "next": "^16.3.5", "react": "^19.3.0", @@ -25,11 +28,14 @@ "devDependencies": { "@playwright/test": "1.63.0", "@tailwindcss/postcss": "^4.2.0", - "tailwindcss": "^4.2.0", + "@types/d3-force": "^3.0.10", + "@types/d3-selection": "^3.0.12", + "@types/d3-zoom": "^3.0.9", "@types/node": "^22.20.3", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@types/sql.js": "^1.4.11", + "tailwindcss": "^4.2.0", "typescript": "^7.0.2", "vitest": "^5.0.1" } -- 2.54.0