{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "node-graph",
  "type": "registry:ui",
  "title": "Node Graph",
  "description": "SVG node-edge graph with responsive width, directed arrowheads, animated marching-ants edges, and per-node status colors.",
  "dependencies": [],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "ui/node-graph/node-graph.tsx",
      "type": "registry:ui",
      "content": "'use client'\n\nimport * as React from 'react'\nimport { cn } from '@/lib/utils'\n\nexport type NodeGraphVariant = 'DEFAULT' | 'ACTIVE' | 'WARNING' | 'CRITICAL'\nexport type NodeStatus = 'ACTIVE' | 'OFFLINE' | 'WARNING' | 'CRITICAL' | 'NEUTRAL'\n\nexport interface NodeGraphNode {\n  id: string\n  label: string\n  x: number       // 0–100 coordinate space\n  y: number\n  status?: NodeStatus\n  sublabel?: string\n}\n\nexport interface NodeGraphEdge {\n  from: string\n  to: string\n  label?: string\n  animated?: boolean\n}\n\nexport interface NodeGraphProps extends React.HTMLAttributes<HTMLDivElement> {\n  nodes: NodeGraphNode[]\n  edges: NodeGraphEdge[]\n  variant?: NodeGraphVariant\n  title?: string\n  height?: number\n  directed?: boolean\n}\n\nconst VARIANT_COLORS: Record<NodeGraphVariant, string> = {\n  DEFAULT:  'var(--text-secondary)',\n  ACTIVE:   'var(--color-green)',\n  WARNING:  'var(--color-amber)',\n  CRITICAL: 'var(--color-red)',\n}\n\nconst STATUS_COLOR: Record<NodeStatus, string> = {\n  ACTIVE:   'var(--color-green)',\n  OFFLINE:  'var(--border)',\n  WARNING:  'var(--color-amber)',\n  CRITICAL: 'var(--color-red)',\n  NEUTRAL:  'var(--text-muted)',\n}\n\nconst NODE_W = 100\nconst NODE_H = 36\n\nconst NodeGraph = React.forwardRef<HTMLDivElement, NodeGraphProps>(\n  (\n    {\n      className,\n      nodes,\n      edges,\n      variant  = 'ACTIVE',\n      title,\n      height   = 320,\n      directed = false,\n      style,\n      ...props\n    },\n    ref\n  ) => {\n    const containerRef = React.useRef<HTMLDivElement>(null)\n    const [svgW, setSvgW] = React.useState(600)\n    const [hovered, setHovered] = React.useState<string | null>(null)\n\n    React.useEffect(() => {\n      if (!containerRef.current) return\n      const ro = new ResizeObserver(entries => {\n        setSvgW(entries[0].contentRect.width)\n      })\n      ro.observe(containerRef.current)\n      return () => ro.disconnect()\n    }, [])\n\n    const color = VARIANT_COLORS[variant]\n    const markerId = `arrow-${variant}`\n\n    function nodePx(node: NodeGraphNode) {\n      return (node.x / 100) * svgW\n    }\n\n    function nodePy(node: NodeGraphNode) {\n      return (node.y / 100) * height\n    }\n\n    function edgeEndpoints(fromNode: NodeGraphNode, toNode: NodeGraphNode) {\n      const fx = nodePx(fromNode)\n      const fy = nodePy(fromNode)\n      const tx = nodePx(toNode)\n      const ty = nodePy(toNode)\n      const dx = tx - fx\n      const dy = ty - fy\n      const dist = Math.sqrt(dx * dx + dy * dy)\n      if (dist < 1) return { x1: fx, y1: fy, x2: tx, y2: ty }\n\n      const ux = dx / dist\n      const uy = dy / dist\n      const hw = NODE_W / 2\n      const hh = NODE_H / 2\n\n      const tW = Math.abs(ux) > 0.001 ? hw / Math.abs(ux) : Infinity\n      const tH = Math.abs(uy) > 0.001 ? hh / Math.abs(uy) : Infinity\n      const t  = Math.min(tW, tH)\n\n      return {\n        x1: fx + ux * t,\n        y1: fy + uy * t,\n        x2: tx - ux * t,\n        y2: ty - uy * t,\n      }\n    }\n\n    const nodeMap = new Map(nodes.map(n => [n.id, n]))\n    const hasAnimatedEdge = edges.some(e => e.animated)\n\n    return (\n      <div\n        ref={ref}\n        className={cn(className)}\n        style={{\n          border:     '1px solid var(--border)',\n          background: 'var(--surface)',\n          fontFamily: 'var(--font-mono)',\n          overflow:   'hidden',\n          ...style,\n        }}\n        {...props}\n      >\n        {title && (\n          <div\n            style={{\n              padding:       '0.4rem 0.75rem',\n              borderBottom:  '1px solid var(--border)',\n              background:    'var(--surface-raised)',\n              fontSize:      '0.6rem',\n              color:         'var(--text-muted)',\n              letterSpacing: '0.12em',\n              textTransform: 'uppercase',\n            }}\n          >\n            {title}\n          </div>\n        )}\n\n        <div ref={containerRef} style={{ width: '100%', height: `${height}px` }}>\n          <svg width={svgW} height={height} style={{ display: 'block' }}>\n            <defs>\n              {directed && (\n                <marker\n                  id={markerId}\n                  markerWidth=\"6\"\n                  markerHeight=\"6\"\n                  refX=\"6\"\n                  refY=\"3\"\n                  orient=\"auto\"\n                >\n                  <path d=\"M 0 0 L 6 3 L 0 6 Z\" fill={color} />\n                </marker>\n              )}\n            </defs>\n\n            {hasAnimatedEdge && (\n              <style>{`\n                @keyframes nodeGraphDash { to { stroke-dashoffset: -8; } }\n              `}</style>\n            )}\n\n            {/* Edges */}\n            {edges.map((edge, ei) => {\n              const fromNode = nodeMap.get(edge.from)\n              const toNode   = nodeMap.get(edge.to)\n              if (!fromNode || !toNode) return null\n\n              const { x1, y1, x2, y2 } = edgeEndpoints(fromNode, toNode)\n              const mx = (x1 + x2) / 2\n              const my = (y1 + y2) / 2\n\n              return (\n                <g key={ei}>\n                  <line\n                    x1={x1.toFixed(1)}\n                    y1={y1.toFixed(1)}\n                    x2={x2.toFixed(1)}\n                    y2={y2.toFixed(1)}\n                    stroke={color}\n                    strokeWidth={1}\n                    opacity={0.5}\n                    strokeDasharray={edge.animated ? '4 4' : undefined}\n                    markerEnd={directed ? `url(#${markerId})` : undefined}\n                    style={edge.animated\n                      ? { animation: 'nodeGraphDash 0.5s linear infinite' }\n                      : undefined}\n                  />\n                  {edge.label && (\n                    <text\n                      x={mx.toFixed(1)}\n                      y={my.toFixed(1)}\n                      textAnchor=\"middle\"\n                      dominantBaseline=\"middle\"\n                      fontSize={8}\n                      fill=\"var(--text-muted)\"\n                      fontFamily=\"var(--font-mono)\"\n                    >\n                      {edge.label}\n                    </text>\n                  )}\n                </g>\n              )\n            })}\n\n            {/* Nodes */}\n            {nodes.map(node => {\n              const cx = nodePx(node)\n              const cy = nodePy(node)\n              const isHovered   = hovered === node.id\n              const statusColor = node.status ? STATUS_COLOR[node.status] : color\n\n              return (\n                <g\n                  key={node.id}\n                  onMouseEnter={() => setHovered(node.id)}\n                  onMouseLeave={() => setHovered(null)}\n                  style={{ cursor: 'default' }}\n                >\n                  {/* Background */}\n                  <rect\n                    x={(cx - NODE_W / 2).toFixed(1)}\n                    y={(cy - NODE_H / 2).toFixed(1)}\n                    width={NODE_W}\n                    height={NODE_H}\n                    fill=\"var(--surface-raised)\"\n                  />\n                  {/* Hover tint */}\n                  {isHovered && (\n                    <rect\n                      x={(cx - NODE_W / 2).toFixed(1)}\n                      y={(cy - NODE_H / 2).toFixed(1)}\n                      width={NODE_W}\n                      height={NODE_H}\n                      fill={statusColor}\n                      fillOpacity={0.12}\n                    />\n                  )}\n                  {/* Border */}\n                  <rect\n                    x={(cx - NODE_W / 2).toFixed(1)}\n                    y={(cy - NODE_H / 2).toFixed(1)}\n                    width={NODE_W}\n                    height={NODE_H}\n                    fill=\"none\"\n                    stroke={statusColor}\n                    strokeWidth={isHovered ? 1.5 : 1}\n                  />\n\n                  {/* Label */}\n                  <text\n                    x={cx.toFixed(1)}\n                    y={(node.sublabel ? cy - 5 : cy).toFixed(1)}\n                    textAnchor=\"middle\"\n                    dominantBaseline=\"middle\"\n                    fontSize={10}\n                    fill={statusColor}\n                    fontFamily=\"var(--font-mono)\"\n                    fontWeight=\"600\"\n                  >\n                    {node.label.toUpperCase()}\n                  </text>\n\n                  {/* Sublabel */}\n                  {node.sublabel && (\n                    <text\n                      x={cx.toFixed(1)}\n                      y={(cy + 8).toFixed(1)}\n                      textAnchor=\"middle\"\n                      dominantBaseline=\"middle\"\n                      fontSize={8}\n                      fill=\"var(--text-muted)\"\n                      fontFamily=\"var(--font-mono)\"\n                    >\n                      {node.sublabel}\n                    </text>\n                  )}\n                </g>\n              )\n            })}\n          </svg>\n        </div>\n      </div>\n    )\n  }\n)\nNodeGraph.displayName = 'NodeGraph'\n\nexport { NodeGraph }\n"
    }
  ]
}