diff --git a/bun.lock b/bun.lock index 2c4a8ee..9d1f9ce 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "space-wanderer", "dependencies": { + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", @@ -136,6 +137,8 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.16", "", { "dependencies": { "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="], diff --git a/components/ui/node-graph.tsx b/components/ui/node-graph.tsx new file mode 100644 index 0000000..9c64ba6 --- /dev/null +++ b/components/ui/node-graph.tsx @@ -0,0 +1,299 @@ +'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 +} + +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 +} + +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, + 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 + + return ( + setHovered(node.id)} + onMouseLeave={() => setHovered(null)} + style={{ cursor: 'default' }} + > + {/* Background */} + + {/* Hover tint */} + {isHovered && ( + + )} + {/* Border */} + + + {/* Label */} + + {node.label.toUpperCase()} + + + {/* Sublabel */} + {node.sublabel && ( + + {node.sublabel} + + )} + + ) + })} + +
+
+ ) + } +) +NodeGraph.displayName = 'NodeGraph' + +export { NodeGraph } diff --git a/components/ui/progress.tsx b/components/ui/progress.tsx new file mode 100644 index 0000000..62bccac --- /dev/null +++ b/components/ui/progress.tsx @@ -0,0 +1,80 @@ +import * as React from 'react' +import * as ProgressPrimitive from '@radix-ui/react-progress' +import { cn } from '@/lib/utils' + +export interface ProgressProps + extends React.ComponentPropsWithoutRef { + showValue?: boolean + label?: string +} + +const Progress = React.forwardRef< + React.ElementRef, + ProgressProps +>(({ className, value = 0, showValue = true, label, ...props }, ref) => { + const pct = Math.min(Math.max(value ?? 0, 0), 100) + const filled = Math.round((pct / 100) * 20) + const empty = 20 - filled + const barText = '='.repeat(filled) + ' '.repeat(empty) + + return ( +
+ {label && ( + + {label} + + )} + + + {/* Bracket-style visual */} +
+ [ + {barText} + ] + {showValue && ( + + {Math.round(pct)}% + + )} +
+ + {/* Accessible indicator (visually hidden, only for Radix a11y) */} + +
+
+ ) +}) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/package.json b/package.json index 45b1db7..4b49326 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1",