'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 }