{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sankey-chart",
  "title": "Sankey Chart",
  "description": "Sankey flow diagram for showing proportional flows between nodes - custom SVG implementation",
  "dependencies": [],
  "registryDependencies": [
    "@boldkit/utils",
    "@boldkit/chart"
  ],
  "files": [
    {
      "path": "registry/default/ui/sankey-chart.tsx",
      "content": "import * as React from 'react'\nimport { cn } from '@/lib/utils'\nimport { ChartEmpty } from './chart'\n\n// Typed here so the dev-only guards compile without @types/node (Vite's\n// default tsconfig has none). `declare` emits nothing: bundlers still\n// replace the literal process.env.NODE_ENV.\ndeclare const process: { env: { NODE_ENV?: string } }\n\nexport interface SankeyNode {\n  id: string\n  label: string\n  color?: string\n}\n\nexport interface SankeyLink {\n  source: string\n  target: string\n  value: number\n}\n\nexport interface SankeyChartProps extends React.HTMLAttributes<HTMLDivElement> {\n  nodes: SankeyNode[]\n  links: SankeyLink[]\n  height?: number\n  showTooltip?: boolean\n  showLabels?: boolean\n  /** Accessible label for screen readers (default: \"Sankey chart\") */\n  ariaLabel?: string\n  emptyState?: React.ReactNode\n}\n\nconst NODE_COLORS = [\n  'hsl(var(--primary))',\n  'hsl(var(--secondary))',\n  'hsl(var(--accent))',\n  'hsl(var(--success))',\n  'hsl(var(--info))',\n  'hsl(var(--warning))',\n]\n\ninterface ComputedNode {\n  id: string\n  label: string\n  color: string\n  x: number\n  y: number\n  width: number\n  height: number\n  value: number\n}\n\ninterface ComputedLink {\n  source: ComputedNode\n  target: ComputedNode\n  value: number\n  sourceY: number\n  targetY: number\n  thickness: number\n  path: string\n  color: string\n}\n\nfunction computeLayout(\n  nodes: SankeyNode[],\n  links: SankeyLink[],\n  width: number,\n  height: number\n): { computedNodes: ComputedNode[]; computedLinks: ComputedLink[] } {\n  const padding = { top: 16, right: 120, bottom: 16, left: 120 }\n  const nodeWidth = 16\n  const nodePadding = 10\n\n  // Build adjacency\n  const nodeMap = new Map<string, SankeyNode & { index: number }>()\n  nodes.forEach((n, i) => nodeMap.set(n.id, { ...n, index: i }))\n\n  // Determine columns (depth) via BFS\n  const depth = new Map<string, number>()\n  const inLinks = new Map<string, SankeyLink[]>()\n  const outLinks = new Map<string, SankeyLink[]>()\n  nodes.forEach(n => { inLinks.set(n.id, []); outLinks.set(n.id, []) })\n  links.forEach(l => {\n    outLinks.get(l.source)?.push(l)\n    inLinks.get(l.target)?.push(l)\n  })\n\n  const sources = nodes.filter(n => (inLinks.get(n.id) || []).length === 0)\n  const queue = sources.map(n => n.id)\n  queue.forEach(id => depth.set(id, 0))\n  while (queue.length > 0) {\n    const id = queue.shift()!\n    const d = depth.get(id) ?? 0\n    for (const link of outLinks.get(id) || []) {\n      if (!depth.has(link.target)) {\n        depth.set(link.target, d + 1)\n        queue.push(link.target)\n      }\n    }\n  }\n\n  // Warn about nodes dropped due to cycles (nodes not reachable from any source)\n  if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'development') {\n    const droppedNodes = nodes.filter(n => !depth.has(n.id))\n    if (droppedNodes.length > 0) {\n      console.warn(\n        `[SankeyChart] ${droppedNodes.length} node(s) were dropped because they are part of a cycle or unreachable from source nodes: ${droppedNodes.map(n => n.id).join(', ')}`\n      )\n    }\n  }\n\n  // Guard against an empty depth map (e.g. a fully-cyclic graph with no source\n  // node) — Math.max(...[]) returns -Infinity, which would corrupt colWidth and\n  // produce NaN/Infinity node x positions.\n  const depthValues = Array.from(depth.values())\n  const maxDepth = depthValues.length ? Math.max(...depthValues) : 0\n  const colWidth = maxDepth === 0 ? 0 : (width - padding.left - padding.right - nodeWidth) / maxDepth\n\n  // Group nodes by column\n  const cols = new Map<number, string[]>()\n  depth.forEach((d, id) => {\n    if (!cols.has(d)) cols.set(d, [])\n    const col = cols.get(d)\n    if (col) col.push(id)\n  })\n\n  // Node values = sum of in-links (or out-links for sources)\n  const nodeValue = new Map<string, number>()\n  nodes.forEach(n => {\n    const inVal = (inLinks.get(n.id) || []).reduce((s, l) => s + l.value, 0)\n    const outVal = (outLinks.get(n.id) || []).reduce((s, l) => s + l.value, 0)\n    nodeValue.set(n.id, Math.max(inVal, outVal, 1))\n  })\n\n  const innerHeight = height - padding.top - padding.bottom\n\n  // Compute node heights and y positions per column\n  const computedNodes = new Map<string, ComputedNode>()\n  cols.forEach((ids, col) => {\n    const colTotal = ids.reduce((s, id) => s + (nodeValue.get(id) || 1), 0)\n    const gaps = Math.max(0, ids.length - 1)\n    // When a column is crowded, the fixed padding would consume the whole column\n    // (or more), driving totalNodeHeight negative and overflowing the SVG. Cap\n    // the combined padding to half the column so nodes always have room.\n    const effectivePadding = gaps > 0 ? Math.min(nodePadding, (innerHeight * 0.5) / gaps) : 0\n    const totalNodeHeight = innerHeight - effectivePadding * gaps\n    let yOffset = padding.top\n\n    ids.forEach((id) => {\n      const val = nodeValue.get(id) || 1\n      const h = Math.max(8, (val / colTotal) * totalNodeHeight)\n      const node = nodeMap.get(id)!\n      computedNodes.set(id, {\n        id,\n        label: node.label,\n        color: node.color || NODE_COLORS[node.index % NODE_COLORS.length],\n        x: padding.left + col * colWidth,\n        y: yOffset,\n        width: nodeWidth,\n        height: h,\n        value: val,\n      })\n      yOffset += h + effectivePadding\n    })\n  })\n\n  // Track current link offsets per node side\n  const sourceOffsets = new Map<string, number>()\n  const targetOffsets = new Map<string, number>()\n  computedNodes.forEach((n, id) => {\n    sourceOffsets.set(id, n.y)\n    targetOffsets.set(id, n.y)\n  })\n\n  const computedLinks: ComputedLink[] = links.map(link => {\n    const src = computedNodes.get(link.source)\n    const tgt = computedNodes.get(link.target)\n    if (!src || !tgt) return null\n\n    const srcTotal = (outLinks.get(link.source) || []).reduce((s, l) => s + l.value, 0)\n    const tgtTotal = (inLinks.get(link.target) || []).reduce((s, l) => s + l.value, 0)\n    // Math.max(..., 1) guards the denominator so all-zero link values can't\n    // produce 0/0 = NaN, which would emit an invalid SVG path.\n    const thickness = Math.max(2, (link.value / Math.max(srcTotal, tgtTotal, 1)) * src.height)\n\n    const sY = sourceOffsets.get(link.source) ?? 0\n    const tY = targetOffsets.get(link.target) ?? 0\n    sourceOffsets.set(link.source, sY + thickness)\n    targetOffsets.set(link.target, tY + thickness)\n\n    const x1 = src.x + src.width\n    const x2 = tgt.x\n    const mx = (x1 + x2) / 2\n\n    const path = `M${x1},${sY} C${mx},${sY} ${mx},${tY} ${x2},${tY} L${x2},${tY + thickness} C${mx},${tY + thickness} ${mx},${sY + thickness} ${x1},${sY + thickness} Z`\n\n    return {\n      source: src,\n      target: tgt,\n      value: link.value,\n      sourceY: sY,\n      targetY: tY,\n      thickness,\n      path,\n      color: src.color,\n    }\n  }).filter(Boolean) as ComputedLink[]\n\n  return { computedNodes: Array.from(computedNodes.values()), computedLinks }\n}\n\nconst SankeyChart = React.forwardRef<HTMLDivElement, SankeyChartProps>(\n  (\n    {\n      // Defaulted at the destructure: computeLayout() runs *above* the empty\n      // guard, so `undefined` (the shape a still-loading fetch passes) would\n      // throw before the guard could render <ChartEmpty>.\n      nodes = [],\n      links = [],\n      height = 320,\n      showTooltip = true,\n      showLabels = true,\n      ariaLabel = 'Sankey chart',\n      emptyState,\n      className,\n      ...props\n    },\n    ref\n  ) => {\n    const isEmpty = !nodes || nodes.length === 0 || !links || links.length === 0\n    const containerRef = React.useRef<HTMLDivElement>(null)\n    const [width, setWidth] = React.useState(600)\n    const [tooltip, setTooltip] = React.useState<{ x: number; y: number; label: string; value: number } | null>(null)\n\n    // useLayoutEffect fires before paint, preventing the initial layout flash\n    // that would occur with useEffect + the 600px placeholder width.\n    // SankeyChart is client-only (ResizeObserver), so useLayoutEffect is safe here.\n    const mountedRef = React.useRef(true)\n    React.useLayoutEffect(() => {\n      mountedRef.current = true\n      if (!containerRef.current) return\n      const ro = new ResizeObserver(entries => {\n        if (mountedRef.current) {\n          setWidth(entries[0].contentRect.width)\n        }\n      })\n      ro.observe(containerRef.current)\n      setWidth(containerRef.current.offsetWidth)\n      return () => {\n        mountedRef.current = false\n        ro.disconnect()\n      }\n    }, [])\n\n    const { computedNodes, computedLinks } = React.useMemo(\n      () => computeLayout(nodes, links, width, height),\n      [nodes, links, width, height]\n    )\n\n    if (isEmpty) {\n      return <ChartEmpty ref={ref} message={emptyState} className={className} {...props} />\n    }\n\n    return (\n      <div\n        ref={ref}\n        role=\"group\"\n        aria-label={ariaLabel}\n        className={cn('relative w-full', className)}\n        {...props}\n      >\n        {/* Text alternative. The diagram's only data affordance is a mouse\n            hover tooltip, so without this the flows are unreadable to anyone\n            not using a pointer. Visually hidden, fully announced. */}\n        <table className=\"sr-only\">\n          <caption>{ariaLabel}</caption>\n          <thead>\n            <tr>\n              <th scope=\"col\">From</th>\n              <th scope=\"col\">To</th>\n              <th scope=\"col\">Value</th>\n            </tr>\n          </thead>\n          <tbody>\n            {links.map((link, i) => (\n              <tr key={`${link.source}-${link.target}-${i}`}>\n                <td>{link.source}</td>\n                <td>{link.target}</td>\n                <td>{link.value}</td>\n              </tr>\n            ))}\n          </tbody>\n        </table>\n        <div ref={containerRef} aria-hidden=\"true\" style={{ height }}>\n          <svg width=\"100%\" height={height}>\n            {/* Links */}\n            {computedLinks.map((link, i) => (\n              <path\n                key={i}\n                d={link.path}\n                fill={link.color}\n                fillOpacity={0.45}\n                stroke=\"hsl(var(--foreground))\"\n                strokeWidth={1}\n                onMouseEnter={(e) => {\n                  if (showTooltip) {\n                    setTooltip({\n                      x: e.clientX,\n                      y: e.clientY,\n                      label: `${link.source.label} → ${link.target.label}`,\n                      value: link.value,\n                    })\n                  }\n                }}\n                onMouseLeave={() => setTooltip(null)}\n                style={{ cursor: 'default', transition: 'fill-opacity 100ms' }}\n                className=\"hover:fill-opacity-70\"\n              />\n            ))}\n\n            {/* Nodes */}\n            {computedNodes.map(node => (\n              <g key={node.id}>\n                <rect\n                  x={node.x}\n                  y={node.y}\n                  width={node.width}\n                  height={node.height}\n                  fill={node.color}\n                  stroke=\"hsl(var(--foreground))\"\n                  strokeWidth={3}\n                />\n                {showLabels && (\n                  <text\n                    x={node.x > width / 2 ? node.x - 6 : node.x + node.width + 6}\n                    y={node.y + node.height / 2}\n                    dominantBaseline=\"middle\"\n                    textAnchor={node.x > width / 2 ? 'end' : 'start'}\n                    fill=\"hsl(var(--foreground))\"\n                    style={{ fontSize: 11, fontFamily: \"'DM Mono', monospace\", fontWeight: 700 }}\n                  >\n                    {node.label}\n                  </text>\n                )}\n              </g>\n            ))}\n          </svg>\n        </div>\n\n        {/* Tooltip */}\n        {showTooltip && tooltip && (\n          <div\n            className=\"fixed z-50 pointer-events-none border-3 border-foreground bg-background px-3 py-2 text-xs font-mono shadow-[4px_4px_0px_hsl(var(--foreground))]\"\n            style={{ left: tooltip.x + 12, top: tooltip.y - 32 }}\n          >\n            <p className=\"font-black\">{tooltip.label}</p>\n            <p className=\"text-muted-foreground\">{tooltip.value.toLocaleString()}</p>\n          </div>\n        )}\n      </div>\n    )\n  }\n)\nSankeyChart.displayName = 'SankeyChart'\n\nexport { SankeyChart }\n",
      "type": "registry:ui",
      "target": "components/ui/sankey-chart.tsx"
    }
  ],
  "type": "registry:ui"
}