{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "flow-sankey",
  "title": "Flow Sankey",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A dependency-free flow diagram: columns placed by the longest chain, crossings reduced, links as thick as their value, nodes as buttons that light up their flows, a detail line, and the same flows as a table for screen readers.",
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/flow-sankey.tsx",
      "content": "// Ballmac UI: Flow Sankey. https://ui.ballmac.com/components/flow-sankey\n\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype SankeyNode = {\n  id: string\n  label: string\n  /** Nodes in the same group share a colour. */\n  group?: string\n}\n\ntype SankeyLink = {\n  source: string\n  target: string\n  /** How much flows from source to target. Zero or less is left out. */\n  value: number\n}\n\ntype Placed = { id: string; label: string; group?: string; col: number; x: number; y: number; h: number; inV: number; outV: number }\ntype PlacedLink = SankeyLink & { path: string; width: number }\n\nconst W = 900\nconst H = 480\nconst NODE_W = 14\nconst GAP = 14\n\n/** Finds the edges that point back to a node still being explored (the ones that close a loop), so layering can ignore them. Returns their indexes. */\nfunction backEdges(ids: string[], edges: { from: string; to: string }[]) {\n  const state = new Map<string, 1 | 2>()\n  const back = new Set<number>()\n  const visit = (id: string) => {\n    state.set(id, 1)\n    edges.forEach((e, i) => {\n      if (e.from !== id) return\n      const s = state.get(e.to)\n      if (s === 1) back.add(i)\n      else if (!s) visit(e.to)\n    })\n    state.set(id, 2)\n  }\n  const hasIn = new Set(edges.map((e) => e.to))\n  for (const id of [...ids.filter((x) => !hasIn.has(x)), ...ids]) if (!state.has(id)) visit(id)\n  return back\n}\n\n/**\n * Lays out a flow diagram: each node goes in a column after everything that feeds it, columns are ordered to reduce crossings, node heights follow the\n * larger of what flows in and out, and each link is a curve as thick as its value. Pure, so it can be tested and reused.\n */\nfunction layoutSankey(nodes: SankeyNode[], links: SankeyLink[]) {\n  const ids = new Set(nodes.map((n) => n.id))\n  const valid = links.filter((l) => l.value > 0 && ids.has(l.source) && ids.has(l.target) && l.source !== l.target)\n  // A flow diagram cannot show a loop: a link that points back to an earlier step is left out.\n  const loops = backEdges(nodes.map((n) => n.id), valid.map((l) => ({ from: l.source, to: l.target })))\n  const good = valid.filter((_, i) => !loops.has(i))\n  const used = new Set(good.flatMap((l) => [l.source, l.target]))\n  const list = nodes.filter((n) => used.has(n.id))\n  // Column = the longest chain of links leading to the node. A node caught in a loop falls back to column 0.\n  const col = new Map<string, number>(list.map((n) => [n.id, 0]))\n  const indeg = new Map<string, number>(list.map((n) => [n.id, good.filter((l) => l.target === n.id).length]))\n  const queue = list.filter((n) => indeg.get(n.id) === 0).map((n) => n.id)\n  while (queue.length) {\n    const id = queue.shift()!\n    for (const l of good.filter((x) => x.source === id)) {\n      col.set(l.target, Math.max(col.get(l.target) ?? 0, (col.get(id) ?? 0) + 1))\n      indeg.set(l.target, (indeg.get(l.target) ?? 1) - 1)\n      if (indeg.get(l.target) === 0) queue.push(l.target)\n    }\n  }\n  const depth = Math.max(0, ...col.values())\n  const cols: string[][] = Array.from({ length: depth + 1 }, () => [])\n  for (const n of list) cols[col.get(n.id) ?? 0].push(n.id)\n  const inV = (id: string) => good.filter((l) => l.target === id).reduce((s, l) => s + l.value, 0)\n  const outV = (id: string) => good.filter((l) => l.source === id).reduce((s, l) => s + l.value, 0)\n  const size = (id: string) => Math.max(inV(id), outV(id))\n  const k = Math.min(...cols.filter((c) => c.length).map((c) => (H - GAP * (c.length - 1)) / c.reduce((s, id) => s + size(id), 0)), Infinity)\n\n  // Reduce crossings: order each column by the average position of what it connects to, a few passes each way.\n  const pos = new Map<string, number>()\n  const stack = () => {\n    for (const c of cols) {\n      let y = 0\n      for (const id of c) {\n        pos.set(id, y + (size(id) * k) / 2)\n        y += size(id) * k + GAP\n      }\n    }\n  }\n  stack()\n  const bary = (id: string, side: \"source\" | \"target\") => {\n    const ls = good.filter((l) => l[side === \"source\" ? \"target\" : \"source\"] === id)\n    const w = ls.reduce((s, l) => s + l.value, 0)\n    return w ? ls.reduce((s, l) => s + (pos.get(l[side]) ?? 0) * l.value, 0) / w : (pos.get(id) ?? 0)\n  }\n  for (let pass = 0; pass < 4; pass++) {\n    for (let c = 1; c < cols.length; c++) {\n      cols[c].sort((a, b) => bary(a, \"source\") - bary(b, \"source\"))\n      stack()\n    }\n    for (let c = cols.length - 2; c >= 0; c--) {\n      cols[c].sort((a, b) => bary(a, \"target\") - bary(b, \"target\"))\n      stack()\n    }\n  }\n\n  const placed = new Map<string, Placed>()\n  for (let c = 0; c < cols.length; c++) {\n    const total = cols[c].reduce((s, id) => s + size(id) * k, 0) + GAP * Math.max(cols[c].length - 1, 0)\n    let y = (H - total) / 2\n    for (const id of cols[c]) {\n      const n = list.find((x) => x.id === id)!\n      const h = size(id) * k\n      placed.set(id, { id, label: n.label, group: n.group, col: c, x: depth ? (c * (W - NODE_W)) / depth : 0, y, h, inV: inV(id), outV: outV(id) })\n      y += h + GAP\n    }\n  }\n  const outOff = new Map<string, number>()\n  const inOff = new Map<string, number>()\n  const result: PlacedLink[] = []\n  const byTarget = [...good].sort((a, b) => placed.get(a.target)!.y - placed.get(b.target)!.y)\n  const bySource = [...good].sort((a, b) => placed.get(a.source)!.y - placed.get(b.source)!.y)\n  const sy = new Map<SankeyLink, number>()\n  const ty = new Map<SankeyLink, number>()\n  for (const l of byTarget) {\n    const s = placed.get(l.source)!\n    const o = outOff.get(l.source) ?? 0\n    sy.set(l, s.y + o + (l.value * k) / 2)\n    outOff.set(l.source, o + l.value * k)\n  }\n  for (const l of bySource) {\n    const t = placed.get(l.target)!\n    const o = inOff.get(l.target) ?? 0\n    ty.set(l, t.y + o + (l.value * k) / 2)\n    inOff.set(l.target, o + l.value * k)\n  }\n  for (const l of good) {\n    const s = placed.get(l.source)!\n    const t = placed.get(l.target)!\n    const x0 = s.x + NODE_W\n    const x1 = t.x\n    const xm = (x0 + x1) / 2\n    result.push({ ...l, width: l.value * k, path: `M ${x0} ${sy.get(l)} C ${xm} ${sy.get(l)}, ${xm} ${ty.get(l)}, ${x1} ${ty.get(l)}` })\n  }\n  return { nodes: [...placed.values()], links: result, depth }\n}\n\ntype FlowSankeyProps = Omit<React.ComponentProps<\"div\">, \"onSelect\"> & {\n  nodes: SankeyNode[]\n  links: SankeyLink[]\n  /** Turns a value into text. */\n  format?: (value: number) => string\n  /** The selected node id. Controlled. */\n  selected?: string | null\n  /** The selected node id for uncontrolled use. */\n  defaultSelected?: string | null\n  /** Called when a node is chosen, or null when it is cleared. */\n  onSelectedChange?: (id: string | null) => void\n}\n\nconst TINTS = [1, 2, 3, 4, 5]\n\n/**\n * A flow diagram: where something comes from and where it goes, with every link as thick as its value. Nodes are buttons, so hovering or focusing one lights\n * up its flows and the arrow keys move between nodes; the same flows are available as a table for screen readers. The diagram always reads left to right.\n */\nfunction FlowSankey({ nodes, links, format = (v) => v.toLocaleString(\"en-US\"), selected: selectedProp, defaultSelected = null, onSelectedChange, className, ...props }: FlowSankeyProps) {\n  const msg = useMessages()\n  const uid = React.useId()\n  const layout = React.useMemo(() => layoutSankey(nodes, links), [nodes, links])\n  const [inner, setInner] = React.useState<string | null>(defaultSelected)\n  const selected = selectedProp !== undefined ? selectedProp : inner\n  const [hover, setHover] = React.useState<string | null>(null)\n  const active = hover ?? selected\n  const groups = React.useMemo(() => {\n    const g: string[] = []\n    for (const n of layout.nodes) if (n.group && !g.includes(n.group)) g.push(n.group)\n    return g\n  }, [layout.nodes])\n  const color = (n: Placed) => `var(--chart-${TINTS[(n.group ? groups.indexOf(n.group) : layout.nodes.indexOf(n)) % TINTS.length]})`\n  const byId = new Map(layout.nodes.map((n) => [n.id, n]))\n  const [focus, setFocus] = React.useState<string | null>(null)\n  const ordered = [...layout.nodes].sort((a, b) => a.col - b.col || a.y - b.y)\n  const tabbable = (focus && byId.has(focus) ? focus : selected && byId.has(selected) ? selected : ordered[0]?.id) ?? null\n  const refs = React.useRef(new Map<string, HTMLButtonElement>())\n  const chosen = selected ? byId.get(selected) : undefined\n  const related = (id: string) => (active ? active === id || layout.links.some((l) => (l.source === active && l.target === id) || (l.target === active && l.source === id)) : true)\n\n  function pick(id: string) {\n    const next = id === selected ? null : id\n    if (selectedProp === undefined) setInner(next)\n    onSelectedChange?.(next)\n  }\n  function onKeyDown(e: React.KeyboardEvent, n: Placed) {\n    let to: Placed | undefined\n    const sameCol = ordered.filter((o) => o.col === n.col)\n    const i = sameCol.indexOf(n)\n    if (e.key === \"ArrowDown\") to = sameCol[i + 1]\n    else if (e.key === \"ArrowUp\") to = sameCol[i - 1]\n    else if (e.key === \"ArrowRight\" || e.key === \"ArrowLeft\") {\n      const next = ordered.filter((o) => o.col === n.col + (e.key === \"ArrowRight\" ? 1 : -1))\n      to = [...next].sort((a, b) => Math.abs(a.y - n.y) - Math.abs(b.y - n.y))[0]\n    } else if (e.key === \"Home\") to = ordered[0]\n    else if (e.key === \"End\") to = ordered[ordered.length - 1]\n    if (to) {\n      e.preventDefault()\n      setFocus(to.id)\n      refs.current.get(to.id)?.focus()\n    }\n  }\n\n  return (\n    <div data-slot=\"flow-sankey\" className={cn(\"grid w-full min-w-0 gap-3\", className)} {...props}>\n      <div dir=\"ltr\" className=\"relative w-full rounded-xl border bg-card p-3 sm:p-5\" role=\"group\" aria-label={msg(\"flow-sankey.label\", \"Flows between steps\")}>\n        <div className=\"relative w-full\" style={{ aspectRatio: `${W} / ${H}` }}>\n          <svg aria-hidden=\"true\" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio=\"none\" className=\"absolute inset-0 size-full overflow-visible\">\n            {layout.links.map((l) => {\n              const s = byId.get(l.source)!\n              const dim = active && !(l.source === active || l.target === active)\n              return <path key={`${l.source}-${l.target}`} d={l.path} fill=\"none\" stroke={color(s)} strokeWidth={l.width} strokeOpacity={dim ? 0.08 : active ? 0.55 : 0.32} className=\"transition-[stroke-opacity] duration-200 motion-reduce:transition-none\" vectorEffect=\"none\" />\n            })}\n          </svg>\n          {layout.nodes.map((n) => {\n            const last = n.col === layout.depth && layout.depth > 0\n            const on = selected === n.id\n            return (\n              <button\n                key={n.id}\n                ref={(el) => {\n                  if (el) refs.current.set(n.id, el)\n                  else refs.current.delete(n.id)\n                }}\n                type=\"button\"\n                aria-pressed={on}\n                tabIndex={tabbable === n.id ? 0 : -1}\n                aria-label={msg(\"flow-sankey.node\", \"{label}: {inV} in, {outV} out\", { label: n.label, inV: format(n.inV), outV: format(n.outV) })}\n                onClick={() => pick(n.id)}\n                onFocus={() => {\n                  setFocus(n.id)\n                  setHover(n.id)\n                }}\n                onBlur={() => setHover(null)}\n                onPointerEnter={() => setHover(n.id)}\n                onPointerLeave={() => setHover(null)}\n                onKeyDown={(e) => onKeyDown(e, n)}\n                className=\"group absolute outline-none\"\n                style={{ left: `${(n.x / W) * 100}%`, top: `${(n.y / H) * 100}%`, width: `${(NODE_W / W) * 100}%`, height: `${(n.h / H) * 100}%`, minHeight: 6 }}\n              >\n                <span aria-hidden=\"true\" className={cn(\"absolute inset-0 rounded-sm transition-shadow group-focus-visible:ring-[3px] group-focus-visible:ring-ring/50 motion-reduce:transition-none\", on && \"shadow-[0_0_0_2px_var(--foreground)]\")} style={{ backgroundColor: color(n) }} />\n                <span className={cn(\"pointer-events-none absolute top-1/2 w-max max-w-[11rem] -translate-y-1/2 text-xs leading-tight transition-opacity motion-reduce:transition-none\", last ? \"end-full me-2 text-end\" : \"start-full ms-2 text-start\", !related(n.id) && \"opacity-35\")}>\n                  <span className=\"block truncate font-medium text-foreground\">{n.label}</span>\n                  <span className=\"block tabular-nums text-muted-foreground\">{format(Math.max(n.inV, n.outV))}</span>\n                </span>\n              </button>\n            )\n          })}\n        </div>\n      </div>\n\n      <div aria-live=\"polite\" className=\"min-h-[1.5rem] text-sm\">\n        {chosen ? (\n          <p>\n            <span className=\"font-medium\">{chosen.label}</span> <span className=\"tabular-nums text-muted-foreground\">· {msg(\"flow-sankey.inout\", \"{inV} in · {outV} out\", { inV: format(chosen.inV), outV: format(chosen.outV) })}{chosen.inV > 0 && chosen.outV > 0 && chosen.outV !== chosen.inV ? ` · ${format(Math.abs(chosen.inV - chosen.outV))} ${chosen.outV < chosen.inV ? \"does not continue\" : \"starts here\"}` : \"\"}</span>\n          </p>\n        ) : (\n          <p className=\"text-muted-foreground\">{msg(\"flow-sankey.hint\", \"Select a step to follow its flows.\")}</p>\n        )}\n      </div>\n\n      <table className=\"sr-only\">\n        <caption>{msg(\"flow-sankey.table\", \"Flows, from one step to the next\")}</caption>\n        <thead>\n          <tr>\n            <th scope=\"col\">{msg(\"flow-sankey.from\", \"From\")}</th>\n            <th scope=\"col\">{msg(\"flow-sankey.to\", \"To\")}</th>\n            <th scope=\"col\">{msg(\"flow-sankey.value\", \"Amount\")}</th>\n          </tr>\n        </thead>\n        <tbody>\n          {layout.links.map((l) => (\n            <tr key={`${uid}-${l.source}-${l.target}`}>\n              <th scope=\"row\">{byId.get(l.source)?.label}</th>\n              <td>{byId.get(l.target)?.label}</td>\n              <td>{format(l.value)}</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  )\n}\n\nexport { FlowSankey, layoutSankey, type FlowSankeyProps, type SankeyLink, type SankeyNode }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/flow-sankey.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "sankey",
      "flow",
      "funnel",
      "chart",
      "journey",
      "diagram",
      "conversion",
      "attribution"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "nodes is [{ id, label, group? }]; links is [{ source, target, value }]. Columns, order and sizes are computed (layoutSankey is exported). format(value), selected / defaultSelected / onSelectedChange. Links with zero value, unknown ids or a node linking to itself are ignored.",
      "whenToUse": [
        "Showing where traffic, money or users go step by step, with drop-off",
        "Outcomes of agent runs: attempts, tool calls, approvals, results"
      ],
      "whenNotToUse": [
        "A simple conversion funnel of one path; use funnel-1",
        "Proportions with no steps; use treemap",
        "A graph where cycles matter; use agent-graph"
      ],
      "composesWith": [
        "treemap",
        "run-ledger",
        "chart-frame",
        "heat-grid"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Enters the diagram once, on the first node (or the selected one)"
        },
        {
          "keys": "ArrowDown / ArrowUp",
          "action": "Next or previous node in the same column"
        },
        {
          "keys": "ArrowRight / ArrowLeft",
          "action": "The nearest node in the next or previous column"
        },
        {
          "keys": "Enter / Space",
          "action": "Selects the node and shows its totals in a polite live region"
        },
        {
          "keys": "Screen readers",
          "action": "Each node reads 'Search: 12,400 in, 11,900 out'; every flow is also in a hidden table with From, To and Amount"
        }
      ],
      "customization": [
        "format",
        "group colours follow the order groups first appear (chart 1 to 5)",
        "layoutSankey is exported for custom drawing"
      ]
    },
    "statesNone": "A presentational chart: you pass the nodes and links in, so it has nothing of its own to load or fail.",
    "examples": [
      "flow-sankey-demo",
      "flow-sankey-agent"
    ],
    "url": "https://ui.ballmac.com/components/flow-sankey"
  },
  "categories": [
    "data-display"
  ],
  "type": "registry:ui"
}