{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sankey-chart",
  "title": "Sankey Chart",
  "description": "A flow diagram with tinted links, colored node bars, and share labels, on shadcn's chart primitives.",
  "dependencies": [
    "recharts"
  ],
  "registryDependencies": [
    "chart"
  ],
  "files": [
    {
      "path": "registry/dashboardcn/ui/sankey-chart.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  Sankey,\n  type SankeyLinkProps,\n  type SankeyNode as RechartsSankeyNode,\n  type SankeyNodeProps,\n} from \"recharts\"\n\nimport { cn } from \"@/lib/utils\"\nimport { formatNumber } from \"@/registry/dashboardcn/lib/format\"\nimport {\n  ChartContainer,\n  ChartTooltip,\n  ChartTooltipContent,\n  type ChartConfig,\n} from \"@/components/ui/chart\"\n\nexport interface SankeyChartNode {\n  name: string\n  /** Any CSS color. Sources default to the palette in order; targets inherit the color of their largest incoming link. */\n  color?: string\n}\n\nexport interface SankeyChartLink {\n  /** Node name or index into `nodes`. */\n  source: number | string\n  /** Node name or index into `nodes`. */\n  target: number | string\n  value: number\n}\n\nexport interface SankeyChartProps\n  extends Omit<React.ComponentProps<typeof ChartContainer>, \"config\" | \"children\"> {\n  nodes: SankeyChartNode[]\n  links: SankeyChartLink[]\n  valueFormatter?: (value: number) => string\n  /** Append each node's share of the total flow to its label, e.g. \"Writing · 18%\". */\n  showShares?: boolean\n  showTooltip?: boolean\n  nodeWidth?: number\n  /** Vertical gap between nodes in the same column. */\n  nodePadding?: number\n  /** Fill opacity of the links. Hovered links are drawn at double this, up to 1. */\n  linkOpacity?: number\n  /** \"outside\" reserves a margin and puts labels left of sources and right of targets; \"inside\" draws them over the flow. */\n  labelPosition?: \"outside\" | \"inside\"\n  /** Palette cycled through source nodes without a color. Defaults to chart-1 through chart-5. */\n  colors?: string[]\n}\n\nconst DEFAULT_COLORS = [\n  \"var(--chart-1)\",\n  \"var(--chart-2)\",\n  \"var(--chart-3)\",\n  \"var(--chart-4)\",\n  \"var(--chart-5)\",\n]\n\n/** Approximate rendered width of a text-xs string, for reserving label margins. */\nconst CHAR_WIDTH = 6.4\nconst LABEL_GAP = 8\n\ninterface ResolvedNode {\n  name: string\n  color: string\n  /** Share of the total flow, as a fraction. */\n  share: number\n  /** Which side of the node bar the label is drawn on. */\n  side: \"left\" | \"right\"\n  /** Label text next to the node, share included. */\n  label: string\n  shareLabel: string\n}\n\ninterface ResolvedLink {\n  source: number\n  target: number\n  value: number\n}\n\nfunction round(n: number) {\n  return Math.round(n * 1000) / 1000\n}\n\nfunction slugify(name: string) {\n  return name.toLowerCase().replace(/[^a-z0-9]+/g, \"-\")\n}\n\nfunction SankeyChart({\n  nodes,\n  links,\n  valueFormatter = (value) => formatNumber(value),\n  showShares = true,\n  showTooltip = true,\n  nodeWidth = 12,\n  nodePadding = 16,\n  linkOpacity = 0.25,\n  labelPosition = \"outside\",\n  colors = DEFAULT_COLORS,\n  className,\n  ...props\n}: SankeyChartProps) {\n  const palette = colors.length ? colors : DEFAULT_COLORS\n  const hoverOpacity = Math.min(1, linkOpacity * 2)\n\n  const { data, margin, config } = React.useMemo(() => {\n    const indexByName = new Map(nodes.map((node, index) => [node.name, index]))\n    const resolveIndex = (ref: number | string) =>\n      typeof ref === \"number\"\n        ? Number.isInteger(ref) && ref >= 0 && ref < nodes.length\n          ? ref\n          : -1\n        : (indexByName.get(ref) ?? -1)\n\n    const resolvedLinks: ResolvedLink[] = links\n      .map((link) => ({\n        source: resolveIndex(link.source),\n        target: resolveIndex(link.target),\n        value: link.value,\n      }))\n      .filter(\n        (link) =>\n          link.source >= 0 &&\n          link.target >= 0 &&\n          link.source !== link.target &&\n          Number.isFinite(link.value) &&\n          link.value > 0\n      )\n\n    const incoming = nodes.map(() => 0)\n    const outgoing = nodes.map(() => 0)\n    const strongestSource = nodes.map<number | null>(() => null)\n    const strongestValue = nodes.map(() => 0)\n    for (const link of resolvedLinks) {\n      outgoing[link.source]! += link.value\n      incoming[link.target]! += link.value\n      if (link.value > strongestValue[link.target]!) {\n        strongestValue[link.target] = link.value\n        strongestSource[link.target] = link.source\n      }\n    }\n\n    const isRoot = nodes.map((_, index) => incoming[index] === 0)\n    const isLeaf = nodes.map((_, index) => outgoing[index] === 0)\n    const values = nodes.map((_, index) => Math.max(incoming[index]!, outgoing[index]!))\n    const total = values.reduce((sum, value, index) => (isRoot[index] ? sum + value : sum), 0)\n\n    // Roots cycle through the palette; everything else inherits its strongest source.\n    const resolvedColors: (string | undefined)[] = nodes.map(() => undefined)\n    let paletteIndex = 0\n    nodes.forEach((node, index) => {\n      if (node.color) resolvedColors[index] = node.color\n      else if (isRoot[index]) resolvedColors[index] = palette[paletteIndex++ % palette.length]\n    })\n    const colorOf = (index: number, seen: Set<number>): string => {\n      const known = resolvedColors[index]\n      if (known) return known\n      const source = strongestSource[index] ?? null\n      if (source !== null && !seen.has(index)) {\n        seen.add(index)\n        const inherited = colorOf(source, seen)\n        resolvedColors[index] = inherited\n        return inherited\n      }\n      const fallback = palette[paletteIndex++ % palette.length]!\n      resolvedColors[index] = fallback\n      return fallback\n    }\n\n    const resolvedNodes: ResolvedNode[] = nodes.map((node, index) => {\n      const share = total > 0 ? values[index]! / total : 0\n      const shareLabel = showShares\n        ? ` · ${formatNumber(share, { format: \"percent\", maximumFractionDigits: 0 })}`\n        : \"\"\n      const side: ResolvedNode[\"side\"] =\n        labelPosition === \"outside\"\n          ? isRoot[index] && !isLeaf[index]\n            ? \"left\"\n            : \"right\"\n          : isLeaf[index] && !isRoot[index]\n            ? \"left\"\n            : \"right\"\n      return {\n        name: node.name,\n        color: colorOf(index, new Set()),\n        share,\n        side,\n        label: node.name,\n        shareLabel,\n      }\n    })\n\n    const labelWidth = (node: ResolvedNode) =>\n      Math.ceil((node.label.length + node.shareLabel.length) * CHAR_WIDTH) + LABEL_GAP\n    const widest = (predicate: (index: number) => boolean) =>\n      resolvedNodes.reduce(\n        (max, node, index) =>\n          predicate(index) && values[index]! > 0 ? Math.max(max, labelWidth(node)) : max,\n        0\n      )\n    const margin =\n      labelPosition === \"outside\"\n        ? {\n            top: 4,\n            bottom: 4,\n            left: widest((index) => resolvedNodes[index]!.side === \"left\"),\n            right: widest((index) => resolvedNodes[index]!.side === \"right\"),\n          }\n        : { top: 4, bottom: 4, left: 4, right: 4 }\n\n    const config = Object.fromEntries(\n      resolvedNodes.map((node) => [slugify(node.name), { label: node.name, color: node.color }])\n    ) satisfies ChartConfig\n\n    return { data: { nodes: resolvedNodes, links: resolvedLinks }, margin, config }\n  }, [nodes, links, palette, showShares, labelPosition])\n\n  const renderNode = React.useCallback((props: SankeyNodeProps) => {\n    const { x, y, width, height, payload } = props\n    const node = payload as unknown as RechartsSankeyNode & ResolvedNode\n    if (!(node.value > 0) || !(height > 0)) return null\n    const radius = Math.min(3, width / 2, height / 2)\n    const labelX = node.side === \"left\" ? x - LABEL_GAP : x + width + LABEL_GAP\n    return (\n      <g data-slot=\"sankey-node\">\n        <rect\n          x={round(x)}\n          y={round(y)}\n          width={round(width)}\n          height={round(height)}\n          rx={round(radius)}\n          fill={node.color}\n        />\n        <text\n          x={round(labelX)}\n          y={round(y + height / 2)}\n          textAnchor={node.side === \"left\" ? \"end\" : \"start\"}\n          dominantBaseline=\"middle\"\n          className=\"fill-foreground text-xs\"\n        >\n          <tspan className=\"font-medium\">{node.label}</tspan>\n          {node.shareLabel ? (\n            <tspan className=\"fill-muted-foreground tabular-nums\">{node.shareLabel}</tspan>\n          ) : null}\n        </text>\n      </g>\n    )\n  }, [])\n\n  const renderLink = React.useCallback(\n    (props: SankeyLinkProps) => {\n      const {\n        sourceX,\n        sourceY,\n        sourceControlX,\n        targetX,\n        targetY,\n        targetControlX,\n        linkWidth,\n        payload,\n      } = props\n      const source = payload.source as RechartsSankeyNode & ResolvedNode\n      const half = Math.max(linkWidth, 0.5) / 2\n      const top = (yValue: number) => round(yValue - half)\n      const bottom = (yValue: number) => round(yValue + half)\n      const d = [\n        `M${round(sourceX)},${top(sourceY)}`,\n        `C${round(sourceControlX)},${top(sourceY)} ${round(targetControlX)},${top(targetY)} ${round(targetX)},${top(targetY)}`,\n        `L${round(targetX)},${bottom(targetY)}`,\n        `C${round(targetControlX)},${bottom(targetY)} ${round(sourceControlX)},${bottom(sourceY)} ${round(sourceX)},${bottom(sourceY)}`,\n        \"Z\",\n      ].join(\" \")\n      return (\n        <path\n          data-slot=\"sankey-link\"\n          d={d}\n          fill={source.color}\n          fillOpacity={linkOpacity}\n          stroke=\"none\"\n          className=\"transition-[fill-opacity] duration-150 hover:[fill-opacity:var(--sankey-link-hover-opacity)]\"\n          style={{ \"--sankey-link-hover-opacity\": hoverOpacity } as React.CSSProperties}\n        />\n      )\n    },\n    [linkOpacity, hoverOpacity]\n  )\n\n  return (\n    <ChartContainer\n      data-slot=\"sankey-chart\"\n      config={config}\n      className={cn(\"aspect-auto h-72 w-full\", className)}\n      {...props}\n    >\n      <Sankey\n        data={data}\n        nodeWidth={nodeWidth}\n        nodePadding={nodePadding}\n        linkCurvature={0.5}\n        margin={margin}\n        node={renderNode}\n        link={renderLink}\n      >\n        {showTooltip ? (\n          <ChartTooltip\n            cursor={false}\n            content={\n              <ChartTooltipContent\n                hideLabel\n                formatter={(value, name, item) => {\n                  // Links carry resolved source/target nodes; nodes carry their own meta.\n                  const raw = item.payload as Record<string, unknown> | undefined\n                  const source = raw?.source as (RechartsSankeyNode & ResolvedNode) | undefined\n                  const target = raw?.target as (RechartsSankeyNode & ResolvedNode) | undefined\n                  const isLink = typeof source === \"object\" && typeof target === \"object\"\n                  const label = isLink\n                    ? `${source.name} → ${target.name}`\n                    : String(raw?.name ?? name)\n                  const color = isLink ? source.color : (raw?.color as string | undefined)\n                  return (\n                    <div className=\"flex w-full items-center gap-2\">\n                      <span\n                        className=\"size-2 shrink-0 rounded-[2px]\"\n                        style={{ backgroundColor: color }}\n                      />\n                      <span className=\"text-muted-foreground flex-1\">{label}</span>\n                      <span className=\"font-mono font-medium tabular-nums\">\n                        {valueFormatter(Number(value))}\n                      </span>\n                    </div>\n                  )\n                }}\n              />\n            }\n          />\n        ) : null}\n      </Sankey>\n    </ChartContainer>\n  )\n}\n\nexport { SankeyChart }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/dashboardcn/lib/format.ts",
      "content": "export type NumberFormat = \"number\" | \"compact\" | \"currency\" | \"percent\"\n\nexport interface FormatNumberOptions {\n  format?: NumberFormat\n  /** ISO 4217 code, used when format is \"currency\". Defaults to USD. */\n  currency?: string\n  locale?: string\n  maximumFractionDigits?: number\n  /** Abbreviate large values, e.g. \"$158K\" instead of \"$158,143\". */\n  compact?: boolean\n}\n\nconst formatterCache = new Map<string, Intl.NumberFormat>()\n\nfunction getFormatter(locale: string, options: Intl.NumberFormatOptions) {\n  const key = locale + JSON.stringify(options)\n  let formatter = formatterCache.get(key)\n  if (!formatter) {\n    formatter = new Intl.NumberFormat(locale, options)\n    formatterCache.set(key, formatter)\n  }\n  return formatter\n}\n\n/**\n * Format a metric value for display.\n *\n * formatNumber(1234567)                          -> \"1,234,567\"\n * formatNumber(1234567, { format: \"compact\" })   -> \"1.2M\"\n * formatNumber(48.2, { format: \"currency\" })     -> \"$48.20\"\n * formatNumber(158143, { format: \"currency\", compact: true }) -> \"$158K\"\n * formatNumber(0.124, { format: \"percent\" })     -> \"12.4%\"\n */\nexport function formatNumber(\n  value: number,\n  {\n    format = \"number\",\n    currency = \"USD\",\n    locale = \"en-US\",\n    maximumFractionDigits,\n    compact = false,\n  }: FormatNumberOptions = {}\n): string {\n  if (!Number.isFinite(value)) return \"—\"\n\n  // Compact values keep three significant digits: $158K, $1.23M, 41.2K.\n  const compactOptions: Intl.NumberFormatOptions = compact\n    ? maximumFractionDigits === undefined\n      ? { notation: \"compact\", maximumSignificantDigits: 3 }\n      : { notation: \"compact\", maximumFractionDigits }\n    : {}\n  switch (format) {\n    case \"compact\":\n      return getFormatter(locale, {\n        notation: \"compact\",\n        maximumFractionDigits: maximumFractionDigits ?? 1,\n      }).format(value)\n    case \"currency\":\n      return getFormatter(locale, {\n        style: \"currency\",\n        currency,\n        maximumFractionDigits: maximumFractionDigits ?? 2,\n        ...compactOptions,\n      }).format(value)\n    case \"percent\":\n      return getFormatter(locale, {\n        style: \"percent\",\n        maximumFractionDigits: maximumFractionDigits ?? 1,\n      }).format(value)\n    default:\n      return getFormatter(locale, {\n        maximumFractionDigits: maximumFractionDigits ?? 0,\n        ...compactOptions,\n      }).format(value)\n  }\n}\n\n/**\n * Format a fractional change as a signed percentage.\n *\n * formatDelta(0.124)  -> \"+12.4%\"\n * formatDelta(-0.03)  -> \"-3.0%\"\n * formatDelta(0)      -> \"0.0%\"\n */\nexport function formatDelta(delta: number, locale = \"en-US\"): string {\n  if (!Number.isFinite(delta)) return \"—\"\n  return getFormatter(locale, {\n    style: \"percent\",\n    signDisplay: \"exceptZero\",\n    maximumFractionDigits: 1,\n    minimumFractionDigits: 1,\n  }).format(delta)\n}\n\n/**\n * Fractional change between two values. Returns 0 when there is no baseline.\n */\nexport function computeDelta(current: number, previous: number): number {\n  if (!previous) return 0\n  return (current - previous) / Math.abs(previous)\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}