Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a786913df | ||
|
|
978a1d119d |
@@ -1,5 +1,8 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.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",
|
"drizzle-orm": "^0.45.2",
|
||||||
"next": "^16.3.5",
|
"next": "^16.3.5",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
@@ -20,6 +23,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@tailwindcss/postcss": "^4.2.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/node": "^22.20.3",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^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/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/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||||
|
|
||||||
"@types/emscripten": ["@types/emscripten@1.41.6", "", {}, "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
@@ -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<HTMLDivElement> {
|
|
||||||
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<NodeGraphVariant, string> = {
|
|
||||||
DEFAULT: 'var(--text-secondary)',
|
|
||||||
ACTIVE: 'var(--color-green)',
|
|
||||||
WARNING: 'var(--color-amber)',
|
|
||||||
CRITICAL: 'var(--color-red)',
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_COLOR: Record<NodeStatus, string> = {
|
|
||||||
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<HTMLDivElement, NodeGraphProps>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
className,
|
|
||||||
nodes,
|
|
||||||
edges,
|
|
||||||
variant = 'ACTIVE',
|
|
||||||
title,
|
|
||||||
height = 320,
|
|
||||||
directed = false,
|
|
||||||
onNodeClick,
|
|
||||||
style,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
||||||
const [svgW, setSvgW] = React.useState(600)
|
|
||||||
const [hovered, setHovered] = React.useState<string | null>(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 (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn(className)}
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
background: 'var(--surface)',
|
|
||||||
fontFamily: 'var(--font-mono)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{title && (
|
|
||||||
<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',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div ref={containerRef} style={{ width: '100%', height: `${height}px` }}>
|
|
||||||
<svg width={svgW} height={height} style={{ display: 'block' }}>
|
|
||||||
<defs>
|
|
||||||
{directed && (
|
|
||||||
<marker
|
|
||||||
id={markerId}
|
|
||||||
markerWidth="6"
|
|
||||||
markerHeight="6"
|
|
||||||
refX="6"
|
|
||||||
refY="3"
|
|
||||||
orient="auto"
|
|
||||||
>
|
|
||||||
<path d="M 0 0 L 6 3 L 0 6 Z" fill={color} />
|
|
||||||
</marker>
|
|
||||||
)}
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
{hasAnimatedEdge && (
|
|
||||||
<style>{`
|
|
||||||
@keyframes nodeGraphDash { to { stroke-dashoffset: -8; } }
|
|
||||||
`}</style>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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 (
|
|
||||||
<g key={ei}>
|
|
||||||
<line
|
|
||||||
x1={x1.toFixed(1)}
|
|
||||||
y1={y1.toFixed(1)}
|
|
||||||
x2={x2.toFixed(1)}
|
|
||||||
y2={y2.toFixed(1)}
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={1}
|
|
||||||
opacity={0.5}
|
|
||||||
strokeDasharray={edge.animated ? '4 4' : undefined}
|
|
||||||
markerEnd={directed ? `url(#${markerId})` : undefined}
|
|
||||||
style={edge.animated
|
|
||||||
? { animation: 'nodeGraphDash 0.5s linear infinite' }
|
|
||||||
: undefined}
|
|
||||||
/>
|
|
||||||
{edge.label && (
|
|
||||||
<text
|
|
||||||
x={mx.toFixed(1)}
|
|
||||||
y={my.toFixed(1)}
|
|
||||||
textAnchor="middle"
|
|
||||||
dominantBaseline="middle"
|
|
||||||
fontSize={8}
|
|
||||||
fill="var(--text-muted)"
|
|
||||||
fontFamily="var(--font-mono)"
|
|
||||||
>
|
|
||||||
{edge.label}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* 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 (
|
|
||||||
<g
|
|
||||||
key={node.id}
|
|
||||||
onMouseEnter={() => 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 */}
|
|
||||||
<rect
|
|
||||||
x={(cx - NODE_W / 2).toFixed(1)}
|
|
||||||
y={(cy - NODE_H / 2).toFixed(1)}
|
|
||||||
width={NODE_W}
|
|
||||||
height={NODE_H}
|
|
||||||
fill="var(--surface-raised)"
|
|
||||||
/>
|
|
||||||
{/* Hover tint */}
|
|
||||||
{isHovered && (
|
|
||||||
<rect
|
|
||||||
x={(cx - NODE_W / 2).toFixed(1)}
|
|
||||||
y={(cy - NODE_H / 2).toFixed(1)}
|
|
||||||
width={NODE_W}
|
|
||||||
height={NODE_H}
|
|
||||||
fill={statusColor}
|
|
||||||
fillOpacity={0.12}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* Border */}
|
|
||||||
<rect
|
|
||||||
x={(cx - NODE_W / 2).toFixed(1)}
|
|
||||||
y={(cy - NODE_H / 2).toFixed(1)}
|
|
||||||
width={NODE_W}
|
|
||||||
height={NODE_H}
|
|
||||||
fill="none"
|
|
||||||
stroke={statusColor}
|
|
||||||
strokeWidth={isHovered ? 1.5 : 1}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Label */}
|
|
||||||
<text
|
|
||||||
x={cx.toFixed(1)}
|
|
||||||
y={(node.sublabel ? cy - 5 : cy).toFixed(1)}
|
|
||||||
textAnchor="middle"
|
|
||||||
dominantBaseline="middle"
|
|
||||||
fontSize={10}
|
|
||||||
fill={statusColor}
|
|
||||||
fontFamily="var(--font-mono)"
|
|
||||||
fontWeight="600"
|
|
||||||
>
|
|
||||||
{node.label.toUpperCase()}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{/* Sublabel */}
|
|
||||||
{node.sublabel && (
|
|
||||||
<text
|
|
||||||
x={cx.toFixed(1)}
|
|
||||||
y={(cy + 8).toFixed(1)}
|
|
||||||
textAnchor="middle"
|
|
||||||
dominantBaseline="middle"
|
|
||||||
fontSize={8}
|
|
||||||
fill="var(--text-muted)"
|
|
||||||
fontFamily="var(--font-mono)"
|
|
||||||
>
|
|
||||||
{node.sublabel}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
NodeGraph.displayName = 'NodeGraph'
|
|
||||||
|
|
||||||
export { NodeGraph }
|
|
||||||
+205
-32
@@ -1,12 +1,16 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { NodeGraph } from '@/components/ui/node-graph'
|
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 type { WorldState } from '@/lib/game/save'
|
||||||
|
|
||||||
// WORLD MAP tab — renders real world state from /api/world. Clicking a system adjacent to the
|
// WORLD MAP tab — real world state from /api/world on a force-directed chart (d3-force) with
|
||||||
// current one on the galactic chart jumps there (same POST /api/jump flow as before); the
|
// pan + zoom (d3-zoom), per #28. Clicking a system adjacent to the current one jumps there
|
||||||
// current and unreachable systems are dimmed and not clickable. Turn-based: fetch on mount,
|
// (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.
|
// refetch via the jump response — no polling.
|
||||||
|
|
||||||
interface WorldMapPanelProps {
|
interface WorldMapPanelProps {
|
||||||
@@ -14,10 +18,37 @@ interface WorldMapPanelProps {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<NodeStatus, string> = {
|
||||||
|
current: 'var(--color-green)',
|
||||||
|
jumpable: 'var(--text-muted)',
|
||||||
|
offline: 'var(--border)',
|
||||||
|
}
|
||||||
|
|
||||||
export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
|
export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
|
||||||
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)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
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) => {
|
const jump = useCallback(async (systemId: number) => {
|
||||||
|
if (jumpingRef.current) return
|
||||||
|
jumpingRef.current = true
|
||||||
setJumpingId(systemId)
|
setJumpingId(systemId)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/jump', {
|
const res = await fetch('/api/jump', {
|
||||||
@@ -52,10 +88,156 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
|
|||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
} finally {
|
} finally {
|
||||||
|
jumpingRef.current = false
|
||||||
setJumpingId(null)
|
setJumpingId(null)
|
||||||
}
|
}
|
||||||
}, [onJumped])
|
}, [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<SVGSVGElement, unknown>()
|
||||||
|
.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<SVGGElement, ChartNode>('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<ChartNode, ChartLink>(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) {
|
if (error && !world) {
|
||||||
return (
|
return (
|
||||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||||
@@ -69,39 +251,30 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
|
|||||||
if (!world) {
|
if (!world) {
|
||||||
return (
|
return (
|
||||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
<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>
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>SCANNING CHART…</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
<NodeGraph
|
{/* Same wrapper + title bar as the old hand-rolled chart; d3 owns everything inside the box. */}
|
||||||
nodes={world.systems.map((s) => {
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', fontFamily: 'var(--font-mono)', overflow: 'hidden' }}>
|
||||||
const jumpable = !s.current && reachableIds.has(s.id)
|
<div
|
||||||
return {
|
style={{
|
||||||
id: String(s.id),
|
padding: '0.4rem 0.75rem',
|
||||||
label: s.name,
|
borderBottom: '1px solid var(--border)',
|
||||||
x: s.x,
|
background: 'var(--surface-raised)',
|
||||||
y: s.y,
|
fontSize: '0.6rem',
|
||||||
status: s.current ? 'ACTIVE' : jumpable ? 'NEUTRAL' : 'OFFLINE',
|
color: 'var(--text-muted)',
|
||||||
sublabel: s.current ? 'CURRENT' : jumpable ? (s.discovered ? 'DISCOVERED // JUMP' : 'UNKNOWN // JUMP') : s.discovered ? 'DISCOVERED' : 'UNKNOWN',
|
letterSpacing: '0.12em',
|
||||||
disabled: !jumpable || jumpingId !== null, // dimmed + not clickable; all locked while a jump is in flight
|
textTransform: 'uppercase',
|
||||||
}
|
}}
|
||||||
})}
|
>
|
||||||
edges={world.edges.map(([a, b]) => ({ from: String(a), to: String(b) }))}
|
GALACTIC CHART // JUMP ROUTES — TURN {world.turn}
|
||||||
variant="ACTIVE"
|
</div>
|
||||||
title={`GALACTIC CHART // JUMP ROUTES — TURN ${world.turn}`}
|
<div ref={containerRef} style={{ width: '100%', height: `${height}px` }} />
|
||||||
height={height}
|
</div>
|
||||||
style={{ width: '100%' }}
|
|
||||||
onNodeClick={(id) => void jump(Number(id))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ export const solarSystems = sqliteTable('solar_systems', {
|
|||||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
saveId: integer('save_id').notNull().references(() => saves.id),
|
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||||
name: text('name').notNull(),
|
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(),
|
x: real('x').notNull(),
|
||||||
y: real('y').notNull(),
|
y: real('y').notNull(),
|
||||||
discovered: integer('discovered', { mode: 'boolean' }).notNull().default(false),
|
discovered: integer('discovered', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
export interface WorldSystem {
|
export interface WorldSystem {
|
||||||
name: string
|
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
|
y: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "space-wanderer",
|
"name": "space-wanderer",
|
||||||
"version": "0.9.8",
|
"version": "0.9.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.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",
|
"drizzle-orm": "^0.45.2",
|
||||||
"next": "^16.3.5",
|
"next": "^16.3.5",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
@@ -25,11 +28,14 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@tailwindcss/postcss": "^4.2.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/node": "^22.20.3",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@types/sql.js": "^1.4.11",
|
"@types/sql.js": "^1.4.11",
|
||||||
|
"tailwindcss": "^4.2.0",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vitest": "^5.0.1"
|
"vitest": "^5.0.1"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user