{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "network-graph",
  "type": "registry:ui",
  "title": "Network Graph",
  "description": "Who is connected to whom, and who the network converges on.",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "data",
    "chart"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/data-state.json",
    "https://ds.interlace.tools/r/skeleton.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/charts/network-graph.tsx",
      "target": "components/ui/charts/network-graph.tsx",
      "type": "registry:ui",
      "content": "'use client';\n\nimport * as React from 'react';\n\n// @interlace/network-graph v1.1.2 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/network-graph\n// What changed since: https://ds.interlace.tools/c/network-graph#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — NetworkGraph\n *\n * Who is connected to whom, and who the network converges on.\n *\n * ## Position means something\n *\n * Radius is rank by connection count: the centre is whoever the network\n * converges on, the rim is the long tail. The same input always produces the\n * same picture, which is the property that makes today's graph comparable\n * against yesterday's — and the property a force simulation destroys. See\n * `graph.ts` for why concentric-by-rank beats a hairball.\n *\n * ## The DS owns the graph; the app owns the meaning\n *\n * Nodes carry `id`, `weight`, an optional `group` and `label` — nothing\n * domain-specific. The detail panel is a render prop, so an app can show\n * whatever a selected node means to it without this component ever learning\n * about authors, packages, services or repos. A graph component that knows\n * about dev.to is a graph component the next site cannot use.\n *\n * ## Ambient edges, meaningful edges\n *\n * Unselected edges are drawn at `--viz-edge` — deliberately below 3:1, because\n * a few hundred edges at full contrast is a grey sheet, not a picture. They are\n * texture. The edges that carry information — the selected node's — switch to\n * `--viz-edge-active` at full opacity. Same split as the slider rail vs knob:\n * the low-contrast element is supplementary, the high-contrast one carries the\n * success criterion.\n *\n * ## MIN_VIEWPORT — 320\n *\n * The plot scales via `viewBox`; the detail panel stacks below it under `md`.\n *\n * | Rule | Concept                    | Where in this file                                      |\n * | ---- | -------------------------- | ------------------------------------------------------- |\n * | R6   | data-slot on every part    | `data-slot=\"network-graph\" / \"-plot\" / \"-detail\"`        |\n * | R7   | className merged + ...rest | `cn(...)` + `{...props}`                                 |\n * | R8   | No `isXxx`                 | `selected`, `limit`                                      |\n * | R11  | One variable per part      | plot owns layout; detail owns the app's meaning          |\n * | R13  | Ecosystem first            | no graph library — layout is 20 lines of trigonometry    |\n * | R14  | Declares min viewport      | `data-min-viewport={String(MIN_VIEWPORT)}`               |\n * | R18  | Tailwind only              | zero inline `style`                                      |\n * | R19  | Tokens only                | `fill-viz-node`, `stroke-viz-edge`, `--viz-*` family     |\n * | R20  | AA contrast                | active edge/node ≥9:1; ambient edge documented decorative|\n * | R23  | Absence is a vocabulary    | `loading` / `error` / no-nodes are three different panels|\n * | R25  | Client component           | selection state + key handlers                           |\n * | R26  | A11y                       | `role=\"img\"` + label + roving focus + node table         |\n */\n\nimport { cn } from '@/lib/utils';\nimport {\n  announceDataState,\n  resolveDataState,\n  type AnnouncementOptions,\n} from '@/components/ui/data-state';\nimport { Skeleton } from '@/components/ui/skeleton';\nimport {\n  concentricLayout,\n  describeGraph,\n  edgesWithin,\n  neighborsOf,\n  topNodes,\n  type GraphEdge,\n  type GraphNode,\n} from '@/components/ui/charts/graph';\n\nexport const MIN_VIEWPORT = 320 as const;\n\n/** Drawing box in user units; the viewBox scales it to any container. */\nconst W = 900;\nconst H = 560;\n\nexport interface NetworkGraphProps extends Omit<React.ComponentProps<'div'>, 'onSelect'> {\n  nodes: readonly GraphNode[];\n  edges: readonly GraphEdge[];\n  /** Caption / accessible summary prefix. */\n  caption?: string;\n  selected?: string | null;\n  onSelect?: (id: string | null) => void;\n  /** How many of the heaviest nodes to draw. Beyond ~200 the picture stops reading. */\n  limit?: number;\n  /** Offer the reader other display caps. Pass `[]` to hide the control. */\n  limitOptions?: readonly number[];\n  /** App-owned detail for the selected node. Omit to render no side panel. */\n  renderDetail?: (node: GraphNode) => React.ReactNode;\n  /** Render a `<Skeleton variant=\"chart\" />` placeholder. */\n  loading?: boolean;\n  /**\n   * The fetch failed.\n   *\n   * \"No connections observed yet\" is a statement about the NETWORK — it says\n   * the reader has not built one. A failed request says nothing about the\n   * network at all, and letting the empty copy stand in for it accuses the\n   * reader of an absence that may not exist.\n   */\n  error?: unknown;\n  /** Context for the absence sentences — noun, coverage, reason. */\n  announce?: AnnouncementOptions;\n}\n\nexport const NetworkGraph = React.forwardRef<HTMLDivElement, NetworkGraphProps>(\n  function NetworkGraph(\n    {\n      nodes,\n      edges,\n      caption,\n      selected = null,\n      onSelect,\n      limit: limitProp = 90,\n      limitOptions = [40, 90, 200],\n      renderDetail,\n      loading = false,\n      error,\n      announce,\n      className,\n      ...props\n    },\n    ref,\n  ) {\n    const [limit, setLimit] = React.useState(limitProp);\n    const [cursor, setCursor] = React.useState(0);\n\n    const shown = React.useMemo(() => topNodes(nodes, limit), [nodes, limit]);\n    const visible = React.useMemo(() => new Set(shown.map((n) => n.id)), [shown]);\n    const positions = React.useMemo(() => concentricLayout(shown, W, H), [shown]);\n    const drawn = React.useMemo(() => edgesWithin(edges, visible), [edges, visible]);\n    const related = React.useMemo(\n      () => (selected ? neighborsOf(drawn, selected) : new Set<string>()),\n      [drawn, selected],\n    );\n\n    const hidden = nodes.length - shown.length;\n\n    const onKeyDown = (event: React.KeyboardEvent<SVGSVGElement>) => {\n      const lastIndex = shown.length - 1;\n      // No initializer: every case below either assigns `next` or returns, so\n      // seeding it with `cursor` only hid that fact from the reader.\n      let next: number;\n      switch (event.key) {\n        case 'ArrowRight':\n        case 'ArrowDown':\n          next = Math.min(lastIndex, cursor + 1);\n          break;\n        case 'ArrowLeft':\n        case 'ArrowUp':\n          next = Math.max(0, cursor - 1);\n          break;\n        case 'Home':\n          next = 0;\n          break;\n        case 'End':\n          next = lastIndex;\n          break;\n        case 'Enter':\n        case ' ':\n          // Toggle, so a keyboard user can deselect without a mouse.\n          onSelect?.(shown[cursor]?.id === selected ? null : (shown[cursor]?.id ?? null));\n          event.preventDefault();\n          return;\n        case 'Escape':\n          onSelect?.(null);\n          return;\n        default:\n          return;\n      }\n      setCursor(next);\n      event.preventDefault();\n    };\n\n    // Before the empty branch: a graph whose data has not arrived is not a graph\n    // with no connections, and saying the second while the first is true is a lie\n    // the reader has no way to detect.\n    const absence = resolveDataState({ loading, error }, announce);\n\n    if (absence.state === 'loading') {\n      return (\n        <Skeleton\n          variant=\"chart\"\n          data-slot=\"network-graph\"\n          data-min-viewport={String(MIN_VIEWPORT)}\n          className={className}\n        />\n      );\n    }\n\n    if (absence.state === 'error') {\n      return (\n        <div\n          ref={ref}\n          data-slot=\"network-graph-error\"\n          data-state=\"error\"\n          data-min-viewport={String(MIN_VIEWPORT)}\n          className={cn(\n            'w-full rounded-lg border border-destructive/40 p-6',\n            className,\n          )}\n          {...props}\n        >\n          <p role=\"alert\" className=\"text-sm text-destructive\">\n            {announceDataState('error', announce)} The network is unknown, not\n            empty.\n          </p>\n        </div>\n      );\n    }\n\n    if (shown.length === 0) {\n      return (\n        <div\n          ref={ref}\n          data-slot=\"network-graph-empty\"\n          data-min-viewport={String(MIN_VIEWPORT)}\n          className={cn('w-full rounded-lg border border-border p-6', className)}\n          {...props}\n        >\n          <p className=\"text-sm text-muted-foreground\">\n            No connections observed yet. A network needs at least one node to plot.\n          </p>\n        </div>\n      );\n    }\n\n    const focused = shown[Math.min(cursor, shown.length - 1)];\n    const detail = selected ? shown.find((n) => n.id === selected) : undefined;\n\n    return (\n      <div\n        ref={ref}\n        data-slot=\"network-graph\"\n        data-min-viewport={String(MIN_VIEWPORT)}\n        // See the note in time-series.tsx: the plot is viewBox-sized, so a\n        // container that collapses paints nothing.\n        className={cn('w-full overflow-hidden rounded-lg border border-border bg-card', className)}\n        {...props}\n      >\n        <div className=\"flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-2.5 text-xs text-muted-foreground\">\n          <span>\n            {nodes.length} nodes · {edges.length} connections\n            {caption ? ` · ${caption}` : ''}\n          </span>\n          {limitOptions.length > 0 && (\n            <span className=\"flex items-center gap-1.5\">\n              {limitOptions.map((option) => (\n                <button\n                  key={option}\n                  type=\"button\"\n                  aria-pressed={limit === option}\n                  onClick={() => setLimit(option)}\n                  className={cn(\n                    'rounded border px-2 py-0.5',\n                    'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',\n                    limit === option\n                      ? 'border-primary bg-primary text-primary-foreground'\n                      : 'border-border hover:bg-muted',\n                  )}\n                >\n                  top {option}\n                </button>\n              ))}\n            </span>\n          )}\n        </div>\n\n        <div className={cn('grid grid-cols-1', renderDetail && 'md:grid-cols-[minmax(0,1fr)_260px]')}>\n          <svg\n            data-slot=\"network-graph-plot\"\n            viewBox={`0 0 ${W} ${H}`}\n            className=\"block w-full focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring\"\n            role=\"img\"\n            aria-label={`${describeGraph(nodes, drawn, shown.length)} Focus this graph and use the arrow keys to move between nodes, Enter to select.`}\n            tabIndex={0}\n            onKeyDown={onKeyDown}\n          >\n            {drawn.map((edge) => {\n              const a = positions.get(edge.from)!;\n              const b = positions.get(edge.to)!;\n              const lit = selected !== null && (edge.from === selected || edge.to === selected);\n              return (\n                <line\n                  key={`${edge.from}~${edge.to}`}\n                  x1={a.x}\n                  y1={a.y}\n                  x2={b.x}\n                  y2={b.y}\n                  strokeWidth={lit ? 1.4 : 0.5}\n                  className={cn(\n                    lit ? 'stroke-viz-edge-active opacity-90' : 'stroke-viz-edge',\n                    !lit && selected !== null && 'opacity-20',\n                  )}\n                  aria-hidden\n                />\n              );\n            })}\n\n            {shown.map((node) => {\n              const position = positions.get(node.id)!;\n              const dimmed = selected !== null && node.id !== selected && !related.has(node.id);\n              const isFocused = node.id === focused.id;\n              return (\n                <g key={node.id}>\n                  <circle\n                    cx={position.x}\n                    cy={position.y}\n                    r={position.r}\n                    strokeWidth={1.2}\n                    className={cn(\n                      'stroke-card',\n                      node.id === selected ? 'fill-viz-node-active' : 'fill-viz-node',\n                      dimmed && 'opacity-20',\n                      onSelect && 'cursor-pointer',\n                    )}\n                    onClick={onSelect ? () => onSelect(node.id === selected ? null : node.id) : undefined}\n                  >\n                    <title>{`${node.label ?? node.id} — ${node.weight} connections`}</title>\n                  </circle>\n                  {/* The keyboard cursor is a ring, not a fill change: it has to\n                      be visible on a node that is already selected. */}\n                  {isFocused && (\n                    <circle\n                      cx={position.x}\n                      cy={position.y}\n                      r={position.r + 4}\n                      fill=\"none\"\n                      strokeWidth={1.5}\n                      className=\"stroke-ring\"\n                      aria-hidden\n                    />\n                  )}\n                </g>\n              );\n            })}\n          </svg>\n\n          {renderDetail && (\n            <aside\n              data-slot=\"network-graph-detail\"\n              className=\"border-t border-border p-4 text-sm md:border-l md:border-t-0\"\n            >\n              {detail ? (\n                renderDetail(detail)\n              ) : (\n                <p className=\"text-muted-foreground\">\n                  Select a node. Distance from the centre is rank by number of connections —\n                  the centre is who this network converges on.\n                </p>\n              )}\n            </aside>\n          )}\n        </div>\n\n        {/* The lossless equivalent. Same contract as SeriesTable: a picture is\n            where numbers stop being readable by anything that is not an eye. */}\n        <div className=\"sr-only\">\n          <table>\n            <caption>{caption ?? 'Network nodes by connection count'}</caption>\n            <thead>\n              <tr>\n                <th scope=\"col\">Node</th>\n                <th scope=\"col\">Connections</th>\n                <th scope=\"col\">Group</th>\n              </tr>\n            </thead>\n            <tbody>\n              {shown.map((node) => (\n                <tr key={node.id}>\n                  <th scope=\"row\">{node.label ?? node.id}</th>\n                  <td>{node.weight}</td>\n                  <td>{node.group ?? 'None'}</td>\n                </tr>\n              ))}\n            </tbody>\n          </table>\n          {hidden > 0 && (\n            <p>\n              {hidden} lower-ranked nodes are below the display cap of {limit}. They are not\n              filtered out.\n            </p>\n          )}\n        </div>\n      </div>\n    );\n  },\n);\n"
    },
    {
      "path": "registry/interlace-ui/charts/graph.ts",
      "target": "components/ui/charts/graph.ts",
      "type": "registry:ui",
      "content": "export interface GraphNode {\n  id: string;\n  /** Ranking metric — ties, links, references. Drives radius and node size. */\n  weight: number;\n  /** Optional grouping, drives the legend and the node tone. */\n  group?: string;\n  /** Display name. Falls back to `id`. */\n  label?: string;\n}\n\nexport interface GraphEdge {\n  from: string;\n  to: string;\n  weight?: number;\n}\n\nexport interface NodePosition {\n  x: number;\n  y: number;\n  /** Painted radius, scaled by weight relative to the heaviest node shown. */\n  r: number;\n}\n\n/**\n * The golden angle, in radians.\n *\n * Successive ranks placed at a rational fraction of a turn line up into\n * visible spokes — a phantom structure the data does not have. The golden\n * angle is the one rotation that never repeats, which is why sunflowers use\n * it and why this is not an arbitrary magic number.\n */\nexport const GOLDEN_ANGLE = 2.399963;\n\n/** Heaviest first. Stable for equal weights, so the picture never flickers. */\nexport const rankNodes = <T extends GraphNode>(nodes: readonly T[]): T[] =>\n  [...nodes].sort((a, b) => b.weight - a.weight);\n\n/** The `limit` heaviest nodes. */\nexport const topNodes = <T extends GraphNode>(nodes: readonly T[], limit: number): T[] =>\n  rankNodes(nodes).slice(0, Math.max(0, limit));\n\n/**\n * Place ranked nodes on concentric rings.\n *\n * Radius follows **rank**, not raw weight. Weight distributions in real\n * networks are long-tailed, so a raw scale piles the low-weight majority onto\n * one outer ring and wastes the whole canvas. Rank spreads them evenly, which\n * is the readable choice even though it discards absolute magnitude — magnitude\n * is carried by node size instead.\n */\nexport function concentricLayout(\n  nodes: readonly GraphNode[],\n  width: number,\n  height: number,\n  { innerRadius = 40, margin = 60, minDot = 3, maxDot = 12 } = {},\n): Map<string, NodePosition> {\n  const ranked = rankNodes(nodes);\n  // Clamped once, here, rather than guarded again at the division below: an\n  // empty network, an all-zero network and a garbage negative weight all have\n  // to land somewhere sane, and one clamp is easier to reason about than two\n  // conditionals that have to agree.\n  const heaviest = Math.max(1, ranked[0]?.weight ?? 0);\n  const cx = width / 2;\n  const cy = height / 2;\n  const span = Math.min(width, height) / 2 - margin - innerRadius;\n  const last = ranked.length - 1;\n\n  return new Map(\n    ranked.map((node, index) => {\n      const t = last > 0 ? index / last : 0;\n      const radius = innerRadius + t * span;\n      const angle = index * GOLDEN_ANGLE;\n      return [\n        node.id,\n        {\n          x: cx + radius * Math.cos(angle),\n          y: cy + radius * Math.sin(angle),\n          r: minDot + (Math.max(0, node.weight) / heaviest) * (maxDot - minDot),\n        },\n      ] as const;\n    }),\n  );\n}\n\n/** Edges whose BOTH ends are visible. A half-drawn edge points at nothing. */\nexport const edgesWithin = (\n  edges: readonly GraphEdge[],\n  visible: ReadonlySet<string>,\n): GraphEdge[] => edges.filter((e) => visible.has(e.from) && visible.has(e.to));\n\n/** Every node one hop from `id`, in the given edge set. */\nexport function neighborsOf(edges: readonly GraphEdge[], id: string): Set<string> {\n  const found = new Set<string>();\n  for (const edge of edges) {\n    if (edge.from === id) found.add(edge.to);\n    if (edge.to === id) found.add(edge.from);\n  }\n  // A self-loop would otherwise report the node as its own neighbour and\n  // make the selected node render dimmed against itself.\n  found.delete(id);\n  return found;\n}\n\n/**\n * The accessible name for a graph.\n *\n * Same contract as `describeSeries`: a screen reader handed `role=\"img\"` with\n * no label announces \"image\". This is the sentence that replaces the picture,\n * and the `<SeriesTable>`-equivalent node listing is what makes it lossless.\n */\nexport function describeGraph(\n  nodes: readonly GraphNode[],\n  edges: readonly GraphEdge[],\n  shown: number,\n): string {\n  if (nodes.length === 0) return 'Network graph: no nodes';\n  const ranked = rankNodes(nodes);\n  const hidden = nodes.length - shown;\n  return (\n    `Network graph: ${nodes.length} nodes, ${edges.length} connections. ` +\n    `Showing the ${shown} most connected${hidden > 0 ? `, ${hidden} below the display cap` : ''}. ` +\n    `Most connected is ${ranked[0].label ?? ranked[0].id} with ${ranked[0].weight}. ` +\n    `Distance from the centre is rank by number of connections.`\n  );\n}\n\n/**\n * @interlace/ui — graph layout and traversal\n *\n * The pure half of `NetworkGraph`. Same split as `scale.ts`: the arithmetic is\n * provable and carries the coverage gate, the SVG above it is checked by\n * stories and axe.\n *\n * ## Why concentric-by-rank and not a force simulation\n *\n * A force layout of a few hundred nodes settles into a hairball where position\n * carries no meaning and every render lands somewhere different. That is fatal\n * for the actual job: comparing today's picture against yesterday's.\n *\n * Here **radius IS the metric**. The centre is whoever the network converges\n * on, and the same input always produces the same picture. A reader can point\n * at a node and say \"it moved inward\", which no force layout permits.\n */\n"
    }
  ],
  "meta": {
    "tier": "chart",
    "client": true,
    "minViewport": 320,
    "loading": true,
    "version": "1.1.2",
    "since": "1.0.0"
  },
  "docs": "## @interlace/network-graph\n\nInstalled to `components/ui/charts/network-graph.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/charts/network-graph';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/network-graph\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
