{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sankey-chart",
  "title": "Sankey Chart",
  "description": "Flow diagram showing weighted links between nodes.",
  "dependencies": [
    "@vllnt/ui@^0.2.1"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/sankey-chart/sankey-chart.tsx",
      "content": "import * as React from \"react\";\n\nimport { cn } from \"@vllnt/ui\";\n\n/**\n * A node in a {@link SankeyChart} flow diagram.\n *\n * @public\n */\nexport type SankeyNode = {\n  /** Unique identifier referenced by links. */\n  id: string;\n  /** Label drawn beside the node. */\n  label: string;\n};\n\n/**\n * A weighted connection between two {@link SankeyNode}s.\n *\n * @public\n */\nexport type SankeyLink = {\n  /** Source node id. */\n  source: string;\n  /** Target node id. */\n  target: string;\n  /** Positive flow weight; sets the ribbon thickness. */\n  value: number;\n};\n\n/**\n * Props for {@link SankeyChart}.\n *\n * @public\n */\nexport type SankeyChartProps = {\n  /** Flow color. Defaults to `currentColor` to follow the text token. */\n  color?: string;\n  /** Viewport height in pixels. @defaultValue 280 */\n  height?: number;\n  /** Weighted edges between nodes. The chart drops links to unknown nodes. */\n  links: SankeyLink[];\n  /** Vertical gap between stacked nodes, in pixels. @defaultValue 12 */\n  nodePadding?: number;\n  /** Flow nodes. */\n  nodes: SankeyNode[];\n  /** Node rectangle width in pixels. @defaultValue 14 */\n  nodeWidth?: number;\n  /** Viewport width in pixels. @defaultValue 480 */\n  width?: number;\n} & React.HTMLAttributes<HTMLDivElement>;\n\nconst DEFAULT_WIDTH = 480;\nconst DEFAULT_HEIGHT = 280;\nconst DEFAULT_NODE_WIDTH = 14;\nconst DEFAULT_NODE_PADDING = 12;\nconst LABEL_GAP = 6;\n\ntype Point = { x: number; y: number };\ntype LaidNode = {\n  height: number;\n  id: string;\n  label: string;\n  value: number;\n  x: number;\n  y: number;\n};\ntype LaidLink = {\n  path: string;\n  source: string;\n  target: string;\n  thickness: number;\n  value: number;\n};\ntype Dimensions = {\n  height: number;\n  nodePadding: number;\n  nodeWidth: number;\n  width: number;\n};\ntype Layout = { links: LaidLink[]; nodes: LaidNode[] };\n\nfunction computeDepths(\n  ids: string[],\n  links: SankeyLink[],\n): Map<string, number> {\n  const incoming = links.reduce<Map<string, string[]>>((map, link) => {\n    const sources = map.get(link.target) ?? [];\n    sources.push(link.source);\n    map.set(link.target, sources);\n    return map;\n  }, new Map());\n\n  const depth = new Map<string, number>();\n  const visiting = new Set<string>();\n  const resolve = (id: string): number => {\n    const cached = depth.get(id);\n    if (cached !== undefined) return cached;\n    if (visiting.has(id)) return 0;\n    visiting.add(id);\n    const sources = incoming.get(id) ?? [];\n    const value =\n      sources.length === 0\n        ? 0\n        : Math.max(...sources.map((source) => resolve(source) + 1));\n    visiting.delete(id);\n    depth.set(id, value);\n    return value;\n  };\n\n  return new Map(ids.map((id) => [id, resolve(id)]));\n}\n\nfunction nodeValues(ids: string[], links: SankeyLink[]): Map<string, number> {\n  const sum = (key: \"source\" | \"target\") =>\n    links.reduce<Map<string, number>>((map, link) => {\n      map.set(link[key], (map.get(link[key]) ?? 0) + link.value);\n      return map;\n    }, new Map());\n  const outgoing = sum(\"source\");\n  const incoming = sum(\"target\");\n  return new Map(\n    ids.map((id) => [\n      id,\n      Math.max(incoming.get(id) ?? 0, outgoing.get(id) ?? 0, 1),\n    ]),\n  );\n}\n\nfunction groupByDepth(\n  ids: string[],\n  depth: Map<string, number>,\n): Map<number, string[]> {\n  return ids.reduce<Map<number, string[]>>((map, id) => {\n    const column = depth.get(id) ?? 0;\n    const bucket = map.get(column) ?? [];\n    bucket.push(id);\n    map.set(column, bucket);\n    return map;\n  }, new Map());\n}\n\nfunction computeScale(\n  columns: Map<number, string[]>,\n  values: Map<string, number>,\n  dims: Dimensions,\n): number {\n  const scales = [...columns.values()].map((bucket) => {\n    const total = bucket.reduce((sum, id) => sum + (values.get(id) ?? 0), 0);\n    const available = dims.height - (bucket.length - 1) * dims.nodePadding;\n    return total > 0 && available > 0\n      ? available / total\n      : Number.POSITIVE_INFINITY;\n  });\n  const scale = Math.min(...scales);\n  return Number.isFinite(scale) && scale > 0 ? scale : 1;\n}\n\ntype PlacementOptions = {\n  dims: Dimensions;\n  maxDepth: number;\n  scale: number;\n  values: Map<string, number>;\n};\n\nfunction positionNodes(\n  nodes: SankeyNode[],\n  columns: Map<number, string[]>,\n  options: PlacementOptions,\n): Map<string, LaidNode> {\n  const { dims, maxDepth, scale, values } = options;\n  const xStep = maxDepth > 0 ? (dims.width - dims.nodeWidth) / maxDepth : 0;\n  const labelOf = (id: string) =>\n    nodes.find((node) => node.id === id)?.label ?? id;\n\n  const perColumn = [...columns.entries()].map(([column, bucket]) => {\n    const used =\n      bucket.reduce((sum, id) => sum + (values.get(id) ?? 0) * scale, 0) +\n      (bucket.length - 1) * dims.nodePadding;\n    const startY = Math.max(0, (dims.height - used) / 2);\n    return bucket.reduce<{ cursor: number; out: [string, LaidNode][] }>(\n      (accumulator, id) => {\n        const value = values.get(id) ?? 0;\n        const height = Math.max(value * scale, 1);\n        accumulator.out.push([\n          id,\n          {\n            height,\n            id,\n            label: labelOf(id),\n            value,\n            x: column * xStep,\n            y: accumulator.cursor,\n          },\n        ]);\n        accumulator.cursor += height + dims.nodePadding;\n        return accumulator;\n      },\n      { cursor: startY, out: [] },\n    ).out;\n  });\n\n  return new Map(perColumn.flat());\n}\n\nfunction orderLinks(\n  links: SankeyLink[],\n  laid: Map<string, LaidNode>,\n): SankeyLink[] {\n  const sorted = [...links].sort(\n    (a, b) => (laid.get(a.target)?.y ?? 0) - (laid.get(b.target)?.y ?? 0),\n  );\n  const bySource = sorted.reduce<Map<string, SankeyLink[]>>((map, link) => {\n    const bucket = map.get(link.source) ?? [];\n    bucket.push(link);\n    map.set(link.source, bucket);\n    return map;\n  }, new Map());\n  return [...bySource.values()].flat();\n}\n\nfunction ribbon(from: Point, to: Point): string {\n  const xc = (from.x + to.x) / 2;\n  return `M ${from.x.toFixed(2)} ${from.y.toFixed(2)} C ${xc.toFixed(2)} ${from.y.toFixed(2)} ${xc.toFixed(2)} ${to.y.toFixed(2)} ${to.x.toFixed(2)} ${to.y.toFixed(2)}`;\n}\n\ntype LinkOptions = {\n  dims: Dimensions;\n  laid: Map<string, LaidNode>;\n  orderedLinks: SankeyLink[];\n  scale: number;\n};\n\nfunction computeLinks(options: LinkOptions): LaidLink[] {\n  const { dims, laid, orderedLinks, scale } = options;\n  const sourceOffset = new Map<string, number>();\n  const targetOffset = new Map<string, number>();\n\n  return orderedLinks.reduce<LaidLink[]>((accumulator, link) => {\n    const source = laid.get(link.source);\n    const target = laid.get(link.target);\n    if (!source || !target) return accumulator;\n\n    const thickness = Math.max(link.value * scale, 1);\n    const sOffset = sourceOffset.get(link.source) ?? 0;\n    const tOffset = targetOffset.get(link.target) ?? 0;\n    sourceOffset.set(link.source, sOffset + thickness);\n    targetOffset.set(link.target, tOffset + thickness);\n\n    const from = {\n      x: source.x + dims.nodeWidth,\n      y: source.y + sOffset + thickness / 2,\n    };\n    const to = { x: target.x, y: target.y + tOffset + thickness / 2 };\n    accumulator.push({\n      path: ribbon(from, to),\n      source: link.source,\n      target: link.target,\n      thickness,\n      value: link.value,\n    });\n    return accumulator;\n  }, []);\n}\n\nfunction computeLayout(\n  nodes: SankeyNode[],\n  links: SankeyLink[],\n  dims: Dimensions,\n): Layout {\n  const ids = nodes.map((node) => node.id);\n  const known = new Set(ids);\n  const validLinks = links.filter(\n    (link) =>\n      link.value > 0 && known.has(link.source) && known.has(link.target),\n  );\n  const depth = computeDepths(ids, validLinks);\n  const values = nodeValues(ids, validLinks);\n  const maxDepth = Math.max(0, ...ids.map((id) => depth.get(id) ?? 0));\n  const columns = groupByDepth(ids, depth);\n  const scale = computeScale(columns, values, dims);\n  const laid = positionNodes(nodes, columns, { dims, maxDepth, scale, values });\n  const orderedLinks = orderLinks(validLinks, laid);\n  const computedLinks = computeLinks({ dims, laid, orderedLinks, scale });\n  return { links: computedLinks, nodes: [...laid.values()] };\n}\n\nfunction SankeyLinks({ color, links }: { color: string; links: LaidLink[] }) {\n  return (\n    <g fill=\"none\" stroke={color} strokeOpacity={0.25}>\n      {links.map((link, index) => (\n        <path\n          d={link.path}\n          key={`${link.source}-${link.target}-${index}`}\n          strokeWidth={link.thickness}\n        >\n          <title>{`${link.source} → ${link.target}: ${link.value.toLocaleString()}`}</title>\n        </path>\n      ))}\n    </g>\n  );\n}\n\nfunction SankeyNodes({\n  color,\n  nodes,\n  nodeWidth,\n  width,\n}: {\n  color: string;\n  nodes: LaidNode[];\n  nodeWidth: number;\n  width: number;\n}) {\n  return (\n    <>\n      {nodes.map((node) => {\n        const isLast = node.x + nodeWidth >= width - 1;\n        return (\n          <g key={node.id}>\n            <rect\n              fill={color}\n              height={node.height}\n              rx={2}\n              width={nodeWidth}\n              x={node.x}\n              y={node.y}\n            >\n              <title>{`${node.label}: ${node.value.toLocaleString()}`}</title>\n            </rect>\n            <text\n              className=\"fill-foreground text-[10px]\"\n              dominantBaseline=\"middle\"\n              textAnchor={isLast ? \"end\" : \"start\"}\n              x={isLast ? node.x - LABEL_GAP : node.x + nodeWidth + LABEL_GAP}\n              y={node.y + node.height / 2}\n            >\n              {node.label}\n            </text>\n          </g>\n        );\n      })}\n    </>\n  );\n}\n\n/**\n * Token-styled SVG Sankey flow diagram.\n *\n * Pure SVG, no chart dependency. The chart builds a layered layout: node depth\n * from the longest incoming path, heights scaled to flow weight, and links as\n * bezier ribbons. Node fills and ribbons use `currentColor`, so the diagram\n * follows the active theme. Returns `null` without nodes.\n *\n * @example\n * ```tsx\n * <SankeyChart\n *   className=\"text-primary\"\n *   nodes={[\n *     { id: \"a\", label: \"Visits\" },\n *     { id: \"b\", label: \"Signup\" },\n *     { id: \"c\", label: \"Paid\" },\n *   ]}\n *   links={[\n *     { source: \"a\", target: \"b\", value: 60 },\n *     { source: \"b\", target: \"c\", value: 25 },\n *   ]}\n * />\n * ```\n *\n * @public\n */\nexport const SankeyChart = ({\n  className,\n  color = \"currentColor\",\n  height = DEFAULT_HEIGHT,\n  links,\n  nodePadding = DEFAULT_NODE_PADDING,\n  nodes,\n  nodeWidth = DEFAULT_NODE_WIDTH,\n  ref,\n  width = DEFAULT_WIDTH,\n  ...props\n}: SankeyChartProps & { ref?: React.Ref<HTMLDivElement> }) => {\n  if (nodes.length === 0) return null;\n\n  const layout = computeLayout(nodes, links, {\n    height,\n    nodePadding,\n    nodeWidth,\n    width,\n  });\n\n  return (\n    <div\n      className={cn(\n        \"rounded-2xl border border-border bg-background/40 p-3\",\n        className,\n      )}\n      ref={ref}\n      {...props}\n    >\n      <svg\n        aria-label=\"Sankey chart\"\n        className=\"h-full w-full\"\n        height={height}\n        role=\"img\"\n        viewBox={`0 0 ${width} ${height}`}\n        width={width}\n      >\n        <SankeyLinks color={color} links={layout.links} />\n        <SankeyNodes\n          color={color}\n          nodes={layout.nodes}\n          nodeWidth={nodeWidth}\n          width={width}\n        />\n      </svg>\n    </div>\n  );\n};\n\nSankeyChart.displayName = \"SankeyChart\";\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component",
  "version": "0.2.1",
  "stability": "stable"
}
