{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sankey-diagram",
  "type": "registry:ui",
  "title": "Sankey Diagram",
  "description": "A Sankey flow diagram with an inline layout: hover or focus a node to trace its flows, keyboard navigation and animated data changes.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/sankey-diagram.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nexport interface SankeyNode {\n  id: string;\n  label: string;\n  /** Optional colour; otherwise the accent, then a categorical palette, in data order. */\n  color?: string;\n}\n\nexport interface SankeyLink {\n  source: string;\n  target: string;\n  value: number;\n}\n\nexport interface SankeyData {\n  nodes: SankeyNode[];\n  links: SankeyLink[];\n}\n\nexport type SankeyLinkColor = \"gradient\" | \"source\" | \"target\" | \"neutral\";\nexport type SankeyAlign = \"justify\" | \"left\";\n\nexport interface SankeyDiagramProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  data: SankeyData;\n  /** Height of the diagram in px; the width follows the container. */\n  height?: number;\n  /** Width of each node bar in px. */\n  nodeWidth?: number;\n  /** Vertical gap between nodes in a column, in px (shrinks if a column is crowded). */\n  nodePadding?: number;\n  /** How links are coloured: a source→target gradient, either end's colour, or neutral ink. */\n  linkColor?: SankeyLinkColor;\n  /** \"justify\" pushes nodes without outgoing links to the last column; \"left\" keeps them at their depth. */\n  align?: SankeyAlign;\n  /** Show each node's value beside its label. */\n  showValues?: boolean;\n  /** Colour of the first node; the rest use a categorical palette. */\n  accent?: string;\n  format?: (value: number) => string;\n  /** Accessible name of the diagram. */\n  label?: string;\n}\n\n/* ----------------------------- Reduced motion ------------------------------ */\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* --------------------------------- Layout ---------------------------------- */\n\ninterface LNode {\n  id: string;\n  label: string;\n  color: string;\n  index: number;\n  sourceLinks: LLink[];\n  targetLinks: LLink[];\n  value: number;\n  inflow: number;\n  outflow: number;\n  layer: number;\n  x0: number;\n  x1: number;\n  y0: number;\n  y1: number;\n}\n\ninterface LLink {\n  id: string;\n  source: LNode;\n  target: LNode;\n  value: number;\n  width: number;\n  /** Centre of the link's band where it leaves the source / enters the target. */\n  y0: number;\n  y1: number;\n}\n\ninterface Layout {\n  nodes: LNode[];\n  links: LLink[];\n  columns: LNode[][];\n  /** Flow leaving the nodes that have no inputs. */\n  total: number;\n}\n\nconst PALETTE = [\"var(--sk-accent)\", \"#3d8bea\", \"#22b07d\", \"#f0b429\", \"#e87ba4\", \"#8b7cf0\", \"#4fb9c9\", \"#a3a3a3\"];\nconst ITERATIONS = 32;\n\nconst centre = (n: LNode) => (n.y0 + n.y1) / 2;\n\n/**\n * Sankey layout, implemented inline:\n * 1. drop invalid links and any link that would close a cycle (found by DFS);\n * 2. place nodes in columns by longest path from a source (sinks pushed right when justified);\n * 3. scale values so the fullest column fits the height; stack and spread each column;\n * 4. relax: repeatedly move nodes toward the value-weighted centre of their neighbours,\n *    sweeping right-to-left and left-to-right, resolving overlaps after each column;\n * 5. order each node's links by the position of the other end and stack their bands.\n */\nfunction layoutSankey(data: SankeyData, width: number, height: number, nodeWidth: number, nodePadding: number, align: SankeyAlign): Layout {\n  const byId = new Map<string, LNode>();\n  data.nodes.forEach((n, index) => {\n    if (byId.has(n.id)) return;\n    byId.set(n.id, {\n      id: n.id,\n      label: n.label,\n      color: n.color ?? PALETTE[index % PALETTE.length],\n      index,\n      sourceLinks: [],\n      targetLinks: [],\n      value: 0,\n      inflow: 0,\n      outflow: 0,\n      layer: 0,\n      x0: 0,\n      x1: 0,\n      y0: 0,\n      y1: 0,\n    });\n  });\n  let nodes = [...byId.values()];\n\n  // Valid links only; parallel links between the same pair get distinct ids.\n  const seen = new Map<string, number>();\n  const candidates: LLink[] = [];\n  for (const l of data.links) {\n    const source = byId.get(l.source);\n    const target = byId.get(l.target);\n    if (!source || !target || source === target || !(l.value > 0)) continue;\n    const key = `${l.source}→${l.target}`;\n    const dup = seen.get(key) ?? 0;\n    seen.set(key, dup + 1);\n    candidates.push({ id: dup ? `${key}#${dup}` : key, source, target, value: l.value, width: 0, y0: 0, y1: 0 });\n  }\n  const outgoing = new Map<LNode, LLink[]>(nodes.map((n) => [n, []]));\n  for (const l of candidates) outgoing.get(l.source)!.push(l);\n\n  // DFS: a link back to a node still on the stack would close a cycle — leave it out.\n  const state = new Map<LNode, 1 | 2>();\n  const order: LNode[] = [];\n  const cyclic = new Set<LLink>();\n  const visit = (n: LNode) => {\n    state.set(n, 1);\n    for (const l of outgoing.get(n)!) {\n      const s = state.get(l.target);\n      if (s === 1) cyclic.add(l);\n      else if (!s) visit(l.target);\n    }\n    state.set(n, 2);\n    order.push(n);\n  };\n  nodes.forEach((n) => !state.has(n) && visit(n));\n  const links = candidates.filter((l) => !cyclic.has(l));\n  for (const l of links) {\n    l.source.sourceLinks.push(l);\n    l.target.targetLinks.push(l);\n  }\n  // Nodes without any remaining link carry no flow: leave them out of the layout.\n  nodes = nodes.filter((n) => n.sourceLinks.length || n.targetLinks.length);\n  if (!nodes.length) return { nodes, links, columns: [], total: 0 };\n\n  // Columns by longest path from a source, in topological order.\n  let maxLayer = 0;\n  for (const n of order.reverse()) {\n    n.layer = n.targetLinks.reduce((m, l) => Math.max(m, l.source.layer + 1), 0);\n    maxLayer = Math.max(maxLayer, n.layer);\n  }\n  if (align === \"justify\") for (const n of nodes) if (!n.sourceLinks.length && n.targetLinks.length) n.layer = maxLayer;\n\n  for (const n of nodes) {\n    n.inflow = n.targetLinks.reduce((s, l) => s + l.value, 0);\n    n.outflow = n.sourceLinks.reduce((s, l) => s + l.value, 0);\n    n.value = Math.max(n.inflow, n.outflow);\n  }\n  const columns: LNode[][] = Array.from({ length: maxLayer + 1 }, () => []);\n  for (const n of nodes) columns[n.layer].push(n);\n\n  const kx = maxLayer ? (width - nodeWidth) / maxLayer : 0;\n  for (const n of nodes) {\n    n.x0 = maxLayer ? n.layer * kx : (width - nodeWidth) / 2;\n    n.x1 = n.x0 + nodeWidth;\n  }\n\n  // Vertical scale: the fullest column (values + gaps) fits the height.\n  const crowded = Math.max(1, ...columns.map((c) => c.length));\n  const py = Math.max(2, Math.min(nodePadding, (height * 0.6) / Math.max(1, crowded - 1)));\n  const ky = Math.min(\n    ...columns.filter((c) => c.length).map((c) => (height - (c.length - 1) * py) / Math.max(1e-9, c.reduce((s, n) => s + n.value, 0))),\n  );\n  for (const l of links) l.width = l.value * ky;\n\n  const resolve = (col: LNode[]) => {\n    col.sort((a, b) => a.y0 - b.y0);\n    let y = 0;\n    for (const n of col) {\n      const dy = y - n.y0;\n      if (dy > 0) {\n        n.y0 += dy;\n        n.y1 += dy;\n      }\n      y = n.y1 + py;\n    }\n    y = height;\n    for (let i = col.length - 1; i >= 0; i--) {\n      const n = col[i];\n      const dy = n.y1 - y;\n      if (dy > 0) {\n        n.y0 -= dy;\n        n.y1 -= dy;\n      }\n      y = n.y0 - py;\n    }\n  };\n\n  // Initial stack, spreading each column's spare height evenly between its nodes.\n  for (const col of columns) {\n    let y = 0;\n    for (const n of col) {\n      n.y0 = y;\n      n.y1 = y + n.value * ky;\n      y = n.y1 + py;\n    }\n    const spare = (height - (y - py)) / (col.length + 1);\n    col.forEach((n, i) => {\n      n.y0 += spare * (i + 1);\n      n.y1 += spare * (i + 1);\n    });\n  }\n\n  const relax = (col: LNode[], pick: (n: LNode) => LLink[], other: (l: LLink) => LNode, alpha: number) => {\n    for (const n of col) {\n      const ls = pick(n);\n      if (!ls.length) continue;\n      let sum = 0;\n      let weight = 0;\n      for (const l of ls) {\n        sum += centre(other(l)) * l.value;\n        weight += l.value;\n      }\n      const dy = (sum / weight - centre(n)) * alpha;\n      n.y0 += dy;\n      n.y1 += dy;\n    }\n    resolve(col);\n  };\n  for (let i = 0; i < ITERATIONS; i++) {\n    const alpha = Math.pow(0.99, i) * 0.9;\n    for (let c = columns.length - 2; c >= 0; c--) relax(columns[c], (n) => n.sourceLinks, (l) => l.target, alpha);\n    for (let c = 1; c < columns.length; c++) relax(columns[c], (n) => n.targetLinks, (l) => l.source, alpha);\n  }\n\n  // Stack link bands at each node, ordered by where their other end sits, to minimise crossings.\n  for (const n of nodes) {\n    n.sourceLinks.sort((a, b) => a.target.y0 - b.target.y0 || a.target.index - b.target.index);\n    n.targetLinks.sort((a, b) => a.source.y0 - b.source.y0 || a.source.index - b.source.index);\n    let y = n.y0;\n    for (const l of n.sourceLinks) {\n      l.y0 = y + l.width / 2;\n      y += l.width;\n    }\n    y = n.y0;\n    for (const l of n.targetLinks) {\n      l.y1 = y + l.width / 2;\n      y += l.width;\n    }\n  }\n\n  const total = nodes.filter((n) => !n.targetLinks.length).reduce((s, n) => s + n.outflow, 0);\n  for (const col of columns) col.sort((a, b) => a.y0 - b.y0);\n  return { nodes, links, columns, total };\n}\n\nconst r2 = (n: number) => Math.round(n * 100) / 100;\n\n/** A horizontal cubic Bézier from the source's right edge to the target's left edge. */\nfunction linkPath(l: LLink) {\n  const x0 = r2(l.source.x1);\n  const x1 = r2(l.target.x0);\n  const xm = r2((x0 + x1) / 2);\n  const y0 = r2(l.y0);\n  // A perfectly flat path has a zero-height bounding box, which hides bounding-box gradients.\n  const y1 = Math.abs(l.y1 - l.y0) < 0.01 ? y0 + 0.01 : r2(l.y1);\n  return `M${x0},${y0}C${xm},${y0} ${xm},${y1} ${x1},${y1}`;\n}\n\n/** Everything upstream and downstream of a node, or a single link and its two ends. */\nfunction flowOf(layout: Layout, active: { kind: \"node\" | \"link\"; id: string } | null) {\n  if (!active) return null;\n  const nodes = new Set<string>();\n  const links = new Set<string>();\n  if (active.kind === \"link\") {\n    const l = layout.links.find((x) => x.id === active.id);\n    if (l) {\n      links.add(l.id);\n      nodes.add(l.source.id).add(l.target.id);\n    }\n    return { nodes, links };\n  }\n  const start = layout.nodes.find((n) => n.id === active.id);\n  if (!start) return null;\n  const walk = (n: LNode, dir: \"up\" | \"down\") => {\n    nodes.add(n.id);\n    for (const l of dir === \"down\" ? n.sourceLinks : n.targetLinks) {\n      if (links.has(l.id)) continue;\n      links.add(l.id);\n      walk(dir === \"down\" ? l.target : l.source, dir);\n    }\n  };\n  walk(start, \"down\");\n  walk(start, \"up\");\n  return { nodes, links };\n}\n\nconst compact = new Intl.NumberFormat(\"en-US\", { notation: \"compact\", maximumFractionDigits: 1 });\nconst pct = (part: number, whole: number) => `${whole > 0 ? ((part / whole) * 100).toFixed(1) : \"0\"}%`;\n\n/* -------------------------------- Component -------------------------------- */\n\n/**\n * A Sankey flow diagram with an inline layout (columns by depth, iterative relaxation, link\n * widths by value, Bézier links). Hover or focus a node to trace everything flowing into and\n * out of it, or hover a link for its value. Every node is a tab stop; arrow keys move within\n * a column and follow the largest flow across columns. A visually hidden table lists every link.\n * Data changes animate nodes and links to their new places.\n */\nexport function SankeyDiagram({\n  data,\n  height = 380,\n  nodeWidth = 12,\n  nodePadding = 14,\n  linkColor = \"gradient\",\n  align = \"justify\",\n  showValues = true,\n  accent = \"#FF7448\",\n  format,\n  label = \"Flow diagram\",\n  className = \"\",\n  style,\n  ...props\n}: SankeyDiagramProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, \"\");\n  const hintId = `${uid}-hint`;\n  const boxRef = React.useRef<HTMLDivElement>(null);\n  const [width, setWidth] = React.useState(720);\n  const [active, setActive] = React.useState<{ kind: \"node\" | \"link\"; id: string } | null>(null);\n  const [tipHidden, setTipHidden] = React.useState(false);\n  const fmt = format ?? ((v: number) => compact.format(v));\n\n  // Animate only when the data changes — not on resizes or the first measurement.\n  const [prevData, setPrevData] = React.useState(data);\n  const [animating, setAnimating] = React.useState(false);\n  if (data !== prevData) {\n    setPrevData(data);\n    setAnimating(true);\n  }\n\n  React.useEffect(() => {\n    const box = boxRef.current;\n    if (!box) return;\n    const ro = new ResizeObserver((entries) => {\n      setWidth(Math.max(1, Math.round(entries[0].contentRect.width)));\n      setAnimating(false);\n    });\n    ro.observe(box);\n    return () => ro.disconnect();\n  }, []);\n\n  const W = Math.max(120, width);\n  const H = Math.max(80, height);\n  const nw = Math.max(2, nodeWidth);\n  const layout = layoutSankey(data, W, H, nw, Math.max(0, nodePadding), align);\n  const maxLayer = layout.columns.length - 1;\n  const flow = flowOf(layout, active);\n  const transition = animating && !reduceMotion ? { type: \"spring\" as const, stiffness: 140, damping: 22, mass: 1 } : { duration: 0 };\n\n  const focusNode = (id: string) => boxRef.current?.querySelector<SVGElement>(`[data-sk-node=\"${CSS.escape(id)}\"]`)?.focus();\n\n  const onNodeKeyDown = (event: React.KeyboardEvent, node: LNode) => {\n    const col = layout.columns[node.layer];\n    const i = col.indexOf(node);\n    let next: LNode | undefined;\n    if (event.key === \"ArrowDown\") next = col[i + 1];\n    else if (event.key === \"ArrowUp\") next = col[i - 1];\n    else if (event.key === \"ArrowRight\" || event.key === \"ArrowLeft\") {\n      const right = event.key === \"ArrowRight\";\n      // Follow the largest flow in that direction; otherwise jump to the nearest node in the next column.\n      const ls = right ? node.sourceLinks : node.targetLinks;\n      const best = [...ls].sort((a, b) => b.value - a.value)[0];\n      next = best ? (right ? best.target : best.source) : undefined;\n      if (!next) {\n        for (let c = node.layer + (right ? 1 : -1); c >= 0 && c <= maxLayer && !next; c += right ? 1 : -1) {\n          next = [...layout.columns[c]].sort((a, b) => Math.abs(centre(a) - centre(node)) - Math.abs(centre(b) - centre(node)))[0];\n        }\n      }\n    } else if (event.key === \"Home\") next = layout.columns.flat()[0];\n    else if (event.key === \"End\") next = layout.columns.flat().at(-1);\n    else if (event.key === \"Escape\" && active && !tipHidden) {\n      // Dismiss only the tooltip; let Escape through when there is nothing to close.\n      event.preventDefault();\n      event.stopPropagation();\n      setTipHidden(true);\n      return;\n    } else return;\n    event.preventDefault();\n    if (next) focusNode(next.id);\n  };\n\n  const show = (next: { kind: \"node\" | \"link\"; id: string } | null) => {\n    setActive(next);\n    setTipHidden(false);\n  };\n\n  const nodeName = (n: LNode) => {\n    const parts = [`${n.label}: ${fmt(n.value)}`];\n    if (n.targetLinks.length) parts.push(`in ${fmt(n.inflow)} from ${n.targetLinks.length} ${n.targetLinks.length === 1 ? \"source\" : \"sources\"}`);\n    if (n.sourceLinks.length) parts.push(`out ${fmt(n.outflow)} to ${n.sourceLinks.length} ${n.sourceLinks.length === 1 ? \"target\" : \"targets\"}`);\n    if (layout.total) parts.push(`${pct(n.value, layout.total)} of total flow`);\n    return parts.join(\", \");\n  };\n\n  const strokeFor = (l: LLink) =>\n    linkColor === \"gradient\" ? `url(#${uid}-g-${l.source.index}-${l.target.index})` : linkColor === \"source\" ? l.source.color : linkColor === \"target\" ? l.target.color : \"currentColor\";\n\n  /* --------------------------------- tooltip -------------------------------- */\n\n  const activeNode = active?.kind === \"node\" ? layout.nodes.find((n) => n.id === active.id) : undefined;\n  const activeLink = active?.kind === \"link\" ? layout.links.find((l) => l.id === active.id) : undefined;\n  // The tooltip sits above (or below) what it describes, so it never covers the node's label.\n  // A node as tall as the chart gets it beside instead.\n  type Tip = { title: string; lines: string[]; style: React.CSSProperties; beside: boolean };\n  const ROOM = 96;\n  const place = (cx: number, top: number, bottom: number, besideX: number, besideRight: boolean): Pick<Tip, \"style\" | \"beside\"> => {\n    const left = Math.min(W - 110, Math.max(110, cx));\n    if (top >= ROOM) return { style: { left, bottom: H - top + 8 }, beside: false };\n    if (H - bottom >= ROOM) return { style: { left, top: bottom + 8 }, beside: false };\n    const y = Math.min(H - 40, Math.max(40, (top + bottom) / 2));\n    return { style: besideRight ? { left: besideX + 10, top: y } : { right: W - besideX + 10, top: y }, beside: true };\n  };\n  let tip: Tip | null = null;\n  if (activeNode && !tipHidden) {\n    const right = activeNode.layer < maxLayer / 2 || maxLayer === 0;\n    tip = {\n      title: activeNode.label,\n      lines: [\n        activeNode.targetLinks.length ? `In · ${fmt(activeNode.inflow)}` : \"\",\n        activeNode.sourceLinks.length ? `Out · ${fmt(activeNode.outflow)}` : \"\",\n        layout.total ? `${pct(activeNode.value, layout.total)} of total` : \"\",\n      ].filter(Boolean),\n      ...place((activeNode.x0 + activeNode.x1) / 2, activeNode.y0, activeNode.y1, right ? activeNode.x1 : activeNode.x0, right),\n    };\n  } else if (activeLink && !tipHidden) {\n    const x = (activeLink.source.x1 + activeLink.target.x0) / 2;\n    const y = (activeLink.y0 + activeLink.y1) / 2;\n    tip = {\n      title: `${activeLink.source.label} → ${activeLink.target.label}`,\n      lines: [fmt(activeLink.value), `${pct(activeLink.value, activeLink.source.outflow)} of ${activeLink.source.label}`],\n      ...place(x, y - activeLink.width / 2, y + activeLink.width / 2, x, x < W / 2),\n    };\n  }\n\n  const dim = (on: boolean) => (flow ? (on ? 1 : 0.25) : 1);\n\n  return (\n    <div\n      className={`flex w-full min-w-0 flex-col gap-2 text-zinc-950 dark:text-zinc-50 ${className}`}\n      style={{ [\"--sk-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <div ref={boxRef} className=\"relative w-full\" style={{ height: H }}>\n        <svg\n          width={W}\n          height={H}\n          viewBox={`0 0 ${W} ${H}`}\n          role=\"group\"\n          aria-label={label}\n          aria-describedby={hintId}\n          className=\"block overflow-visible\"\n          onPointerLeave={(e) => e.pointerType === \"mouse\" && show(null)}\n        >\n          <defs>\n            {linkColor === \"gradient\" &&\n              layout.links.map((l) => (\n                <linearGradient key={l.id} id={`${uid}-g-${l.source.index}-${l.target.index}`}>\n                  <stop offset=\"0%\" stopColor={l.source.color} />\n                  <stop offset=\"100%\" stopColor={l.target.color} />\n                </linearGradient>\n              ))}\n          </defs>\n\n          <g fill=\"none\" className=\"text-zinc-400 dark:text-zinc-500\">\n            <AnimatePresence initial={false}>\n              {layout.links.map((l) => {\n                const on = !flow || flow.links.has(l.id);\n                return (\n                  <motion.path\n                    key={l.id}\n                    aria-hidden=\"true\"\n                    initial={{ opacity: 0 }}\n                    animate={{ d: linkPath(l), strokeWidth: r2(Math.max(1, l.width)), opacity: 1 }}\n                    exit={{ opacity: 0, transition: { duration: reduceMotion ? 0 : 0.2 } }}\n                    transition={transition}\n                    stroke={strokeFor(l)}\n                    strokeOpacity={flow ? (on ? 0.62 : 0.06) : linkColor === \"neutral\" ? 0.4 : 0.34}\n                    className=\"cursor-default transition-[stroke-opacity] duration-200 motion-reduce:transition-none\"\n                    onPointerEnter={(e) => e.pointerType === \"mouse\" && show({ kind: \"link\", id: l.id })}\n                    onClick={() => show(active?.kind === \"link\" && active.id === l.id ? null : { kind: \"link\", id: l.id })}\n                  />\n                );\n              })}\n            </AnimatePresence>\n          </g>\n\n          <g role=\"list\" aria-label={`${label} nodes`}>\n            <AnimatePresence initial={false}>\n              {layout.columns.flat().map((n) => {\n                const h = r2(Math.max(1, n.y1 - n.y0));\n                const labelRight = n.layer < maxLayer || maxLayer === 0;\n                return (\n                  <motion.g\n                    key={n.id}\n                    className=\"group\"\n                    initial={{ opacity: 0 }}\n                    animate={{ x: r2(n.x0), y: r2(n.y0), opacity: dim(!flow || flow.nodes.has(n.id)) }}\n                    exit={{ opacity: 0, transition: { duration: reduceMotion ? 0 : 0.2 } }}\n                    transition={transition}\n                  >\n                    {/* Focus ring, keyboard focus only. */}\n                    <motion.rect\n                      aria-hidden=\"true\"\n                      x={-3}\n                      y={-3}\n                      width={nw + 6}\n                      rx={4}\n                      initial={false}\n                      animate={{ height: r2(h + 6) }}\n                      transition={transition}\n                      fill=\"none\"\n                      strokeWidth={2}\n                      className=\"pointer-events-none stroke-zinc-950 opacity-0 group-has-[:focus-visible]:opacity-100 dark:stroke-white\"\n                    />\n                    <motion.rect\n                      data-sk-node={n.id}\n                      role=\"listitem\"\n                      aria-label={nodeName(n)}\n                      tabIndex={0}\n                      width={nw}\n                      rx={2}\n                      initial={false}\n                      animate={{ height: h }}\n                      transition={transition}\n                      fill={n.color}\n                      className=\"cursor-default outline-none\"\n                      onPointerEnter={(e) => e.pointerType === \"mouse\" && show({ kind: \"node\", id: n.id })}\n                      onClick={() => show(active?.kind === \"node\" && active.id === n.id ? null : { kind: \"node\", id: n.id })}\n                      onFocus={() => show({ kind: \"node\", id: n.id })}\n                      onBlur={() => show(null)}\n                      onKeyDown={(e) => onNodeKeyDown(e, n)}\n                    />\n                    <motion.text\n                      aria-hidden=\"true\"\n                      initial={false}\n                      animate={{ y: r2(h / 2) }}\n                      transition={transition}\n                      x={labelRight ? nw + 6 : -6}\n                      dy=\"0.35em\"\n                      textAnchor={labelRight ? \"start\" : \"end\"}\n                      className=\"pointer-events-none fill-current text-[11px] font-medium tracking-[-0.01em] [paint-order:stroke] [stroke-linejoin:round] [stroke-width:3px] stroke-white/70 dark:stroke-black/50\"\n                    >\n                      {n.label}\n                      {showValues && W >= 480 && (\n                        <tspan className=\"fill-zinc-500 font-mono text-[10px] font-normal dark:fill-zinc-400\" dx={5}>\n                          {fmt(n.value)}\n                        </tspan>\n                      )}\n                    </motion.text>\n                  </motion.g>\n                );\n              })}\n            </AnimatePresence>\n          </g>\n        </svg>\n\n        {tip && (\n          <div\n            aria-hidden=\"true\"\n            className={`pointer-events-none absolute z-10 w-max max-w-[220px] rounded-md border border-zinc-900/10 bg-white/95 px-2.5 py-2 shadow-[0_10px_30px_-12px_rgb(0_0_0/0.4)] dark:border-white/10 dark:bg-zinc-900/95 ${\n              tip.beside ? \"-translate-y-1/2\" : \"-translate-x-1/2\"\n            }`}\n            style={tip.style}\n          >\n            <span className=\"block text-sm font-semibold tracking-[-0.01em]\">{tip.title}</span>\n            {tip.lines.map((line) => (\n              <span key={line} className=\"mt-0.5 block font-mono text-[11px] tabular-nums text-zinc-600 dark:text-zinc-300\">\n                {line}\n              </span>\n            ))}\n          </div>\n        )}\n      </div>\n\n      <p id={hintId} className=\"sr-only\">\n        Tab or the arrow keys move between nodes: up and down within a column, left and right along the largest flow. Escape hides the details.\n      </p>\n      <table className=\"sr-only\">\n        <caption>{label}: links (source → target: value)</caption>\n        <thead>\n          <tr>\n            <th scope=\"col\">Source</th>\n            <th scope=\"col\">Target</th>\n            <th scope=\"col\">Value</th>\n          </tr>\n        </thead>\n        <tbody>\n          {layout.links.map((l) => (\n            <tr key={l.id}>\n              <td>{l.source.label}</td>\n              <td>{l.target.label}</td>\n              <td>{fmt(l.value)}</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/sankey-diagram"
}
