{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sankey-chart",
  "type": "registry:ui",
  "title": "Sankey chart",
  "description": "An SVG sankey diagram that lays nodes out in columns computed from the links, draws curved ribbons, labels nodes with values, highlights a node's flows on hover or focus, and includes a hidden table.",
  "dependencies": [],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/sankey-chart.tsx",
      "type": "registry:ui",
      "target": "components/ui/sankey-chart.tsx",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\n/** An SVG sankey diagram that lays nodes out in columns computed from the links, draws curved ribbons, labels nodes with values, highlights a node's flows on hover or focus, and includes a hidden table. */\ntype SankeyLink = { source: string; target: string; value: number }\n\ntype SankeyNodeInput = {\n  id: string\n  /** Shown instead of the id. */\n  label?: string\n  /** Any CSS colour. Defaults to the chart tokens in order within the column. */\n  color?: string\n}\n\ntype SankeyChartProps = {\n  /** Flows between node ids. Columns are computed from these. */\n  links?: SankeyLink[]\n  /** Optional node order, labels and colours. Unknown ids found in links are added automatically. */\n  nodes?: SankeyNodeInput[]\n  /** \"left\" puts each node one column after its furthest source; \"justify\" also pushes nodes with no outgoing links to the last column. */\n  align?: \"left\" | \"justify\"\n  title?: string\n  caption?: string\n  /** Chart height in px. The width follows the container. */\n  height?: number\n  nodeWidth?: number\n  /** Vertical gap between nodes in a column, in px. */\n  nodePadding?: number\n  /** Formats values in labels, the tooltip text and the table. */\n  format?: (value: number) => string\n  /** Unit word for accessible labels, e.g. \"visitors\". */\n  unit?: string\n  className?: string\n}\n\ntype Node = {\n  id: string\n  label: string\n  color: string\n  layer: number\n  value: number\n  x: number\n  y: number\n  h: number\n  cy: number\n  inLinks: Edge[]\n  outLinks: Edge[]\n}\ntype Edge = { i: number; source: Node; target: Node; value: number; sy: number; ty: number; w: number }\n\nconst SAMPLE_LINKS: SankeyLink[] = [\n  { source: \"Organic search\", target: \"Signed up\", value: 1400 },\n  { source: \"Organic search\", target: \"Left\", value: 800 },\n  { source: \"Paid social\", target: \"Signed up\", value: 720 },\n  { source: \"Paid social\", target: \"Left\", value: 680 },\n  { source: \"Referrals\", target: \"Signed up\", value: 610 },\n  { source: \"Referrals\", target: \"Left\", value: 290 },\n  { source: \"Newsletter\", target: \"Signed up\", value: 400 },\n  { source: \"Newsletter\", target: \"Left\", value: 200 },\n  { source: \"Direct\", target: \"Signed up\", value: 350 },\n  { source: \"Direct\", target: \"Left\", value: 350 },\n  { source: \"Signed up\", target: \"Starter\", value: 1640 },\n  { source: \"Signed up\", target: \"Growth\", value: 980 },\n  { source: \"Signed up\", target: \"Scale\", value: 310 },\n  { source: \"Signed up\", target: \"Stayed free\", value: 550 },\n]\n\nconst SAMPLE_NODES: SankeyNodeInput[] = [\n  { id: \"Signed up\", color: \"var(--chart-4)\" },\n  { id: \"Left\", color: \"var(--chart-5)\" },\n  { id: \"Starter\", color: \"var(--chart-1)\" },\n  { id: \"Growth\", color: \"var(--chart-2)\" },\n  { id: \"Scale\", color: \"var(--chart-3)\" },\n  { id: \"Stayed free\", color: \"var(--chart-5)\" },\n]\n\nconst COLORS = [\"var(--chart-1)\", \"var(--chart-2)\", \"var(--chart-3)\", \"var(--chart-4)\", \"var(--chart-5)\"]\nconst PAD_Y = 10\nconst PAD_X = 8\nconst defaultFormat = (n: number) => n.toLocaleString(\"en-US\")\n\nconst FONT = 11\nconst est = (text: string) => text.length * FONT * 0.58 + 2\nconst fit = (text: string, max: number) => {\n  const chars = Math.floor((max - 2) / (FONT * 0.58))\n  return text.length <= chars ? text : text.slice(0, Math.max(1, chars - 1)).trimEnd() + \"…\"\n}\nconst twoLine = (n: Node) => n.h >= 28\n\nfunction layout(\n  linksIn: SankeyLink[],\n  nodesIn: SankeyNodeInput[],\n  width: number,\n  height: number,\n  nodeWidth: number,\n  nodePadding: number,\n  align: \"left\" | \"justify\",\n  format: (n: number) => string\n) {\n  const nodes = new Map<string, Node>()\n  const add = (id: string) => {\n    let n = nodes.get(id)\n    if (!n) {\n      n = { id, label: id, color: \"\", layer: 0, value: 0, x: 0, y: 0, h: 0, cy: 0, inLinks: [], outLinks: [] }\n      nodes.set(id, n)\n    }\n    return n\n  }\n  for (const n of nodesIn) {\n    const node = add(n.id)\n    if (n.label) node.label = n.label\n    if (n.color) node.color = n.color\n  }\n  const edges: Edge[] = []\n  linksIn.forEach((l, i) => {\n    if (l.source === l.target || !(l.value > 0)) return\n    const e: Edge = { i, source: add(l.source), target: add(l.target), value: l.value, sy: 0, ty: 0, w: 0 }\n    e.source.outLinks.push(e)\n    e.target.inLinks.push(e)\n    edges.push(e)\n  })\n  const list = [...nodes.values()]\n  for (const n of list) {\n    n.value = Math.max(n.inLinks.reduce((s, e) => s + e.value, 0), n.outLinks.reduce((s, e) => s + e.value, 0))\n  }\n\n  // Layers: longest path from a source (Kahn). Nodes left over by a cycle go after their placed sources.\n  const indeg = new Map(list.map((n) => [n, n.inLinks.length]))\n  const queue = list.filter((n) => n.inLinks.length === 0)\n  const seen = new Set<Node>()\n  while (queue.length) {\n    const n = queue.shift()!\n    seen.add(n)\n    for (const e of n.outLinks) {\n      e.target.layer = Math.max(e.target.layer, n.layer + 1)\n      indeg.set(e.target, (indeg.get(e.target) ?? 1) - 1)\n      if (indeg.get(e.target) === 0) queue.push(e.target)\n    }\n  }\n  for (const n of list) if (!seen.has(n)) n.layer = Math.max(0, ...n.inLinks.filter((e) => seen.has(e.source)).map((e) => e.source.layer + 1))\n  const layers = Math.max(0, ...list.map((n) => n.layer)) + 1\n  if (align === \"justify\") for (const n of list) if (!n.outLinks.length && n.inLinks.length) n.layer = layers - 1\n\n  const columns: Node[][] = Array.from({ length: layers }, () => [])\n  for (const n of list) columns[n.layer].push(n)\n  columns.forEach((col) => col.forEach((n, i) => { if (!n.color) n.color = COLORS[i % COLORS.length] }))\n\n  const innerH = Math.max(1, height - PAD_Y * 2)\n  const k = Math.min(...columns.map((col) => (innerH - nodePadding * (col.length - 1)) / Math.max(1, col.reduce((s, n) => s + n.value, 0))))\n  for (const n of list) n.h = Math.max(1, n.value * k)\n\n  // Heights do not depend on the width, so the outside label widths are known before the columns are placed.\n  // First column labels sit left of their nodes and last column labels right of theirs, each capped to 30% of the width.\n  const labelW = (n: Node) => (twoLine(n) ? Math.max(est(n.label), est(format(n.value))) : est(`${n.label} ${format(n.value)}`))\n  const maxLabel = Math.max(40, Math.floor((width - PAD_X * 2) * 0.3))\n  const side = (col: Node[]) => Math.min(maxLabel, Math.max(0, ...col.map(labelW))) + 6\n  const left = layers > 1 ? side(columns[0]) : 0\n  const right = side(columns[layers - 1])\n  const innerW = Math.max(nodeWidth + 1, width - PAD_X * 2 - left - right)\n  for (const n of list) {\n    n.x = PAD_X + left + (layers > 1 ? (n.layer * (innerW - nodeWidth)) / (layers - 1) : (innerW - nodeWidth) / 2)\n  }\n  const gap = layers > 1 ? (innerW - nodeWidth) / (layers - 1) - nodeWidth - 12 : innerW\n  const place = (col: Node[]) => {\n    const used = col.reduce((s, n) => s + n.h, 0) + nodePadding * (col.length - 1)\n    let y = PAD_Y + Math.max(0, (innerH - used) / 2)\n    for (const n of col) {\n      n.y = y\n      n.cy = y + n.h / 2\n      y += n.h + nodePadding\n    }\n  }\n  columns.forEach(place)\n  // The first column keeps the given order; each later column follows the weighted centre of its sources.\n  const center = (n: Node) => {\n    if (!n.inLinks.length) return n.cy\n    const w = n.inLinks.reduce((s, e) => s + e.value, 0)\n    return n.inLinks.reduce((s, e) => s + e.source.cy * e.value, 0) / w\n  }\n  for (let pass = 0; pass < 2; pass++) {\n    for (let l = 1; l < layers; l++) {\n      const col = columns[l]\n      const want = new Map(col.map((n) => [n, center(n)]))\n      col.sort((a, b) => want.get(a)! - want.get(b)!)\n      place(col)\n    }\n  }\n  // Ribbon offsets along each node edge, sorted so ribbons do not cross at the node.\n  for (const n of list) {\n    let acc = n.y\n    for (const e of [...n.outLinks].sort((a, b) => a.target.cy - b.target.cy)) {\n      e.w = e.value * k\n      e.sy = acc + e.w / 2\n      acc += e.w\n    }\n    acc = n.y\n    for (const e of [...n.inLinks].sort((a, b) => a.source.cy - b.source.cy)) {\n      e.ty = acc + e.w / 2\n      acc += e.w\n    }\n  }\n  const ordered = columns.flat()\n  return { nodes: ordered, edges, layers, maxLabel, gap: Math.max(24, gap) }\n}\n\n/**\n * A sankey diagram. Columns come from the links themselves, ribbons curve\n * between nodes with a gradient from source to target, and hovering or\n * focusing a node lights its flows while the rest fall back. Nodes are\n * keyboard focusable with a spoken summary; a hidden table carries the data.\n */\nfunction SankeyChart({\n  links = SAMPLE_LINKS,\n  nodes: nodesProp = SAMPLE_NODES,\n  align = \"left\",\n  title = \"Where signups come from\",\n  caption = \"Last 30 days, visitors to paid plan\",\n  height = 320,\n  nodeWidth = 10,\n  nodePadding = 14,\n  format = defaultFormat,\n  unit = \"visitors\",\n  className,\n}: SankeyChartProps) {\n  const boxRef = React.useRef<HTMLDivElement>(null)\n  const refs = React.useRef<(SVGGElement | null)[]>([])\n  const [width, setWidth] = React.useState(640)\n  const [active, setActive] = React.useState<string | null>(null)\n  const [focusIdx, setFocusIdx] = React.useState(0)\n  const uid = React.useId().replace(/:/g, \"\")\n  const titleId = React.useId()\n\n  React.useEffect(() => {\n    const el = boxRef.current\n    if (!el || typeof ResizeObserver === \"undefined\") return\n    const ro = new ResizeObserver(([e]) => setWidth(Math.max(160, Math.round(e.contentRect.width))))\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [])\n\n  const { nodes, edges, layers, maxLabel, gap } = React.useMemo(\n    () => layout(links, nodesProp, width, height, nodeWidth, nodePadding, align, format),\n    [links, nodesProp, width, height, nodeWidth, nodePadding, align, format]\n  )\n\n  const touches = (e: Edge) => active != null && (e.source.id === active || e.target.id === active)\n  const near = (n: Node) => active == null || n.id === active || n.inLinks.some((e) => e.source.id === active) || n.outLinks.some((e) => e.target.id === active)\n\n  const describe = (n: Node) => {\n    const from = n.inLinks.map((e) => `${e.source.label} ${format(e.value)}`).join(\", \")\n    const to = n.outLinks.map((e) => `${e.target.label} ${format(e.value)}`).join(\", \")\n    return `${n.label}: ${format(n.value)} ${unit}.${from ? ` From ${from}.` : \"\"}${to ? ` To ${to}.` : \"\"}`\n  }\n\n  const move = (to: number) => {\n    if (!nodes.length) return\n    const n = (to + nodes.length) % nodes.length\n    setFocusIdx(n)\n    refs.current[n]?.focus()\n  }\n\n  const total = nodes.filter((n) => !n.inLinks.length).reduce((s, n) => s + n.value, 0)\n\n  return (\n    <figure\n      data-slot=\"sankey-chart\"\n      aria-labelledby={titleId}\n      className={cn(\"w-full max-w-2xl rounded-xl border border-border bg-surface p-5 shadow-raised\", className)}\n    >\n      <figcaption className=\"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1\">\n        <p id={titleId} className=\"text-[0.8125rem] font-medium text-fg\">\n          {title}\n        </p>\n        <p className=\"text-xs text-fg-muted\">\n          <span className=\"font-medium text-fg tabular-nums\">{format(total)}</span> {unit}\n          {caption ? <span> · {caption}</span> : null}\n        </p>\n      </figcaption>\n\n      <div ref={boxRef} className=\"mt-4 w-full\" style={{ height }}>\n        <svg\n          viewBox={`0 0 ${width} ${height}`}\n          width=\"100%\"\n          height={height}\n          preserveAspectRatio=\"none\"\n          role=\"group\"\n          aria-label={`${title}, ${nodes.length} nodes and ${edges.length} flows`}\n          className=\"block overflow-visible\"\n          onPointerLeave={() => setActive(null)}\n        >\n          <defs>\n            {edges.map((e) => (\n              <linearGradient key={e.i} id={`${uid}-${e.i}`} gradientUnits=\"userSpaceOnUse\" x1={e.source.x + nodeWidth} x2={e.target.x} y1=\"0\" y2=\"0\">\n                <stop offset=\"0%\" stopColor={e.source.color} />\n                <stop offset=\"100%\" stopColor={e.target.color} />\n              </linearGradient>\n            ))}\n          </defs>\n          <g fill=\"none\" aria-hidden>\n            {edges.map((e) => {\n              const x0 = e.source.x + nodeWidth\n              const x1 = e.target.x\n              const mx = (x0 + x1) / 2\n              const on = touches(e)\n              return (\n                <path\n                  key={e.i}\n                  d={`M${x0},${e.sy}C${mx},${e.sy} ${mx},${e.ty} ${x1},${e.ty}`}\n                  stroke={`url(#${uid}-${e.i})`}\n                  strokeWidth={Math.max(1, e.w)}\n                  className=\"transition-opacity duration-[140ms] ease-hairline\"\n                  style={{ opacity: active == null ? 0.32 : on ? 0.78 : 0.06 }}\n                />\n              )\n            })}\n          </g>\n          {nodes.map((n, i) => {\n            // First column: label left of the node. Everything else: right of the node (over the ribbons, with a halo).\n            const right = !(layers > 1 && n.layer === 0)\n            const two = twoLine(n)\n            const tx = right ? n.x + nodeWidth + 6 : n.x - 6\n            const room = n.layer === 0 || n.layer === layers - 1 ? maxLabel : gap\n            const name = fit(n.label, two ? room : room - est(format(n.value)) - 5)\n            // Middle columns lose their text when fewer than five characters fit; the node keeps its spoken label.\n            const show = room >= est(\"MMMMM\")\n            const halo = { paintOrder: \"stroke\" as const, stroke: \"var(--surface)\", strokeWidth: 3, strokeLinejoin: \"round\" as const }\n            return (\n              <g\n                key={n.id}\n                ref={(el) => {\n                  refs.current[i] = el\n                }}\n                role=\"img\"\n                aria-label={describe(n)}\n                tabIndex={i === focusIdx ? 0 : -1}\n                className=\"cursor-pointer outline-none transition-opacity duration-[140ms] ease-hairline\"\n                style={{ opacity: near(n) ? 1 : 0.35 }}\n                onPointerEnter={() => setActive(n.id)}\n                onFocus={() => {\n                  setFocusIdx(i)\n                  setActive(n.id)\n                }}\n                onBlur={() => setActive(null)}\n                onKeyDown={(e) => {\n                  if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") { e.preventDefault(); move(i + 1) }\n                  if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") { e.preventDefault(); move(i - 1) }\n                  if (e.key === \"Home\") { e.preventDefault(); move(0) }\n                  if (e.key === \"End\") { e.preventDefault(); move(nodes.length - 1) }\n                }}\n              >\n                <rect\n                  x={n.x}\n                  y={n.y}\n                  width={nodeWidth}\n                  height={n.h}\n                  rx={2}\n                  fill={n.color}\n                  stroke={active === n.id ? \"var(--fg)\" : \"none\"}\n                  strokeWidth={1.5}\n                />\n                {show ? (\n                  <text x={tx} y={two ? n.cy - 2 : n.cy + 4} textAnchor={right ? \"start\" : \"end\"} className=\"fill-fg\" style={{ fontSize: FONT, fontWeight: 500, ...halo }}>\n                    {name}\n                    {!two ? (\n                      <tspan className=\"fill-fg-muted tabular-nums\" style={{ fontWeight: 400 }} dx={5}>\n                        {format(n.value)}\n                      </tspan>\n                    ) : null}\n                  </text>\n                ) : null}\n                {show && two ? (\n                  <text x={tx} y={n.cy + 11} textAnchor={right ? \"start\" : \"end\"} className=\"fill-fg-muted tabular-nums\" style={{ fontSize: FONT, ...halo }}>\n                    {format(n.value)}\n                  </text>\n                ) : null}\n              </g>\n            )\n          })}\n        </svg>\n      </div>\n\n      <table className=\"sr-only\">\n        <caption>{title}</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          {edges.map((e) => (\n            <tr key={e.i}>\n              <th scope=\"row\">{e.source.label}</th>\n              <td>{e.target.label}</td>\n              <td>{format(e.value)}</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </figure>\n  )\n}\n\nexport { SankeyChart }\nexport type { SankeyChartProps, SankeyLink, SankeyNodeInput }\n"
    }
  ]
}
