{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sankey-chart-card",
  "title": "Sankey Chart",
  "description": "Flow diagram with tinted links, node values, and sink share labels.",
  "registryDependencies": [
    "https://components.exe.xyz/r/chart-card.json"
  ],
  "files": [
    {
      "path": "src/components/charts/sankey-chart-card.tsx",
      "content": "import { useMemo, useState } from \"react\"\n\nimport {\n  ChartCard,\n  chartColor,\n  useActiveRange,\n} from \"@/components/charts/chart-card\"\n\nexport type SankeyNode = { name: string; color?: string }\nexport type SankeyLink = {\n  source: string | number\n  target: string | number\n  value: number\n}\n\nconst DEFAULT_NODES: SankeyNode[] = [\n  { name: \"Focus\" },\n  { name: \"Meetings\" },\n  { name: \"Breaks\" },\n  { name: \"Browsing\" },\n  { name: \"Writing\" },\n  { name: \"Email\", color: \"neutral\" },\n  { name: \"Coding\" },\n  { name: \"Calls\" },\n  { name: \"Idle\" },\n]\n\nconst DEFAULT_LINKS: SankeyLink[] = [\n  { source: \"Focus\", target: \"Browsing\", value: 16.2 },\n  { source: \"Focus\", target: \"Writing\", value: 11.6 },\n  { source: \"Focus\", target: \"Email\", value: 3.4 },\n  { source: \"Focus\", target: \"Coding\", value: 20.8 },\n  { source: \"Meetings\", target: \"Email\", value: 1.2 },\n  { source: \"Meetings\", target: \"Calls\", value: 16.8 },\n  { source: \"Breaks\", target: \"Idle\", value: 16 },\n]\n\nconst LAST_WEEK_LINKS: SankeyLink[] = [\n  { source: \"Focus\", target: \"Browsing\", value: 14.1 },\n  { source: \"Focus\", target: \"Writing\", value: 10.4 },\n  { source: \"Focus\", target: \"Email\", value: 4.8 },\n  { source: \"Focus\", target: \"Coding\", value: 18.2 },\n  { source: \"Meetings\", target: \"Email\", value: 2.1 },\n  { source: \"Meetings\", target: \"Calls\", value: 15.4 },\n  { source: \"Breaks\", target: \"Idle\", value: 14 },\n]\n\ntype SankeyRange = {\n  id: string\n  label: string\n  links: SankeyLink[]\n  nodes?: SankeyNode[]\n  delta?: number\n  headline?: number\n}\n\nconst DEFAULT_RANGES: SankeyRange[] = [\n  {\n    id: \"this-week\",\n    label: \"This week\",\n    links: DEFAULT_LINKS,\n    headline: 86,\n  },\n  {\n    id: \"last-week\",\n    label: \"Last week\",\n    links: LAST_WEEK_LINKS,\n    headline: 79,\n    delta: -4.2,\n  },\n]\n\nfunction resolveName(\n  ref: string | number,\n  nodes: SankeyNode[]\n): string | undefined {\n  if (typeof ref === \"number\") return nodes[ref]?.name\n  return ref\n}\n\nfunction hourFormat(n: number) {\n  const rounded = Math.round(n * 10) / 10\n  return Number.isInteger(rounded) ? `${rounded}h` : `${rounded.toFixed(1)}h`\n}\n\ntype LaidNode = {\n  name: string\n  color: string\n  value: number\n  x: number\n  y: number\n  height: number\n  layer: 0 | 1\n}\n\ntype LaidLink = {\n  source: string\n  target: string\n  value: number\n  color: string\n  path: string\n}\n\nfunction layoutSankey(\n  nodes: SankeyNode[],\n  links: SankeyLink[],\n  width: number,\n  height: number\n) {\n  const resolved = links\n    .map((link) => ({\n      source: resolveName(link.source, nodes),\n      target: resolveName(link.target, nodes),\n      value: link.value,\n    }))\n    .filter((link): link is { source: string; target: string; value: number } =>\n      Boolean(link.source && link.target && link.value > 0)\n    )\n\n  const outgoing = new Map<string, number>()\n  const incoming = new Map<string, number>()\n  for (const link of resolved) {\n    outgoing.set(link.source, (outgoing.get(link.source) ?? 0) + link.value)\n    incoming.set(link.target, (incoming.get(link.target) ?? 0) + link.value)\n  }\n\n  const sources = nodes.filter(\n    (node) =>\n      (outgoing.get(node.name) ?? 0) > 0 && (incoming.get(node.name) ?? 0) === 0\n  )\n  const sourceNames = new Set(sources.map((node) => node.name))\n  const sinks = nodes.filter((node) => !sourceNames.has(node.name))\n\n  const nodeW = 10\n  const gap = 10\n  const top = 8\n  const innerH = height - 28\n  const sourceTotal = sources.reduce(\n    (sum, node) => sum + (outgoing.get(node.name) ?? 0),\n    0\n  )\n  const sinkTotal = sinks.reduce(\n    (sum, node) => sum + (incoming.get(node.name) ?? 0),\n    0\n  )\n  const sourceAvail = innerH - gap * Math.max(0, sources.length - 1)\n  const sinkAvail = innerH - gap * Math.max(0, sinks.length - 1)\n  const sourceK = sourceTotal === 0 ? 0 : sourceAvail / sourceTotal\n  const sinkK = sinkTotal === 0 ? 0 : sinkAvail / sinkTotal\n\n  const laid: LaidNode[] = []\n  let y = top\n  sources.forEach((node, index) => {\n    const value = outgoing.get(node.name) ?? 0\n    const h = Math.max(8, value * sourceK)\n    laid.push({\n      name: node.name,\n      color:\n        node.color === \"neutral\"\n          ? \"var(--muted-foreground)\"\n          : chartColor(index, node.color),\n      value,\n      x: 88,\n      y,\n      height: h,\n      layer: 0,\n    })\n    y += h + gap\n  })\n  y = top\n  sinks.forEach((node, index) => {\n    const value = incoming.get(node.name) ?? 0\n    const h = Math.max(8, value * sinkK)\n    const color =\n      node.color === \"neutral\"\n        ? \"var(--muted-foreground)\"\n        : chartColor(sources.length + index, node.color)\n    laid.push({\n      name: node.name,\n      color,\n      value,\n      x: width - 88 - nodeW,\n      y,\n      height: h,\n      layer: 1,\n    })\n    y += h + gap\n  })\n\n  const byName = new Map(laid.map((node) => [node.name, node]))\n  const sourceOffset = new Map(laid.map((node) => [node.name, node.y]))\n  const targetOffset = new Map(laid.map((node) => [node.name, node.y]))\n  const sourceOrder = new Map(sources.map((node, index) => [node.name, index]))\n  const sinkOrder = new Map(sinks.map((node, index) => [node.name, index]))\n\n  const sorted = [...resolved].sort((a, b) => {\n    const s =\n      (sourceOrder.get(a.source) ?? 0) - (sourceOrder.get(b.source) ?? 0)\n    if (s !== 0) return s\n    return (sinkOrder.get(a.target) ?? 0) - (sinkOrder.get(b.target) ?? 0)\n  })\n\n  const sourceEnds = new Map<\n    string,\n    { target: string; value: number; color: string; y0: number; y1: number }[]\n  >()\n  for (const link of sorted) {\n    const source = byName.get(link.source)\n    if (!source) continue\n    const k = source.value === 0 ? 0 : source.height / source.value\n    const y0 = sourceOffset.get(link.source) ?? source.y\n    const y1 = y0 + link.value * k\n    sourceOffset.set(link.source, y1)\n    const color = source.color\n    const list = sourceEnds.get(link.source) ?? []\n    list.push({ target: link.target, value: link.value, color, y0, y1 })\n    sourceEnds.set(link.source, list)\n  }\n\n  const targetEnds = new Map<\n    string,\n    { source: string; value: number; y0: number; y1: number }[]\n  >()\n  const byTarget = [...resolved].sort((a, b) => {\n    const t = (sinkOrder.get(a.target) ?? 0) - (sinkOrder.get(b.target) ?? 0)\n    if (t !== 0) return t\n    return (sourceOrder.get(a.source) ?? 0) - (sourceOrder.get(b.source) ?? 0)\n  })\n  for (const link of byTarget) {\n    const target = byName.get(link.target)\n    if (!target) continue\n    const k = target.value === 0 ? 0 : target.height / target.value\n    const y0 = targetOffset.get(link.target) ?? target.y\n    const y1 = y0 + link.value * k\n    targetOffset.set(link.target, y1)\n    const list = targetEnds.get(link.target) ?? []\n    list.push({ source: link.source, value: link.value, y0, y1 })\n    targetEnds.set(link.target, list)\n  }\n\n  const laidLinks: LaidLink[] = []\n  for (const link of sorted) {\n    const source = byName.get(link.source)\n    const target = byName.get(link.target)\n    const sEnd = sourceEnds\n      .get(link.source)\n      ?.find((item) => item.target === link.target)\n    const tEnd = targetEnds\n      .get(link.target)\n      ?.find((item) => item.source === link.source)\n    if (!source || !target || !sEnd || !tEnd) continue\n    const x0 = source.x + nodeW\n    const x1 = target.x\n    const mx = (x0 + x1) / 2\n    const path = [\n      `M ${x0} ${sEnd.y0}`,\n      `C ${mx} ${sEnd.y0}, ${mx} ${tEnd.y0}, ${x1} ${tEnd.y0}`,\n      `L ${x1} ${tEnd.y1}`,\n      `C ${mx} ${tEnd.y1}, ${mx} ${sEnd.y1}, ${x0} ${sEnd.y1}`,\n      \"Z\",\n    ].join(\" \")\n    laidLinks.push({\n      source: link.source,\n      target: link.target,\n      value: link.value,\n      color: sEnd.color,\n      path,\n    })\n  }\n\n  return { nodes: laid, links: laidLinks, nodeW, sourceTotal, sinkTotal }\n}\n\nexport function SankeyChartCard({\n  title = \"Tracked time\",\n  nodes = DEFAULT_NODES,\n  links,\n  format = hourFormat,\n  axisLabels = [\"Tracked time\", \"Share of tracked time\"],\n  range,\n  ranges,\n}: {\n  title?: string\n  nodes?: SankeyNode[]\n  links?: SankeyLink[]\n  format?: (n: number) => string\n  axisLabels?: [string, string]\n  range?: string\n  ranges?: SankeyRange[]\n}) {\n  const usingCustom = ranges == null && links != null\n  const plotRanges = ranges ?? (usingCustom ? undefined : DEFAULT_RANGES)\n  const { id, setId, active } = useActiveRange<SankeyRange>(plotRanges, range)\n  const plotNodes = active?.nodes ?? nodes\n  const plotLinks = active?.links ?? links ?? DEFAULT_LINKS\n  const width = 420\n  const height = 248\n  const layout = useMemo(\n    () => layoutSankey(plotNodes, plotLinks, width, height),\n    [plotNodes, plotLinks]\n  )\n  const plotHeadline = active?.headline ?? layout.sourceTotal\n  const plotDelta = active?.delta\n  const [activeName, setActiveName] = useState<string | null>(null)\n  const sinkTotal = layout.sinkTotal || 1\n  const summary = `${title} ${format(plotHeadline)}. ${layout.links\n    .map((link) => `${link.source} to ${link.target} ${format(link.value)}`)\n    .join(\". \")}`\n\n  return (\n    <ChartCard\n      title={title}\n      headline={plotHeadline}\n      headlineFormat={format}\n      delta={plotDelta}\n      period={plotRanges ? undefined : \"This week\"}\n      ranges={plotRanges}\n      range={id}\n      onRangeChange={setId}\n      summary={summary}\n    >\n      <svg\n        role=\"img\"\n        aria-label={summary}\n        viewBox={`0 0 ${width} ${height}`}\n        className=\"h-60 w-full\"\n      >\n        {layout.links.map((link) => {\n          const related =\n            activeName == null ||\n            activeName === link.source ||\n            activeName === link.target\n          return (\n            <path\n              key={`${link.source}-${link.target}`}\n              d={link.path}\n              fill={link.color}\n              fillOpacity={related ? 0.42 : 0.08}\n            />\n          )\n        })}\n        {layout.nodes.map((node) => {\n          const dimmed = activeName != null && activeName !== node.name\n          const related = layout.links.some(\n            (link) =>\n              (link.source === node.name || link.target === node.name) &&\n              (activeName == null ||\n                link.source === activeName ||\n                link.target === activeName)\n          )\n          return (\n            <g\n              key={node.name}\n              opacity={dimmed && !related ? 0.35 : 1}\n              className=\"cursor-pointer\"\n              onMouseEnter={() => setActiveName(node.name)}\n              onMouseLeave={() => setActiveName(null)}\n            >\n              <rect\n                x={node.x}\n                y={node.y}\n                width={layout.nodeW}\n                height={node.height}\n                rx={1}\n                fill={\n                  node.color === \"var(--muted-foreground)\"\n                    ? \"var(--chart-5)\"\n                    : node.color\n                }\n              />\n              <text\n                x={node.layer === 0 ? node.x - 8 : node.x + layout.nodeW + 8}\n                y={node.y + node.height / 2 - 6}\n                textAnchor={node.layer === 0 ? \"end\" : \"start\"}\n                fill=\"var(--foreground)\"\n                fontSize={11}\n                fontFamily=\"var(--font-sans)\"\n                tabIndex={0}\n                role=\"button\"\n                aria-label={\n                  node.layer === 0\n                    ? `${node.name} ${format(node.value)}`\n                    : `${node.name} ${Math.round((node.value / sinkTotal) * 100)} percent`\n                }\n                onFocus={() => setActiveName(node.name)}\n                onBlur={() => setActiveName(null)}\n              >\n                {node.name}\n              </text>\n              <text\n                x={node.layer === 0 ? node.x - 8 : node.x + layout.nodeW + 8}\n                y={node.y + node.height / 2 + 8}\n                textAnchor={node.layer === 0 ? \"end\" : \"start\"}\n                fill=\"var(--muted-foreground)\"\n                fontSize={10}\n                fontFamily=\"var(--font-mono)\"\n              >\n                {node.layer === 0\n                  ? format(node.value)\n                  : `${Math.round((node.value / sinkTotal) * 100)}%`}\n              </text>\n            </g>\n          )\n        })}\n        <text\n          x={88}\n          y={height - 2}\n          fill=\"var(--muted-foreground)\"\n          fontSize={9}\n          fontFamily=\"var(--font-mono)\"\n        >\n          {axisLabels[0]}\n        </text>\n        <text\n          x={width - 88}\n          y={height - 2}\n          textAnchor=\"end\"\n          fill=\"var(--muted-foreground)\"\n          fontSize={9}\n          fontFamily=\"var(--font-mono)\"\n        >\n          {axisLabels[1]}\n        </text>\n      </svg>\n      <table className=\"sr-only\">\n        <caption>Flows</caption>\n        <thead>\n          <tr>\n            <th>Source</th>\n            <th>Target</th>\n            <th>Value</th>\n          </tr>\n        </thead>\n        <tbody>\n          {layout.links.map((link) => (\n            <tr key={`${link.source}-${link.target}`}>\n              <td>{link.source}</td>\n              <td>{link.target}</td>\n              <td>{format(link.value)}</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </ChartCard>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:block"
}