{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "echarts-sankey-chart",
  "description": "Sankey chart component rendered with Apache ECharts",
  "dependencies": [
    "echarts",
    "motion"
  ],
  "registryDependencies": [
    "@evilcharts/echarts-chart",
    "@evilcharts/echarts-tooltip"
  ],
  "files": [
    {
      "path": "src/registry/charts/echarts-sankey-chart.tsx",
      "content": "\"use client\";\n\nimport {\n  resolveTooltipPosition,\n  roundnessClass,\n  tooltipIndicatorHtml,\n  tooltipRow,\n  tooltipVariantClass,\n  type TooltipPosition,\n  type TooltipRoundness,\n  type TooltipVariant,\n} from \"@/registry/ui/echarts-tooltip\";\nimport {\n  buildChartCss,\n  getColorsCount,\n  resolveColors,\n  withAlpha,\n  type ChartConfig,\n  type ResolvedColors,\n} from \"@/registry/ui/echarts-chart\";\nimport {\n  Children,\n  isValidElement,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type FC,\n  type ReactNode,\n} from \"react\";\nimport { TooltipComponent, type TooltipComponentOption } from \"echarts/components\";\nimport { SankeyChart, type SankeySeriesOption } from \"echarts/charts\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { CanvasRenderer } from \"echarts/renderers\";\nimport type { ComposeOption } from \"echarts/core\";\nimport * as echarts from \"echarts/core\";\n\n// Re-export the shared types that were previously declared inline here, so\n// existing consumers/examples keep importing them from the chart module.\nexport type { ChartConfig, TooltipPosition, TooltipRoundness, TooltipVariant };\n\n// Modular registration keeps the bundle lean — only the pieces this chart needs.\n// A sankey draws its own node/link geometry, so there is no grid, axis, or\n// dataZoom here; the tooltip is the one extra component. GraphicComponent is\n// deliberately NOT registered — this chart adds no raw graphic overlays.\necharts.use([SankeyChart, TooltipComponent, CanvasRenderer]);\n\ntype EChartsInstance = ReturnType<typeof echarts.init>;\n\n// The exact option surface this chart uses — a sankey series plus the tooltip.\n// Narrower than echarts' full EChartsOption, so a misspelled key fails the\n// compile instead of silently reaching setOption.\ntype EChartsOption = ComposeOption<SankeySeriesOption | TooltipComponentOption>;\n\n// Single-item views of the composed series' node/link arrays — the modular entry\n// points don't export the sankey node/edge item option types directly, so derive\n// them from the composed series to keep the builders fully type-checked.\ntype SankeyNodeItem = NonNullable<SankeySeriesOption[\"data\"]>[number];\ntype SankeyEdgeItem = NonNullable<SankeySeriesOption[\"links\"]>[number];\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Constants\n// ─────────────────────────────────────────────────────────────────────────────\n\n// Intro reveal — the diagram assembles itself column by column. Each node grows\n// out of its own vertical centre, then its outgoing bands draw toward the next\n// column, which pulls the eye along the flow. (Echarts' native sankey entrance is\n// a single clip rect sweeping across the whole diagram; it ignores the graph, so\n// bands appear before the nodes they leave. This one is driven frame by frame\n// instead — see the intro rAF effect.) Times are in milliseconds.\nconst INTRO_COLUMN_STAGGER = 130; // delay between one column and the next\nconst INTRO_NODE_GROW = 340; // a single node opening from its centre\nconst INTRO_LINK_DELAY = 90; // head start a column's nodes get over their bands\nconst INTRO_LINK_DRAW = 520; // a band drawing from its source to its target\nconst INTRO_FEATHER = 0.05; // softening on the growing/drawing edge, in gradient offset\nconst INTRO_NODE_SCALE_FROM = 0.8; // a node opens from this fraction of its height, not from nothing\nconst LOADING_ANIMATION_DURATION = 2000; // shimmer loop, in milliseconds\nconst DEFAULT_NODE_WIDTH = 10;\nconst DEFAULT_NODE_PADDING = 10;\nconst DEFAULT_LINK_CURVATURE = 0.5;\nconst DEFAULT_ITERATIONS = 32;\nconst GRAY = \"rgba(120, 120, 120, 1)\"; // fallback when a node has no resolved color\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Theme knobs — every opacity in the diagram draws from these. Base colors come\n// from the consumer's CSS tokens (resolved from the live DOM), so only the\n// opacity factors live here. `withAlpha` MULTIPLIES a token's own alpha, so a\n// translucent background/border token stays honest. Tune here, not inline.\n// ─────────────────────────────────────────────────────────────────────────────\nconst NODE_FILL_OPACITY = 1; // resting/selected node rectangle — the bold, opaque element (stroke analogue) reads solid at full opacity (bumped from Recharts fillOpacity 0.9)\nconst NODE_DIM_OPACITY = 0.3; // node not connected to the current selection (stroke-dim analogue — kept)\nconst LINK_FILL_OPACITY = 0.4; // resting link band (Recharts fillOpacity 0.4 — the translucent fill base, kept)\nconst LINK_DIM_OPACITY = 0.05; // link not touching the current selection — the translucent band (fill analogue) recedes further (halved from 0.1)\nconst LABEL_DIM_OPACITY = 0.3; // node label faded when its node is dimmed\nconst INSIDE_PLATE_ALPHA = 0.55; // inside-label plate fill, × background alpha (twin's white/50 · black/60 wash)\nconst INSIDE_RIM_WIDTH = 1; // colored rim around the inside-label plate, in pixels (matches the twin's 1px inset edge)\n\n// The loading skeleton is a fixed gray sankey swept by a shimmer band. Unlike the\n// area chart's clip window (fully transparent outside the sweep), the sankey keeps\n// a low BASE floor so the blocky node/link geometry stays legible between sweeps —\n// the bright band still rides across as an absolute-pixel gradient shared by nodes\n// and links, sine-feathered at its edges.\nconst LOADING_NODE_FLOOR = 0.1; // node fill outside the sweep, × foreground alpha\nconst LOADING_NODE_PEAK = 0.42; // node fill inside the sweep, × foreground alpha\nconst LOADING_LINK_FLOOR = 0.04; // link fill outside the sweep, × foreground alpha\nconst LOADING_LINK_PEAK = 0.16; // link fill inside the sweep, × foreground alpha\nconst LOADING_SHIMMER_BAND = 0.22; // sweep half-width, fraction of chart width\nconst LOADING_SHIMMER_FEATHER = 0.22; // eased edge softening of the sweep\n\n// Fixed skeleton graph — three columns, auto-laid-out by echarts. Values are\n// arbitrary; only the shape matters while loading.\nconst SKELETON_NODES = [\n  { name: \"s0\" },\n  { name: \"s1\" },\n  { name: \"s2\" },\n  { name: \"m0\" },\n  { name: \"m1\" },\n  { name: \"m2\" },\n  { name: \"e0\" },\n  { name: \"e1\" },\n];\nconst SKELETON_LINKS = [\n  { source: \"s0\", target: \"m0\", value: 8 },\n  { source: \"s0\", target: \"m1\", value: 5 },\n  { source: \"s1\", target: \"m1\", value: 7 },\n  { source: \"s1\", target: \"m2\", value: 4 },\n  { source: \"s2\", target: \"m1\", value: 5 },\n  { source: \"s2\", target: \"m2\", value: 6 },\n  { source: \"m0\", target: \"e0\", value: 7 },\n  { source: \"m1\", target: \"e0\", value: 9 },\n  { source: \"m1\", target: \"e1\", value: 6 },\n  { source: \"m2\", target: \"e1\", value: 8 },\n];\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Public types\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport type LinkVariant = \"gradient\" | \"solid\" | \"source\" | \"target\";\nexport type NodeLabelPosition = \"inside\" | \"outside\";\n// TooltipVariant and TooltipRoundness now live in @/registry/ui/echarts-tooltip and\n// are imported + re-exported at the top of this file.\n// Sankey has no directional draw-in — its entrance follows the graph, not an\n// axis: \"default\" plays the column cascade, \"none\" turns it off. Kept as a small\n// union for copy-paste parity with the other EvilCharts entrance off-switches.\nexport type SankeyAnimationType = \"none\" | \"default\";\n\n// ChartConfig (and its AtLeastOneThemeColor constraint) now lives in the shared\n// @/registry/ui/echarts-chart module and is imported + re-exported at the top.\n\n// A single flow node. `icon` mirrors the Recharts twin's data shape for source\n// compatibility, but canvas can't mount a React node, so it is not rendered.\nexport type SankeyNode = {\n  name: string;\n  icon?: ReactNode;\n};\n\n// A single directed flow. `source`/`target` are indices into `nodes`, matching\n// the Recharts Sankey data contract.\nexport type SankeyLink = {\n  source: number;\n  target: number;\n  value: number;\n};\n\nexport type SankeyData = {\n  nodes: SankeyNode[];\n  links: SankeyLink[];\n};\n\nexport interface EChartsSankeyChartProps {\n  data: SankeyData; // nodes + links rendered by the chart\n  config: ChartConfig; // node colors + labels keyed by node name\n  children: ReactNode; // composed parts — <Node>, <NodeLabel>, <Link>, <Tooltip>\n  className?: string; // extra classes for the chart container\n  nodeWidth?: number; // width of each node in pixels\n  nodePadding?: number; // vertical gap between nodes (echarts nodeGap)\n  linkCurvature?: number; // link curve amount, 0 (straight) to 1 (maximum)\n  iterations?: number; // layout iterations — higher is more accurate\n  // `sort` and `verticalAlign` mirror the Recharts twin's prop surface but have\n  // no ECharts sankey equivalent (the layout always sorts + distributes\n  // vertically). They are accepted and ignored; see the port notes.\n  sort?: boolean;\n  align?: \"left\" | \"justify\"; // horizontal node alignment (echarts nodeAlign)\n  verticalAlign?: \"justify\" | \"top\";\n  defaultSelectedNode?: string | null; // node selected on first render\n  onSelectionChange?: (selection: { dataKey: string; value: number } | null) => void; // fires when the selected node changes\n  isLoading?: boolean; // shows the animated loading skeleton\n  animation?: boolean; // master switch for the intro draw-in — false renders instantly\n  animationType?: SankeyAnimationType; // \"none\" disables the intro reveal\n  chartOptions?: Record<string, unknown>; // escape hatch merged over the built ECharts option\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Composible parts — DECLARATIVE CONFIG. Every part renders `null`; the root\n// walks `children` by reference (child.type === Node, …) to collect its props.\n// A sankey's nodes and links are intrinsic to its data, so <Node>/<Link> always\n// render — they only CONFIGURE the diagram. <NodeLabel> and <Tooltip> follow the\n// twin's presence semantics: omit them and that part does not render.\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface NodeProps {\n  radius?: number; // corner radius of node rectangles in pixels\n  isClickable?: boolean; // lets nodes be selected by clicking them\n  children?: ReactNode; // optional <NodeLabel> composition\n}\n\n/**\n * Configures how the sankey nodes render. A configuration slot — the root reads\n * its props and wires them into the ECharts sankey series, so it renders nothing\n * itself. Compose a <NodeLabel> inside it to show labels.\n */\nconst Node: FC<NodeProps> = () => null;\n\nexport interface NodeLabelProps {\n  position?: NodeLabelPosition; // places labels inside or beside the nodes\n  showValues?: boolean; // appends each node's total flow value\n  valueFormatter?: (value: number) => string; // formats node values when shown\n}\n\n/**\n * Declares labels for the <Node> it is composed inside. Like <Node>, it is a\n * configuration slot and renders nothing on its own. With no `position`, no\n * labels show — matching the Recharts twin.\n */\nconst NodeLabel: FC<NodeLabelProps> = () => null;\n\nexport interface LinkProps {\n  variant?: LinkVariant; // coloring strategy for the link bands\n  verticalPadding?: number; // reserved for parity with the Recharts twin (see notes)\n}\n\n/**\n * Configures how the sankey links render. Like <Node>, it is a configuration slot\n * read by the root and renders nothing itself. The `variant` controls how each\n * link band is colored.\n */\nconst Link: FC<LinkProps> = () => null;\n\nexport interface TooltipProps {\n  variant?: TooltipVariant; // visual style of the tooltip surface\n  roundness?: TooltipRoundness; // border-radius of the tooltip\n  position?: TooltipPosition; // \"variable\" follows the pointer (default); \"fixed\" pins the tooltip near the top and tracks the pointer's X\n  defaultIndex?: number; // reserved for parity with the Recharts twin (see notes)\n}\n\n/** Presence enables the hover tooltip. Renders nothing. */\nconst Tooltip: FC<TooltipProps> = () => null;\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Children collection — walk the declarative config into plain objects the option\n// builder consumes. <NodeLabel> is read from the <Node>'s own children.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype NodeSlot = {\n  radius: number;\n  isClickable: boolean;\n};\ntype NodeLabelSlot = {\n  position?: NodeLabelPosition; // undefined → no labels, like the Recharts twin\n  showValues: boolean;\n  valueFormatter?: (value: number) => string;\n};\ntype LinkSlot = {\n  variant: LinkVariant;\n  verticalPadding: number;\n};\ntype TooltipSlot = {\n  present: boolean;\n  variant: TooltipVariant;\n  roundness: TooltipRoundness;\n  position: TooltipPosition;\n  defaultIndex?: number;\n};\n\ntype CollectedConfig = {\n  nodeConfig: NodeSlot;\n  nodeLabel: NodeLabelSlot | null;\n  linkConfig: LinkSlot;\n  tooltip: TooltipSlot;\n};\n\nfunction collectConfig(children: ReactNode): CollectedConfig {\n  let nodeConfig: NodeSlot = { radius: 0, isClickable: false };\n  let nodeLabel: NodeLabelSlot | null = null;\n  let linkConfig: LinkSlot = { variant: \"gradient\", verticalPadding: 0 };\n  let tooltip: TooltipSlot = {\n    present: false,\n    variant: \"default\",\n    roundness: \"lg\",\n    position: \"variable\",\n  };\n\n  Children.forEach(children, (child) => {\n    if (!isValidElement(child)) return;\n    const type = child.type;\n\n    if (type === Node) {\n      const props = child.props as NodeProps;\n      nodeConfig = {\n        radius: props.radius ?? 0,\n        isClickable: props.isClickable ?? false,\n      };\n      Children.forEach(props.children, (labelChild) => {\n        if (isValidElement(labelChild) && labelChild.type === NodeLabel) {\n          const lp = labelChild.props as NodeLabelProps;\n          nodeLabel = {\n            position: lp.position,\n            showValues: lp.showValues ?? false,\n            valueFormatter: lp.valueFormatter,\n          };\n        }\n      });\n    } else if (type === Link) {\n      const props = child.props as LinkProps;\n      linkConfig = {\n        variant: props.variant ?? \"gradient\",\n        verticalPadding: props.verticalPadding ?? 0,\n      };\n    } else if (type === Tooltip) {\n      const props = child.props as TooltipProps;\n      tooltip = {\n        present: true,\n        variant: props.variant ?? \"default\",\n        roundness: props.roundness ?? \"lg\",\n        position: props.position ?? \"variable\",\n        defaultIndex: props.defaultIndex,\n      };\n    }\n  });\n\n  return { nodeConfig, nodeLabel, linkConfig, tooltip };\n}\n\n// Color plumbing (ChartConfig, getColorsCount, distributeColors, buildChartCss,\n// normalizeColor, withAlpha, ResolvedColors, resolveColors) now lives in\n// @/registry/ui/echarts-chart and is imported at the top of this file. `resolveColors`\n// falls back to GRAY (rgba(120, 120, 120, 1)) for an unresolved node slot, matching\n// this file's GRAY constant.\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Paint helpers — the ECharts analogue of the Recharts SVG paints. Node fills are\n// a vertical gradient of the node's colors; link fills follow the <Link> variant.\n// ─────────────────────────────────────────────────────────────────────────────\n\n// A node's fill: a vertical multi-stop gradient of its color slots (top → bottom),\n// or a solid color when it has only one. Mirrors the twin's `NodeColorGradients`,\n// which paints each node with a `y1=0 → y2=1` linear gradient.\nfunction nodeGradient(slots: string[]): string | echarts.graphic.LinearGradient {\n  if (slots.length <= 1) return slots[0] ?? GRAY;\n  const stops = slots.map((color, i) => ({ offset: i / (slots.length - 1), color }));\n  return new echarts.graphic.LinearGradient(0, 0, 0, 1, stops);\n}\n\n// A link band's fill for a given variant. `gradient` bakes the twin's 0.2/0.5/0.2\n// source→target stop alphas into the color; `source`/`target` reuse the node's\n// vertical gradient; `solid` is the foreground token. The connected/dimmed alpha\n// is applied separately as `lineStyle.opacity`, matching the twin's `fillOpacity`.\nfunction edgeColor(\n  variant: LinkVariant,\n  sourceSlots: string[],\n  targetSlots: string[],\n  foreground: string,\n): string | echarts.graphic.LinearGradient {\n  switch (variant) {\n    case \"gradient\": {\n      const source = sourceSlots[0] ?? GRAY;\n      const target = targetSlots[0] ?? GRAY;\n      return new echarts.graphic.LinearGradient(0, 0, 1, 0, [\n        { offset: 0, color: withAlpha(source, 0.2) },\n        { offset: 0.5, color: withAlpha(source, 0.5) },\n        { offset: 1, color: withAlpha(target, 0.2) },\n      ]);\n    }\n    case \"source\":\n      return nodeGradient(sourceSlots);\n    case \"target\":\n      return nodeGradient(targetSlots);\n    case \"solid\":\n    default:\n      return foreground;\n  }\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Intro reveal — timing and paint\n//\n// The entrance is a windowed alpha on the SAME paint the element already uses:\n// a node's fill runs vertically, so a window opening from offset 0.5 makes it\n// grow from its centre; a link's gradient runs horizontally across its own\n// bounding box — which spans exactly source edge → target edge — so a window\n// sweeping 0 → 1 makes the band draw out of its source node. Nothing about the\n// layout moves, so no frame re-runs the sankey solver on different geometry.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype Paint = string | echarts.graphic.LinearGradient;\ntype IntroState = {\n  elapsed: number; // milliseconds since the intro started\n  depths: Record<string, number>; // node name → column index\n};\n\n// Column index per node: the longest path from any source, which is what puts a\n// node in a later column than every node feeding it. Edges are relaxed until the\n// pass stops changing anything; the node-count cap keeps a malformed (cyclic)\n// graph from spinning forever.\nfunction computeNodeDepths(data: SankeyData): Record<string, number> {\n  const nameOf = (ref: number) => data.nodes[ref]?.name ?? String(ref);\n  const depths: Record<string, number> = {};\n  for (const node of data.nodes) depths[node.name] = 0;\n\n  for (let pass = 0; pass < data.nodes.length; pass++) {\n    let changed = false;\n    for (const link of data.links) {\n      const source = nameOf(link.source);\n      const target = nameOf(link.target);\n      if (depths[target] === undefined || depths[source] === undefined) continue;\n      if (depths[target] < depths[source] + 1) {\n        depths[target] = depths[source] + 1;\n        changed = true;\n      }\n    }\n    if (!changed) break;\n  }\n  return depths;\n}\n\n// How long the whole cascade runs: whichever finishes last, the finalmost column\n// of nodes or the bands leaving the column before it.\nfunction introDuration(depths: Record<string, number>): number {\n  const maxDepth = Math.max(0, ...Object.values(depths));\n  return Math.max(\n    maxDepth * INTRO_COLUMN_STAGGER + INTRO_NODE_GROW,\n    Math.max(0, maxDepth - 1) * INTRO_COLUMN_STAGGER + INTRO_LINK_DELAY + INTRO_LINK_DRAW,\n  );\n}\n\nconst clamp01 = (value: number) => (value < 0 ? 0 : value > 1 ? 1 : value);\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n\n// 0 → 1 for one node's grow, and for one link's draw. Both key off the source\n// node's column, so a band never starts before the node it leaves.\nfunction nodePhase(intro: IntroState, name: string): number {\n  const start = (intro.depths[name] ?? 0) * INTRO_COLUMN_STAGGER;\n  return easeOut(clamp01((intro.elapsed - start) / INTRO_NODE_GROW));\n}\nfunction linkPhase(intro: IntroState, sourceName: string): number {\n  const start = (intro.depths[sourceName] ?? 0) * INTRO_COLUMN_STAGGER + INTRO_LINK_DELAY;\n  return easeOut(clamp01((intro.elapsed - start) / INTRO_LINK_DRAW));\n}\n\n// The axis a paint runs along — \"y\" for a node's vertical gradient, \"x\" for a\n// link's horizontal one, null for a flat color (which composes with either).\nfunction paintAxis(paint: Paint): \"x\" | \"y\" | null {\n  if (typeof paint === \"string\") return null;\n  const horizontal = Math.abs((paint.x2 ?? 0) - (paint.x ?? 0));\n  const vertical = Math.abs((paint.y2 ?? 0) - (paint.y ?? 0));\n  return horizontal >= vertical ? \"x\" : \"y\";\n}\n\nfunction paintStops(paint: Paint): { offset: number; color: string }[] {\n  if (typeof paint === \"string\") {\n    return [\n      { offset: 0, color: paint },\n      { offset: 1, color: paint },\n    ];\n  }\n  const stops = paint.colorStops ?? [];\n  if (stops.length === 0) return [{ offset: 0, color: GRAY }];\n  return stops.map((stop) => ({ offset: stop.offset, color: stop.color }));\n}\n\n// The paint's color at an arbitrary offset, so a window edge inserted between two\n// stops keeps the hue it interrupts.\nfunction sampleStops(stops: { offset: number; color: string }[], at: number): string {\n  const first = stops[0];\n  const last = stops[stops.length - 1];\n  if (!first) return GRAY;\n  if (at <= first.offset) return first.color;\n  if (at >= last.offset) return last.color;\n  for (let i = 1; i < stops.length; i++) {\n    const from = stops[i - 1];\n    const to = stops[i];\n    if (at > to.offset) continue;\n    const span = to.offset - from.offset;\n    if (span <= 1e-6) return to.color;\n    return echarts.color.lerp((at - from.offset) / span, [from.color, to.color]) || from.color;\n  }\n  return last.color;\n}\n\n// Multiply a paint's alpha by a trapezoid window along `axis`: transparent before\n// `edges[0]`, opaque between `edges[1]` and `edges[2]`, transparent again after\n// `edges[3]`. Returns null when the paint runs along the OTHER axis with real\n// color variation — two axes can't be composed into one canvas gradient, so the\n// caller falls back to a plain fade for that (rare) case.\nfunction windowedPaint(\n  paint: Paint,\n  axis: \"x\" | \"y\",\n  edges: [number, number, number, number],\n): Paint | null {\n  const own = paintAxis(paint);\n  if (own !== null && own !== axis) return null;\n\n  const stops = paintStops(paint);\n  const alphaAt = (offset: number) => {\n    if (offset <= edges[0] || offset >= edges[3]) return 0;\n    if (offset >= edges[1] && offset <= edges[2]) return 1;\n    if (offset < edges[1]) return (offset - edges[0]) / Math.max(1e-6, edges[1] - edges[0]);\n    return (edges[3] - offset) / Math.max(1e-6, edges[3] - edges[2]);\n  };\n\n  const offsets = [...new Set([0, 1, ...stops.map((stop) => stop.offset), ...edges])]\n    .filter((offset) => offset >= 0 && offset <= 1)\n    .sort((a, b) => a - b);\n  const windowed = offsets.map((offset) => ({\n    offset,\n    color: withAlpha(sampleStops(stops, offset), alphaAt(offset)),\n  }));\n\n  return axis === \"x\"\n    ? new echarts.graphic.LinearGradient(0, 0, 1, 0, windowed)\n    : new echarts.graphic.LinearGradient(0, 0, 0, 1, windowed);\n}\n\n// A node scaling up about its own centre: it starts at INTRO_NODE_SCALE_FROM of\n// full height and opens to 1 — a short pop rather than a wipe from nothing. The\n// fade-in rides the same window (applied by the caller as itemStyle.opacity), so\n// the box scales and lightens together.\nfunction growPaint(paint: Paint, phase: number): Paint | null {\n  const half = (INTRO_NODE_SCALE_FROM + (1 - INTRO_NODE_SCALE_FROM) * phase) / 2;\n  return windowedPaint(paint, \"y\", [\n    0.5 - half - INTRO_FEATHER,\n    0.5 - half,\n    0.5 + half,\n    0.5 + half + INTRO_FEATHER,\n  ]);\n}\n\n// A band drawing from its source edge toward its target edge.\nfunction drawPaint(paint: Paint, phase: number): Paint | null {\n  const head = phase * (1 + INTRO_FEATHER);\n  return windowedPaint(paint, \"x\", [-2, -1, head - INTRO_FEATHER, head]);\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Selection helpers — a node click highlights the node plus its direct neighbors\n// and dims the rest, exactly like the Recharts twin's `isNodeConnected`.\n// ─────────────────────────────────────────────────────────────────────────────\n\n// The selected node plus every node one link away from it.\nfunction connectedNodeSet(data: SankeyData, selected: string): Set<string> {\n  const set = new Set<string>([selected]);\n  const selectedIdx = data.nodes.findIndex((node) => node.name === selected);\n  if (selectedIdx === -1) return set;\n\n  for (const link of data.links) {\n    if (link.source === selectedIdx) {\n      const name = data.nodes[link.target]?.name;\n      if (name) set.add(name);\n    } else if (link.target === selectedIdx) {\n      const name = data.nodes[link.source]?.name;\n      if (name) set.add(name);\n    }\n  }\n  return set;\n}\n\n// Each node's total flow: outgoing sum, falling back to incoming for leaf nodes —\n// the same value the twin surfaces in labels, the tooltip, and `onSelectionChange`.\nfunction computeNodeValues(data: SankeyData): Record<string, number> {\n  const values: Record<string, number> = {};\n  data.nodes.forEach((node, index) => {\n    let outgoing = 0;\n    let incoming = 0;\n    for (const link of data.links) {\n      if (link.source === index) outgoing += link.value;\n      if (link.target === index) incoming += link.value;\n    }\n    values[node.name] = outgoing > 0 ? outgoing : incoming;\n  });\n  return values;\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Loading skeleton helper — a hard clip window swept across the fixed skeleton.\n// `floor` keeps the geometry faintly visible between sweeps; `peak` is the bright\n// band. `center` may run outside [0, 1] so the window fully enters and exits.\n// ─────────────────────────────────────────────────────────────────────────────\n\nfunction shimmerWindowStops(center: number, color: string, floor: number, peak: number) {\n  const half = LOADING_SHIMMER_BAND;\n  const feather = LOADING_SHIMMER_FEATHER;\n\n  const alphaAt = (x: number) => {\n    const dist = Math.abs(x - center);\n    if (dist <= half - feather) return peak;\n    if (dist >= half) return floor;\n    // Sine-eased falloff — a linear ramp still reads as a hard cut.\n    const eased = Math.sin(((1 - (dist - (half - feather)) / feather) * Math.PI) / 2);\n    return floor + (peak - floor) * eased;\n  };\n\n  const offsets = [\n    0,\n    center - half,\n    center - half + feather,\n    center,\n    center + half - feather,\n    center + half,\n    1,\n  ]\n    .filter((x) => x >= 0 && x <= 1)\n    .sort((a, b) => a - b);\n\n  const stops: { offset: number; color: string }[] = [];\n  for (const offset of offsets) {\n    if (stops.length === 0 || offset - stops[stops.length - 1].offset > 1e-4) {\n      stops.push({ offset, color: withAlpha(color, alphaAt(offset)) });\n    }\n  }\n  return stops;\n}\n\n// Tooltip HTML primitives (roundnessClass, tooltipVariantClass, tooltipIndicatorHtml,\n// tooltipRow, resolveTooltipPosition, indicatorBackground) now live in\n// @/registry/ui/echarts-tooltip and are imported at the top. The tooltip DOM lives\n// inside `[data-chart={id}]`, so the injected `--color-*` vars and Tailwind classes\n// resolve directly (no color read).\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Option builders — pure functions from a snapshot context to ECharts option\n// fragments. The component reads its refs ONCE per build into this context;\n// nothing below touches React state or the chart instance, so each fragment can\n// be reasoned about (and tested) in isolation.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype OptionBuildContext = {\n  data: SankeyData;\n  config: ChartConfig;\n  nodeConfig: NodeSlot;\n  nodeLabel: NodeLabelSlot | null;\n  linkConfig: LinkSlot;\n  tooltipSlot: TooltipSlot;\n  selectedNode: string | null;\n  nodeWidth: number;\n  nodePadding: number;\n  linkCurvature: number;\n  iterations: number;\n  align: \"left\" | \"justify\";\n  isLoading: boolean;\n  resolved: ResolvedColors;\n  nodeValues: Record<string, number>;\n  outsideLabels: boolean; // reserves right padding for outside labels\n  intro: IntroState | null; // mid-entrance cascade, null once the diagram is fully drawn\n};\n\n// The node label config, shared by every node. Two-line rich text when values are\n// shown; per-node opacity (for selection dimming) is applied on the node items.\nfunction buildNodeLabel(ctx: OptionBuildContext): SankeySeriesOption[\"label\"] {\n  const { nodeLabel, config, nodeValues, resolved } = ctx;\n  const position = nodeLabel?.position;\n\n  // No <NodeLabel>, or one with no position, shows nothing — Recharts parity.\n  if (position !== \"inside\" && position !== \"outside\") return { show: false };\n\n  const { tokens } = resolved;\n  const inside = position === \"inside\";\n  const showValues = nodeLabel?.showValues ?? false;\n  const format = nodeLabel?.valueFormatter ?? ((value: number) => value.toLocaleString());\n\n  const labelOf = (name: string) => {\n    const label = config[name]?.label;\n    return typeof label === \"string\" ? label : name;\n  };\n\n  const formatter = (params: unknown): string => {\n    const name = String((params as { name?: string | number }).name ?? \"\");\n    const nameText = labelOf(name);\n    if (!showValues) return `{name|${nameText}}`;\n    return `{name|${nameText}}\\n{value|${format(nodeValues[name] ?? 0)}}`;\n  };\n\n  return {\n    show: true,\n    // Inside sits centered on the node; outside hangs to the node's right.\n    position: inside ? \"inside\" : \"right\",\n    align: inside ? \"center\" : \"left\",\n    formatter,\n    rich: {\n      name: {\n        color: tokens.foreground,\n        fontSize: inside ? 10 : 12,\n        fontWeight: 500,\n        lineHeight: 15,\n      },\n      value: {\n        color: withAlpha(tokens.foreground, inside ? 0.6 : 0.5),\n        fontFamily: \"monospace\",\n        fontSize: inside ? 11 : 12,\n        lineHeight: 15,\n      },\n    },\n    // No label-scoped backing box for inside labels: the whole node is rebuilt as\n    // the plate in buildSankeySeries (a full-rect background wash with the node's\n    // color showing only as a rounded rim), so the text sits centered directly on\n    // that plate — matching the twin, where the plate spans the entire node rect.\n  };\n}\n\nfunction buildSankeySeries(ctx: OptionBuildContext): SankeySeriesOption {\n  const {\n    config,\n    data,\n    nodeConfig,\n    linkConfig,\n    selectedNode,\n    nodeWidth,\n    nodePadding,\n    linkCurvature,\n    iterations,\n    align,\n    resolved,\n    outsideLabels,\n    intro,\n  } = ctx;\n  const { tokens, series: slotsByName } = resolved;\n  const hasSelection = selectedNode !== null;\n  const connected = hasSelection ? connectedNodeSet(data, selectedNode) : null;\n  // With inside labels the node is rebuilt as a card: a translucent background\n  // plate fills the whole rect and the node's own color/gradient shows only as a\n  // rounded rim (the colored card behind it — the __sankey-plate series — tints\n  // through the plate). Mirrors the twin's full-rect inset plate + 1px colored edge.\n  const insideLabels = ctx.nodeLabel?.position === \"inside\";\n\n  // Nodes with no incoming link start the diagram, so an outside label reads on\n  // their LEFT; anything downstream keeps its label on the right. Without the split\n  // every label sits to the right of its node, which drops the first column's text\n  // straight onto its own outgoing bands.\n  const targetNames = new Set(\n    data.links.map((link) => data.nodes[link.target]?.name ?? String(link.target)),\n  );\n\n  const nodes: SankeyNodeItem[] = data.nodes.map((node) => {\n    const slots = slotsByName[node.name] ?? [GRAY];\n    const dimmed = connected ? !connected.has(node.name) : false;\n    // Mid-intro the box scales up about its centre and fades in together; the\n    // label just fades with it (rich text has no partial reveal).\n    const phase = intro ? nodePhase(intro, node.name) : 1;\n    const fill = nodeGradient(slots);\n    const grown = phase < 1 ? growPaint(fill, phase) : fill;\n    const nodeAlpha = (dimmed ? NODE_DIM_OPACITY : NODE_FILL_OPACITY) * phase;\n\n    return {\n      name: node.name,\n      itemStyle: insideLabels\n        ? {\n            // Dark card: plate fill spans the full rect, color rides the rim only.\n            color: withAlpha(tokens.background, INSIDE_PLATE_ALPHA * phase),\n            borderColor: grown ?? fill,\n            borderWidth: INSIDE_RIM_WIDTH,\n            borderRadius: nodeConfig.radius,\n            opacity: (dimmed ? NODE_DIM_OPACITY : 1) * phase,\n          }\n        : {\n            color: grown ?? fill,\n            opacity: nodeAlpha,\n            borderWidth: 0,\n            borderRadius: nodeConfig.radius,\n          },\n      // Fade a node's own label with it when the selection dims it. An empty\n      // config label opts a node out entirely — a pass-through hub carries its\n      // total in the surrounding layout, not on the node.\n      label: {\n        ...(config[node.name]?.label === \"\" ? { show: false } : {}),\n        opacity: (dimmed ? LABEL_DIM_OPACITY : 1) * phase,\n        ...(outsideLabels && !targetNames.has(node.name)\n          ? { position: \"left\" as const, align: \"right\" as const }\n          : {}),\n      },\n    };\n  });\n\n  const links: SankeyEdgeItem[] = data.links.map((link) => {\n    const source = data.nodes[link.source]?.name ?? String(link.source);\n    const target = data.nodes[link.target]?.name ?? String(link.target);\n    const sourceSlots = slotsByName[source] ?? [GRAY];\n    const targetSlots = slotsByName[target] ?? [GRAY];\n    // Connected = nothing selected, or this link touches the selected node.\n    const isConnected = !hasSelection || source === selectedNode || target === selectedNode;\n    // Mid-intro the band is windowed along its own bounding box, so it draws out\n    // of the source node rather than fading in place.\n    const phase = intro ? linkPhase(intro, source) : 1;\n    const band = edgeColor(linkConfig.variant, sourceSlots, targetSlots, tokens.foreground);\n    const drawn = phase < 1 ? drawPaint(band, phase) : band;\n\n    return {\n      source,\n      target,\n      value: link.value,\n      lineStyle: {\n        color: drawn ?? band,\n        opacity: (isConnected ? LINK_FILL_OPACITY : LINK_DIM_OPACITY) * (drawn ? 1 : phase),\n      },\n    };\n  });\n\n  return {\n    id: \"__sankey\",\n    type: \"sankey\",\n    z: 3,\n    // Outside labels hang past the outermost columns on BOTH sides — reserve room.\n    left: outsideLabels ? 120 : 8,\n    right: outsideLabels ? 120 : 8,\n    top: 12,\n    bottom: 12,\n    nodeWidth,\n    nodeGap: nodePadding,\n    layoutIterations: iterations,\n    nodeAlign: align === \"left\" ? \"left\" : \"justify\",\n    draggable: false,\n    // The twin has no hover-dimming — hovering only shows the tooltip. `focus:\n    // \"none\"` keeps every element at full styling on hover (no adjacency blur).\n    emphasis: { focus: \"none\" },\n    lineStyle: { curveness: linkCurvature },\n    label: buildNodeLabel(ctx),\n    data: nodes,\n    links,\n  };\n}\n\n// The colored card drawn UNDER the inside-label plate. With inside labels the\n// real node's fill becomes a translucent background plate (see buildSankeySeries),\n// so this silent duplicate — identical layout, pixel-exact under the real nodes —\n// supplies the node's actual color/gradient behind that plate. The plate's\n// translucency lets this color tint through (a pale card in light, a dark card in\n// dark), matching the Recharts twin's colored rect beneath its white/black wash.\n// Returns null unless inside labels are active, so the extra series is only paid\n// for on demand.\nfunction buildInsidePlateSeries(ctx: OptionBuildContext): SankeySeriesOption | null {\n  const {\n    data,\n    nodeConfig,\n    nodeLabel,\n    selectedNode,\n    nodeWidth,\n    nodePadding,\n    linkCurvature,\n    iterations,\n    align,\n    resolved,\n    outsideLabels,\n    intro,\n  } = ctx;\n  if (nodeLabel?.position !== \"inside\") return null;\n\n  const { series: slotsByName } = resolved;\n  const hasSelection = selectedNode !== null;\n  const connected = hasSelection ? connectedNodeSet(data, selectedNode) : null;\n\n  const nodes: SankeyNodeItem[] = data.nodes.map((node) => {\n    const slots = slotsByName[node.name] ?? [GRAY];\n    const dimmed = connected ? !connected.has(node.name) : false;\n    // Grows in step with the real node above it — the card and its plate are one\n    // element to the eye, so they must open together.\n    const phase = intro ? nodePhase(intro, node.name) : 1;\n    const fill = nodeGradient(slots);\n    const grown = phase < 1 ? growPaint(fill, phase) : fill;\n    return {\n      name: node.name,\n      itemStyle: {\n        color: grown ?? fill,\n        opacity: (dimmed ? NODE_DIM_OPACITY : NODE_FILL_OPACITY) * phase,\n        borderWidth: 0,\n        borderRadius: nodeConfig.radius,\n      },\n      label: { show: false },\n    };\n  });\n\n  // Links exist only so the layout matches the main series pixel-exact; they are\n  // fully transparent here — the real bands are drawn by the __sankey series.\n  const links: SankeyEdgeItem[] = data.links.map((link) => ({\n    source: data.nodes[link.source]?.name ?? String(link.source),\n    target: data.nodes[link.target]?.name ?? String(link.target),\n    value: link.value,\n    lineStyle: { opacity: 0 },\n  }));\n\n  return {\n    id: \"__sankey-plate\",\n    type: \"sankey\",\n    z: 2, // below the real __sankey series (z: 3)\n    silent: true,\n    left: outsideLabels ? 120 : 8,\n    right: outsideLabels ? 120 : 8,\n    top: 12,\n    bottom: 12,\n    nodeWidth,\n    nodeGap: nodePadding,\n    layoutIterations: iterations,\n    nodeAlign: align === \"left\" ? \"left\" : \"justify\",\n    draggable: false,\n    emphasis: { disabled: true },\n    label: { show: false },\n    lineStyle: { curveness: linkCurvature },\n    data: nodes,\n    links,\n  };\n}\n\n// Tooltip HTML builder, closed over the build context. A sankey fires item events\n// for both nodes (`dataType: \"node\"`) and links (`dataType: \"edge\"`); the\n// formatter renders the right row for each.\nfunction createTooltipFormatter(ctx: OptionBuildContext) {\n  const { config, nodeValues, tooltipSlot } = ctx;\n\n  const labelOf = (name: string) => {\n    const label = config[name]?.label;\n    return typeof label === \"string\" ? label : name;\n  };\n  const colorsOf = (name: string) => (config[name] ? getColorsCount(config[name]) : 1);\n  // A sankey tooltip carries no axis title — each hovered node/link surfaces a\n  // single indicator+label+value row. The shared tooltipShell always renders a\n  // title slot, so the outer surface stays a chart-local, title-less wrapper\n  // (reusing the shared roundness/variant classes); the row itself is the shared\n  // tooltipRow with the shared indicator swatch and no per-row dim.\n  const wrap = (body: string) =>\n    `<div class=\"grid min-w-32 items-start gap-1.5 border border-border/50 px-2.5 py-1.5 text-xs shadow-xl ${roundnessClass[tooltipSlot.roundness]} ${tooltipVariantClass[tooltipSlot.variant]}\"><div class=\"grid gap-1.5\">${body}</div></div>`;\n\n  return (params: unknown): string => {\n    const p = params as {\n      dataType?: string;\n      name?: string;\n      data?: { source?: string | number; target?: string | number; value?: number };\n    };\n\n    if (p.dataType === \"edge\") {\n      const source = String(p.data?.source ?? \"\");\n      const target = String(p.data?.target ?? \"\");\n      const value = typeof p.data?.value === \"number\" ? p.data.value.toLocaleString() : \"\";\n      return wrap(\n        tooltipRow({\n          indicatorHtml: tooltipIndicatorHtml(source, colorsOf(source)),\n          labelText: `${labelOf(source)} → ${labelOf(target)}`,\n          valueText: value,\n          dimmed: \"\",\n        }),\n      );\n    }\n\n    const name = String(p.name ?? \"\");\n    const value = (nodeValues[name] ?? 0).toLocaleString();\n    return wrap(\n      tooltipRow({\n        indicatorHtml: tooltipIndicatorHtml(name, colorsOf(name)),\n        labelText: labelOf(name),\n        valueText: value,\n        dimmed: \"\",\n      }),\n    );\n  };\n}\n\nfunction buildTooltipOption(ctx: OptionBuildContext): TooltipComponentOption {\n  const { tooltipSlot, isLoading } = ctx;\n  return {\n    show: tooltipSlot.present && !isLoading,\n    trigger: \"item\",\n    confine: true,\n    backgroundColor: \"transparent\",\n    borderWidth: 0,\n    padding: 0,\n    extraCssText: \"box-shadow:none;\",\n    displayTransition: false,\n    // \"variable\" (default) keeps ECharts' item-follow position — the current\n    // behavior; \"fixed\" pins the tooltip near the top and tracks only the\n    // pointer's X. The sankey tooltip is item-triggered (nodes/links, no axis),\n    // so it wires the position directly through resolveTooltipPosition rather\n    // than tooltipBaseOption, which is trigger:\"axis\" only.\n    position: resolveTooltipPosition(tooltipSlot.position),\n    formatter: createTooltipFormatter(ctx),\n  };\n}\n\n// Loading skeleton — a fixed gray sankey, invisible until the first shimmer tick\n// tints it. Node fills and link fills are set to fully-transparent foreground so\n// there is no flash before the rAF loop positions the sweep.\nfunction buildLoadingOption(ctx: OptionBuildContext): EChartsOption {\n  const { resolved } = ctx;\n  const transparent = withAlpha(resolved.tokens.foreground, 0);\n\n  return {\n    animation: false,\n    tooltip: { show: false },\n    series: [\n      {\n        id: \"__loading\",\n        type: \"sankey\",\n        left: 12,\n        right: 12,\n        top: 12,\n        bottom: 12,\n        nodeWidth: DEFAULT_NODE_WIDTH,\n        nodeGap: DEFAULT_NODE_PADDING,\n        layoutIterations: DEFAULT_ITERATIONS,\n        draggable: false,\n        silent: true,\n        emphasis: { disabled: true },\n        label: { show: false },\n        itemStyle: { color: transparent, borderWidth: 0 },\n        lineStyle: { color: transparent, curveness: DEFAULT_LINK_CURVATURE },\n        data: SKELETON_NODES,\n        links: SKELETON_LINKS,\n      },\n    ],\n  };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Live imperative state — everything the ECharts event handlers, the shimmer rAF,\n// and the theme repush read or write OUTSIDE the React render cycle, grouped in\n// one ref-stable object. None of it is render output, which is why it is not\n// React state.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype LiveState = {\n  resolved: ResolvedColors | null; // colors read off the live DOM — feeds builds and the shimmer\n  hasRevealed: boolean; // the intro cascade already played on this chart instance\n  intro: IntroState | null; // current cascade frame, read by every build while it runs\n  // Latest callbacks/flags for the imperative ECharts click handler.\n  handlers: {\n    onSelectionChange?: (selection: { dataKey: string; value: number } | null) => void;\n    isNodeClickable: boolean;\n    nodeValues: Record<string, number>;\n  };\n  // Update-style re-push for paths that bypass React entirely (theme flips,\n  // resizes) — set by the sync effect.\n  repush: () => void;\n};\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Component\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Apache ECharts port of the EvilCharts sankey chart, exposing a compound-as-config\n * API so its JSX reads identically to the Recharts twin. The root owns the flow\n * data, selection state, the loading skeleton, and the intro reveal; the visual\n * parts — `<Node>`, `<NodeLabel>`, `<Link>`, `<Tooltip>` — are composed as\n * declarative children that render nothing. The root walks those children by\n * reference and drives a single imperative ECharts instance. Fully self-contained:\n * its only dependencies are `react`, `echarts`, and `motion`.\n */\nexport function EChartsSankeyChart({\n  data,\n  config,\n  children,\n  className,\n  nodeWidth = DEFAULT_NODE_WIDTH,\n  nodePadding = DEFAULT_NODE_PADDING,\n  linkCurvature = DEFAULT_LINK_CURVATURE,\n  iterations = DEFAULT_ITERATIONS,\n  align = \"justify\",\n  defaultSelectedNode = null,\n  onSelectionChange,\n  isLoading = false,\n  animation = true,\n  animationType = \"default\",\n  chartOptions,\n}: EChartsSankeyChartProps) {\n  const rawId = useId();\n  const chartId = `chart-${rawId.replace(/:/g, \"\")}`;\n\n  const containerRef = useRef<HTMLDivElement>(null);\n  const mountRef = useRef<HTMLDivElement>(null);\n  const echartsRef = useRef<EChartsInstance | null>(null);\n\n  // The single imperative surface (see LiveState). `resolved` lives here rather\n  // than in state: as state it would force an extra render pass and an effect\n  // whose only job is to push the option. Object identity is stable for the\n  // component's lifetime.\n  const live = useRef<LiveState>({\n    resolved: null,\n    hasRevealed: false,\n    intro: null,\n    handlers: {\n      onSelectionChange,\n      isNodeClickable: false,\n      nodeValues: {},\n    },\n    repush: () => {},\n  }).current;\n\n  const shouldReduceMotion = useReducedMotion();\n\n  const [selectedNode, setSelectedNode] = useState<string | null>(defaultSelectedNode);\n\n  // ── Declarative config, collected from children by reference ─────────────────\n  const collected = useMemo(() => collectConfig(children), [children]);\n  const { nodeConfig, nodeLabel, linkConfig, tooltip: tooltipSlot } = collected;\n\n  const nodeValues = useMemo(() => computeNodeValues(data), [data]);\n  const outsideLabels = nodeLabel?.position === \"outside\";\n\n  const css = useMemo(() => buildChartCss(chartId, config), [chartId, config]);\n\n  // Node names double as the color keys — resolve `--color-{name}-{n}` for each.\n  const nodeNames = useMemo(() => data.nodes.map((node) => node.name), [data]);\n\n  // Refresh the click handler's snapshot of the latest callbacks/flags every render.\n  live.handlers = {\n    onSelectionChange,\n    isNodeClickable: nodeConfig.isClickable,\n    nodeValues,\n  };\n\n  const toggleSelection = useCallback(\n    (name: string) => {\n      setSelectedNode((prev) => {\n        const next = prev === name ? null : name;\n        const { onSelectionChange: cb, nodeValues: values } = live.handlers;\n        cb?.(next === null ? null : { dataKey: next, value: values[next] ?? 0 });\n        return next;\n      });\n    },\n    [live],\n  );\n\n  // ── Option builder ───────────────────────────────────────────────────────────\n  // Thin orchestrator over the pure builders above: snapshot the imperative\n  // surface into an OptionBuildContext, then assemble.\n  const buildOption = useCallback((): EChartsOption => {\n    const resolved = live.resolved;\n    if (!resolved) return {};\n\n    const ctx: OptionBuildContext = {\n      data,\n      config,\n      nodeConfig,\n      nodeLabel,\n      linkConfig,\n      tooltipSlot,\n      selectedNode,\n      nodeWidth,\n      nodePadding,\n      linkCurvature,\n      iterations,\n      align,\n      isLoading,\n      resolved,\n      nodeValues,\n      outsideLabels,\n      intro: live.intro,\n    };\n\n    if (isLoading) return buildLoadingOption(ctx);\n\n    // Draw order, bottom → top: the colored card under inside-label plates, then\n    // the real sankey on top.\n    const series: SankeySeriesOption[] = [];\n    const plateSeries = buildInsidePlateSeries(ctx);\n    if (plateSeries) series.push(plateSeries);\n    series.push(buildSankeySeries(ctx));\n\n    return {\n      animation: false,\n      tooltip: buildTooltipOption(ctx),\n      series,\n    };\n  }, [\n    live,\n    data,\n    config,\n    nodeConfig,\n    nodeLabel,\n    linkConfig,\n    tooltipSlot,\n    selectedNode,\n    nodeWidth,\n    nodePadding,\n    linkCurvature,\n    iterations,\n    align,\n    isLoading,\n    nodeValues,\n    outsideLabels,\n  ]);\n\n  // ── Init + resize + theme observer (once) ────────────────────────────────────\n  useEffect(() => {\n    const mount = mountRef.current;\n    const container = containerRef.current;\n    if (!mount || !container) return;\n\n    const chart = echarts.init(mount);\n    echartsRef.current = chart;\n\n    const resizeObserver = new ResizeObserver(() => {\n      // Observers always fire once right after observe(). Repushing on that no-op\n      // fire would land one frame into the intro and stomp the reveal — only\n      // react when the renderer size actually changed.\n      if (mount.clientWidth === chart.getWidth() && mount.clientHeight === chart.getHeight()) {\n        return;\n      }\n      chart.resize();\n      live.repush();\n    });\n    resizeObserver.observe(mount);\n\n    // Light/dark flips change no React state — re-resolve and push directly.\n    const themeObserver = new MutationObserver(() => {\n      live.repush();\n    });\n    themeObserver.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    // Clicking a node toggles its selection. Sankey click params carry\n    // `dataType: \"node\"` (vs \"edge\" for links); only nodes are selectable, and\n    // only when <Node isClickable> is set.\n    chart.on(\"click\", (params) => {\n      const { isNodeClickable } = live.handlers;\n      if (!isNodeClickable) return;\n      const p = params as { dataType?: string; name?: string };\n      if (p.dataType !== \"node\") return;\n      if (typeof p.name === \"string\") toggleSelection(p.name);\n    });\n\n    return () => {\n      resizeObserver.disconnect();\n      themeObserver.disconnect();\n      chart.dispose();\n      echartsRef.current = null;\n      // The reveal guard belongs to the chart instance it guarded. Without this\n      // reset, StrictMode's dev-only mount→unmount→remount plays the entrance on\n      // the throwaway instance and the surviving one renders without it.\n      live.hasRevealed = false;\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  // ── Sync ECharts with props/theme/selection — resolve, build, push ────────────\n  useEffect(() => {\n    const chart = echartsRef.current;\n    const container = containerRef.current;\n    if (!chart || !container) return;\n\n    // Colors come from the <style> committed just before this effect ran — read\n    // them here, right before the push, rather than round-tripping through state.\n    live.resolved = resolveColors(container, config, nodeNames);\n\n    // ECharts' own animation stays off throughout: the entrance is painted by the\n    // option itself (windowed gradients, below), so there is nothing left for the\n    // native tweener to do — and its sankey entrance is the clip sweep this\n    // replaces. Intro frames merge rather than replace, so the chart keeps its\n    // views instead of rebuilding them 60 times a second.\n    const push = (mergeOnly: boolean) => {\n      const option = buildOption();\n      const merged = chartOptions ? { ...option, ...chartOptions } : option;\n      Object.assign(merged, { animation: false, animationDurationUpdate: 0 });\n      // chartOptions is an untyped escape hatch — the spread erases the option's\n      // shape, so re-assert it. The only cast in the file.\n      chart.setOption(\n        merged as EChartsOption,\n        mergeOnly ? { lazyUpdate: true, silent: true } : { notMerge: true },\n      );\n    };\n\n    // Intro cascade, played once per chart instance: columns of nodes grow out of\n    // their centres left to right, each column's bands drawing toward the next as\n    // soon as its nodes are up. Every later push (selection, theme) lands with\n    // `intro` null, so it applies instantly instead of replaying. A loading cycle\n    // re-arms it: the Recharts twin remounts its diagram after loading and replays\n    // the intro, so data → loading → data draws in again here too.\n    if (isLoading) live.hasRevealed = false;\n    const shouldReveal = !live.hasRevealed && !isLoading;\n    if (shouldReveal) live.hasRevealed = true;\n    const revealEnabled =\n      animation && shouldReveal && animationType !== \"none\" && !shouldReduceMotion;\n\n    let raf = 0;\n    if (revealEnabled) {\n      const depths = computeNodeDepths(data);\n      const duration = introDuration(depths);\n      live.intro = { elapsed: 0, depths };\n      push(false);\n\n      const start = performance.now();\n      const tick = (now: number) => {\n        const elapsed = now - start;\n        const done = elapsed >= duration;\n        live.intro = done ? null : { elapsed, depths };\n        push(true);\n        if (!done) raf = requestAnimationFrame(tick);\n      };\n      raf = requestAnimationFrame(tick);\n    } else {\n      live.intro = null;\n      push(false);\n    }\n\n    // Theme flips and resizes re-enter here without touching React: re-read the\n    // tokens (the .dark class changed, or the renderer resized) and push an\n    // update-style option. Mid-intro that push carries the current cascade frame,\n    // so a theme flip retints the entrance instead of interrupting it.\n    live.repush = () => {\n      live.resolved = resolveColors(container, config, nodeNames);\n      push(false);\n    };\n\n    // Anything that re-runs this effect (a selection, a prop change) ends an\n    // in-flight cascade — the next push draws the finished diagram.\n    return () => {\n      cancelAnimationFrame(raf);\n      live.intro = null;\n    };\n  }, [\n    live,\n    buildOption,\n    chartOptions,\n    data,\n    isLoading,\n    animation,\n    animationType,\n    shouldReduceMotion,\n    config,\n    nodeNames,\n  ]);\n\n  // ── Loading shimmer — rAF sweeps a bright band across the fixed skeleton ──────\n  useEffect(() => {\n    const chart = echartsRef.current;\n    if (!chart || !isLoading) return;\n\n    let raf = 0;\n    const start = performance.now();\n    const tick = (now: number) => {\n      const phase = ((((now - start) / LOADING_ANIMATION_DURATION) % 1) + 1) % 1;\n\n      // Read tokens per frame, so a theme flip mid-loading retints the shimmer.\n      const foreground = live.resolved?.tokens.foreground ?? GRAY;\n      const w = chart.getWidth();\n      const h = chart.getHeight();\n      if (!w || !h) {\n        raf = requestAnimationFrame(tick);\n        return;\n      }\n      // Sweep the clip window from fully off-screen left to fully off-screen\n      // right, leaned 45°. ABSOLUTE pixel coordinates (global gradient) are\n      // shared by nodes and links, so every element lights up as the same band\n      // passes its x-position — nodes in a column brighten together.\n      const maxT = (w + h) / (2 * w);\n      const center = phase * (maxT + 2 * LOADING_SHIMMER_BAND) - LOADING_SHIMMER_BAND;\n      const clip = (floor: number, peak: number) =>\n        new echarts.graphic.LinearGradient(\n          0,\n          0,\n          w,\n          w,\n          shimmerWindowStops(center, foreground, floor, peak),\n          true,\n        );\n      chart.setOption(\n        {\n          series: [\n            {\n              id: \"__loading\",\n              itemStyle: { color: clip(LOADING_NODE_FLOOR, LOADING_NODE_PEAK), borderWidth: 0 },\n              lineStyle: {\n                color: clip(LOADING_LINK_FLOOR, LOADING_LINK_PEAK),\n                curveness: DEFAULT_LINK_CURVATURE,\n              },\n            },\n          ],\n        },\n        { silent: true, lazyUpdate: true },\n      );\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [live, isLoading]);\n\n  return (\n    <div\n      ref={containerRef}\n      data-chart={chartId}\n      className={`relative flex flex-col text-xs ${className ?? \"\"}`}\n    >\n      <style dangerouslySetInnerHTML={{ __html: css }} />\n\n      <div className=\"relative min-h-0 w-full flex-1\">\n        <div ref={mountRef} className=\"h-full min-h-0 w-full\" />\n      </div>\n\n      {isLoading && (\n        <div className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center\">\n          <motion.div\n            initial={shouldReduceMotion ? false : { opacity: 0, scale: 0.92 }}\n            animate={{ opacity: 1, scale: 1 }}\n            transition={{ duration: 0.25, ease: \"easeOut\" }}\n            className=\"text-primary bg-background flex items-center justify-center gap-2 rounded-md border px-2 py-0.5 text-sm\"\n          >\n            <div className=\"border-border border-t-primary h-3 w-3 animate-spin rounded-full border\" />\n            <span>Loading</span>\n          </motion.div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nEChartsSankeyChart.Node = Node;\nEChartsSankeyChart.NodeLabel = NodeLabel;\nEChartsSankeyChart.Link = Link;\nEChartsSankeyChart.Tooltip = Tooltip;\n",
      "type": "registry:component",
      "target": "components/evilcharts/charts/echarts-sankey-chart.tsx"
    }
  ],
  "type": "registry:component"
}