{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "echarts-composed-chart",
  "description": "Composed chart component rendered with Apache ECharts",
  "dependencies": [
    "echarts",
    "motion"
  ],
  "registryDependencies": [
    "@evilcharts/echarts-chart",
    "@evilcharts/echarts-tooltip",
    "@evilcharts/echarts-dot",
    "@evilcharts/echarts-legend",
    "@evilcharts/echarts-brush"
  ],
  "files": [
    {
      "path": "src/registry/charts/echarts-composed-chart.tsx",
      "content": "\"use client\";\n\nimport {\n  tooltipBaseOption,\n  tooltipIndicatorHtml,\n  tooltipRow,\n  tooltipShell,\n  type TooltipPosition,\n  type TooltipRoundness,\n  type TooltipVariant,\n} from \"@/registry/ui/echarts-tooltip\";\nimport {\n  Brush,\n  buildBrushDataZoom,\n  syncBrushOverlay,\n  type BrushGeometry,\n  type BrushOverlayElements,\n  type BrushProps,\n  type BrushRange,\n} from \"@/registry/ui/echarts-brush\";\nimport {\n  DataZoomComponent,\n  GridComponent,\n  TooltipComponent,\n  type DataZoomComponentOption,\n  type GridComponentOption,\n  type TooltipComponentOption,\n} from \"echarts/components\";\nimport {\n  buildChartCss,\n  flattenColor,\n  getColorsCount,\n  resolveColors,\n  seriesPaint,\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 CSSProperties,\n  type FC,\n  type ReactNode,\n} from \"react\";\nimport { dotItemStyle, dotStyle, sampleGradient, type DotVariant } from \"@/registry/ui/echarts-dot\";\nimport { BarChart, LineChart, type BarSeriesOption, type LineSeriesOption } from \"echarts/charts\";\nimport { LegendOverlay, type LegendVariant } from \"@/registry/ui/echarts-legend\";\nimport type { ComposeOption, ImagePatternObject } from \"echarts/core\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { CanvasRenderer } from \"echarts/renderers\";\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 {\n  ChartConfig,\n  DotVariant,\n  LegendVariant,\n  TooltipPosition,\n  TooltipRoundness,\n  TooltipVariant,\n};\n\n// Modular registration keeps the bundle lean — only the pieces a composed chart\n// needs. A composed chart mixes `BarChart` and `LineChart` series in one grid.\n// `DataZoomComponent` bundles both the slider (brush footer) and inside (wheel/\n// drag) zoom; the brush's frame/handles/labels are raw zrender elements, NOT the\n// graphic component (never registered) — see syncBrushOverlay.\necharts.use([\n  BarChart,\n  LineChart,\n  GridComponent,\n  TooltipComponent,\n  DataZoomComponent,\n  CanvasRenderer,\n]);\n\ntype EChartsInstance = ReturnType<typeof echarts.init>;\n\n// The exact option surface this chart uses — bar + line series, grid, tooltip,\n// and dataZoom, plus the axis options they pull in as dependencies. Narrower than\n// echarts' full EChartsOption, so a misspelled key fails the compile instead of\n// silently reaching setOption.\ntype EChartsOption = ComposeOption<\n  | BarSeriesOption\n  | LineSeriesOption\n  | GridComponentOption\n  | TooltipComponentOption\n  | DataZoomComponentOption\n>;\n\n// Single-entry views of the composed option's array-or-single fields — the\n// modular entry points don't export the axis option types directly.\ntype ArrayItem<T> = T extends readonly (infer U)[] ? U : T;\ntype XAxisOption = ArrayItem<NonNullable<EChartsOption[\"xAxis\"]>>;\ntype YAxisOption = ArrayItem<NonNullable<EChartsOption[\"yAxis\"]>>;\n\n// The fill/paint any bar or line resolves to — a solid string, a gradient object,\n// or a tiling canvas pattern (hatched). Assignable to itemStyle/lineStyle color.\ntype SeriesPaint = string | echarts.graphic.LinearGradient | ImagePatternObject;\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Constants\n// ─────────────────────────────────────────────────────────────────────────────\n\nconst STROKE_WIDTH = 2; // line stroke width — the Recharts twin draws lines at 2px\nconst AXIS_POINTER_WIDTH = 1; // tooltip cursor line\nconst DEFAULT_BAR_RADIUS = 4; // bar corner radius, matching the Recharts twin\nconst LOADING_ANIMATION_DURATION = 2000; // shimmer loop, in milliseconds\nconst REVEAL_DURATION = 1000; // intro draw-in length, in milliseconds\n// The bar entrance is a per-datum grow-in staggered by `animationType`, exactly\n// like the ECharts bar chart. Unlike the line's clip (which ECharts hardcodes to\n// linear left-to-right), bars are independent rectangles, so the direction values\n// are honored via a per-datum `animationDelay`.\nconst BAR_GROW_DURATION = 500; // per-bar grow-in length, in milliseconds\nconst BAR_STAGGER = 50; // delay between consecutive bars in the reveal, in milliseconds\n// NOTE: the LINE intro runs ECharts' RAW default entrance — lines trace in\n// left-to-right. Custom easing/direction was tried and abandoned for the line:\n// ECharts hardcodes the line-entrance clip to linear and ignores animationEasing\n// at every level (verified empirically). The `animationType` direction values are\n// kept as recharts-parity aliases and drive the BAR stagger.\nconst LOADING_DEFAULT_BARS = 12;\nconst DASH_PATTERN: [number, number] = [5, 5]; // dashed stroke — the twin uses \"5 5\"\nconst DASH_PERIOD = 10; // sum of DASH_PATTERN — the animated sweep travels one period per second\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Theme knobs — every neutral line in the chart draws from these. Base colors\n// come from the consumer's CSS tokens (resolved from the live DOM), so only the\n// opacity factors live here. Factors MULTIPLY the token's own alpha — a border\n// token that is already 10%-white stays subtle. Tune here, not in the builder.\n// ─────────────────────────────────────────────────────────────────────────────\n// Recharts draws its grid at border/50, but SVG dashes render pixel-crisp while\n// canvas at 2× DPR spreads a 1px line across device pixels — roughly halving\n// perceived intensity. Using the border token's full alpha lands both engines at\n// the same apparent brightness.\nconst GRID_LINE_OPACITY = 1; // dashed y-axis split lines, × border alpha\nconst AXIS_POINTER_OPACITY = 1; // tooltip cursor line, × border alpha\n// The skeleton bars are CLIPPED to a small sweeping window — only the bars inside\n// it are painted, everything outside is fully transparent, like a clip-path\n// sliding diagonally across the chart.\nconst LOADING_BAR_MAX_OPACITY = 0.22; // skeleton bar fill inside the window, × foreground alpha\nconst LOADING_LINE_MAX_OPACITY = 0.5; // skeleton line stroke inside the window, × foreground alpha\nconst LOADING_LINE_WIDTH = 2; // skeleton line stroke width\nconst LOADING_SHIMMER_BAND = 0.2; // window half-width, fraction of chart width\nconst LOADING_SHIMMER_FEATHER = 0.2; // eased edge softening of the clip window\nconst BRUSH_STROKE_OPACITY = 0.5; // mini-chart series stroke\nconst BRUSH_FILL_OPACITY = 0.15; // mini-chart series fade, at the top stop\nconst BRUSH_FILLER_OPACITY = 0; // selected-range wash — evil-brush draws none\n// Glow — the canvas analogue of the Recharts feGaussianBlur filters.\n// Bars: a soft outer canvas shadow. The Recharts BarGlowFilter blurs the fill at\n// stdDeviation 8 and merges it under the shape, so the halo is generous and soft;\n// a canvas shadowBlur reproduces that. shadowColor is sampled PER-DATUM for\n// multi-color bars (§sampleGradient) so the halo follows the palette, not a single\n// wrong tint.\nconst BAR_GLOW_BLUR = 16; // bar glow radius, canvas shadowBlur\nconst BAR_GLOW_OPACITY = 0.6; // bar glow strength, × series color alpha\n// Lines: SILENT copies of the stroke stacked UNDER the real line, all at the SAME\n// NARROW WIDTH so they stay hidden beneath it — the visible halo is entirely each\n// copy's canvas `shadowBlur`. Widening the copies instead (the obvious approach)\n// paints concentric contour rings, because a translucent stroke has a hard edge\n// and every extra layer adds another visible boundary; a shadow is a true gaussian\n// and several at different radii sum to a smooth falloff. The copies keep the\n// series gradient so the bright core tracks the line's color; the shadow itself is\n// one flat tone (a canvas shadow cannot be a gradient), which only tints the soft\n// outer bloom. Matches the line chart's GLOW_LAYERS.\nconst LINE_GLOW_LAYERS: { width: number; opacity: number; blur: number }[] = [\n  { width: 2, opacity: 0.9, blur: 5 }, // × series color alpha\n  { width: 2, opacity: 0.6, blur: 12 },\n  { width: 2, opacity: 0.38, blur: 24 },\n  { width: 2, opacity: 0.22, blur: 42 },\n];\n// The dim applied to unselected series once one series is selected. Line strokes\n// and dots drop to SELECTION_DIM (0.3, matching the Recharts twin); bar FILLS\n// recede further to SELECTION_DIM_FILL so a dimmed column fades back like the area\n// chart's dimmed fill while thin strokes and dots stay legible.\nconst SELECTION_DIM = 0.3;\nconst SELECTION_DIM_FILL = 0.15;\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Public types\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport type BarVariant =\n  | \"default\"\n  | \"hatched\"\n  | \"duotone\"\n  | \"duotone-reverse\"\n  | \"gradient\"\n  | \"stripped\";\nexport type StrokeVariant = \"solid\" | \"dashed\" | \"animated-dashed\";\nexport type ComposedAnimationType =\n  | \"none\"\n  | \"left-to-right\"\n  | \"right-to-left\"\n  | \"center-out\"\n  | \"edges-in\";\nexport type CurveType =\n  | \"linear\"\n  | \"smooth\"\n  | \"bump\"\n  | \"monotone\"\n  | \"monotoneX\"\n  | \"monotoneY\"\n  | \"natural\"\n  | \"step\";\n// DotVariant, TooltipVariant, TooltipRoundness, LegendVariant, and ChartConfig\n// now live in the shared @/registry/ui/echarts/* modules and are imported +\n// re-exported at the top of this file.\n\nexport interface EChartsComposedChartProps<TData extends Record<string, unknown>> {\n  data: TData[]; // rows rendered by the chart\n  config: ChartConfig; // series colors + labels for every bar and line\n  xDataKey?: keyof TData & string; // x category key — falls back to the <XAxis> dataKey / first free column\n  className?: string; // extra classes for the chart container\n  curveType?: CurveType; // default curve interpolation each <Line> inherits\n  animation?: boolean; // master switch for the intro draw-in — false renders instantly\n  animationType?: ComposedAnimationType; // default intro reveal (first series overrides)\n  barGap?: number | string; // gap between bars sharing a category (ECharts accepts \"30%\" or a pixel number)\n  barCategoryGap?: number | string; // gap between bar categories\n  defaultSelectedDataKey?: string | null; // series selected on first render\n  onSelectionChange?: (key: string | null) => void; // fires when the selected series changes\n  isLoading?: boolean; // shows the animated loading skeleton\n  loadingBars?: number; // number of bars in the loading skeleton\n  chartOptions?: Record<string, unknown>; // escape hatch merged over the built ECharts option\n  children?: ReactNode; // declarative config — <Bar>, <Line>, <XAxis>, <Grid>, <Tooltip>, <Legend>, …\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Composible parts — DECLARATIVE CONFIG. Every part renders `null`; the root\n// walks `children` by reference (child.type === Bar, …) to collect its props.\n// Presence semantics mirror the Recharts twin: omit a child and that part does\n// not render. These are never mounted into the tree — they only carry props.\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface BarProps {\n  dataKey: string; // series key — must exist on the data + config\n  variant?: BarVariant; // fill style for this bar only\n  radius?: number; // corner radius of the bar in pixels\n  glow?: boolean; // applies a soft neon glow to this bar\n  animationType?: ComposedAnimationType; // grow-in order — the first series drives the chart\n  isClickable?: boolean; // lets this bar be selected by clicking it\n  enableHoverHighlight?: boolean; // dims the other columns of this bar when one is hovered\n  barProps?: Partial<BarSeriesOption>; // escape hatch merged into the raw ECharts bar series\n}\n\n/**\n * A single bar series. Declares its own fill variant, radius, glow, and\n * clickability. Renders nothing — the root reads these props to build the\n * ECharts bar series.\n */\nconst Bar: FC<BarProps> = () => null;\n\nexport interface LineProps {\n  dataKey: string; // series key — must exist on the data + config\n  strokeVariant?: StrokeVariant; // stroke style for this line only\n  curveType?: CurveType; // curve interpolation — falls back to the root curveType\n  animationType?: ComposedAnimationType; // intro reveal — the first series drives the chart\n  connectNulls?: boolean; // join segments across null/missing values\n  glow?: boolean; // applies a soft neon glow to this line\n  isClickable?: boolean; // lets this line be selected by clicking it\n  children?: ReactNode; // optional <Dot> and <ActiveDot> config\n  lineProps?: Partial<LineSeriesOption>; // escape hatch merged into the raw ECharts line series\n}\n\n/**\n * A single line series. Declares its own stroke/curve/glow/clickability and,\n * optionally, resting/active point markers via composed <Dot> / <ActiveDot>.\n * Renders nothing — the root reads these props to build the ECharts line series.\n */\nconst Line: FC<LineProps> = () => null;\n\nexport interface DotProps {\n  variant?: DotVariant; // visual style of the point marker\n}\n\n/** Declares the resting point marker for the enclosing <Line>. Renders nothing. */\nconst Dot: FC<DotProps> = () => null;\n\n/** Declares the hovered/active point marker for the enclosing <Line>. Renders nothing. */\nconst ActiveDot: FC<DotProps> = () => null;\n\nexport interface XAxisProps {\n  dataKey?: string; // x category key — overrides the root xDataKey\n  // Category-axis values are always stringified, so the formatter sees a string —\n  // letting examples share `(value) => value.substring(0, 3)` with the Recharts twin.\n  tickFormatter?: (value: string, index: number) => string; // formats x tick labels\n  label?: string; // axis title, centered below the tick labels\n  hideDots?: boolean; // hides the tick dots beside this axis's labels\n}\n\n/** Presence shows the x-axis category labels. Renders nothing. */\nconst XAxis: FC<XAxisProps> = () => null;\n\nexport interface YAxisProps {\n  dataKey?: string; // reserved for parity with the Recharts twin\n  tickFormatter?: (value: number, index: number) => string; // formats y tick labels\n  label?: string; // axis title, rotated alongside the tick labels\n  hideDots?: boolean; // hides the tick dots beside this axis's labels\n}\n\n/** Presence shows the y value axis. Renders nothing. */\nconst YAxis: FC<YAxisProps> = () => null;\n\n/** Presence shows the dashed horizontal split lines. Renders nothing. */\nconst Grid: FC = () => null;\n\nexport interface TooltipProps {\n  variant?: TooltipVariant; // visual style of the tooltip surface\n  roundness?: TooltipRoundness; // border-radius of the tooltip\n  defaultIndex?: number; // data index shown by default with no hover\n  cursor?: boolean; // whether the vertical cursor line follows the pointer\n  position?: TooltipPosition; // \"variable\" follows both axes (default); \"fixed\" pins the tooltip near the top and tracks the pointer's X\n}\n\n/** Presence enables the hover tooltip. Renders nothing. */\nconst Tooltip: FC<TooltipProps> = () => null;\n\nexport interface LegendProps {\n  variant?: LegendVariant; // visual style of the legend indicators\n  align?: \"left\" | \"center\" | \"right\"; // horizontal placement\n  verticalAlign?: \"top\" | \"middle\" | \"bottom\"; // vertical placement\n  isClickable?: boolean; // lets each entry toggle selection of its series\n}\n\n/** Presence enables the HTML legend overlay. Renders nothing. */\nconst Legend: FC<LegendProps> = () => null;\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Children collection — walk the declarative config into plain objects the\n// option builder consumes. <Dot> / <ActiveDot> are read from each <Line>'s own\n// children; a missing dot child means that marker does not render. Bars and lines\n// are kept in separate ordered lists so the series array is [...bars, ...lines] —\n// bars render behind lines, matching the Recharts twin's JSX order.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype BarSeriesConfig = {\n  dataKey: string;\n  variant: BarVariant;\n  radius: number;\n  glow: boolean;\n  animationType?: ComposedAnimationType;\n  isClickable: boolean;\n  enableHoverHighlight: boolean;\n  barProps?: Partial<BarSeriesOption>;\n};\n\ntype LineSeriesConfig = {\n  dataKey: string;\n  strokeVariant: StrokeVariant;\n  curveType?: CurveType;\n  animationType?: ComposedAnimationType;\n  connectNulls: boolean;\n  glow: boolean;\n  isClickable: boolean;\n  dotVariant: DotVariant; // \"none\" when no <Dot> child is present\n  activeDotVariant: DotVariant; // \"none\" when no <ActiveDot> child is present\n  lineProps?: Partial<LineSeriesOption>;\n};\n\ntype XAxisSlot = {\n  present: boolean;\n  dataKey?: string;\n  tickFormatter?: (value: string, index: number) => string;\n  label?: string;\n  hideDots: boolean;\n};\ntype YAxisSlot = {\n  present: boolean;\n  dataKey?: string;\n  tickFormatter?: (value: number, index: number) => string;\n  label?: string;\n  hideDots: boolean;\n};\ntype TooltipSlot = {\n  present: boolean;\n  variant: TooltipVariant;\n  roundness: TooltipRoundness;\n  defaultIndex?: number;\n  cursor: boolean;\n  position: TooltipPosition;\n};\ntype LegendSlot = {\n  present: boolean;\n  variant: LegendVariant;\n  align: \"left\" | \"center\" | \"right\";\n  verticalAlign: \"top\" | \"middle\" | \"bottom\";\n  isClickable: boolean;\n};\ntype BrushSlot = {\n  present: boolean; // a <Brush> child was passed — replaces the old showBrush prop\n  height?: number;\n  formatLabel?: (value: string, index: number) => string;\n  onChange?: (range: { startIndex: number; endIndex: number }) => void;\n};\n\ntype CollectedConfig = {\n  bars: BarSeriesConfig[];\n  lines: LineSeriesConfig[];\n  xAxis: XAxisSlot;\n  yAxis: YAxisSlot;\n  showGrid: boolean;\n  tooltip: TooltipSlot;\n  legend: LegendSlot;\n  brush: BrushSlot;\n};\n\nfunction collectConfig(children: ReactNode): CollectedConfig {\n  const bars: BarSeriesConfig[] = [];\n  const lines: LineSeriesConfig[] = [];\n  let xAxis: XAxisSlot = { present: false, hideDots: false };\n  let yAxis: YAxisSlot = { present: false, hideDots: false };\n  let showGrid = false;\n  let tooltip: TooltipSlot = {\n    present: false,\n    variant: \"default\",\n    roundness: \"lg\",\n    cursor: true,\n    position: \"variable\",\n  };\n  let legend: LegendSlot = {\n    present: false,\n    variant: \"rounded-square\",\n    align: \"right\",\n    verticalAlign: \"top\",\n    isClickable: false,\n  };\n  let brush: BrushSlot = { present: false };\n\n  Children.forEach(children, (child) => {\n    if (!isValidElement(child)) return;\n    const type = child.type;\n\n    if (type === Bar) {\n      const props = child.props as BarProps;\n      bars.push({\n        dataKey: props.dataKey,\n        variant: props.variant ?? \"default\",\n        radius: props.radius ?? DEFAULT_BAR_RADIUS,\n        glow: props.glow ?? false,\n        animationType: props.animationType,\n        isClickable: props.isClickable ?? false,\n        enableHoverHighlight: props.enableHoverHighlight ?? false,\n        barProps: props.barProps,\n      });\n    } else if (type === Line) {\n      const props = child.props as LineProps;\n      let dotVariant: DotVariant = \"none\";\n      let activeDotVariant: DotVariant = \"none\";\n      Children.forEach(props.children, (dotChild) => {\n        if (!isValidElement(dotChild)) return;\n        if (dotChild.type === Dot) {\n          dotVariant = (dotChild.props as DotProps).variant ?? \"default\";\n        } else if (dotChild.type === ActiveDot) {\n          activeDotVariant = (dotChild.props as DotProps).variant ?? \"default\";\n        }\n      });\n      lines.push({\n        dataKey: props.dataKey,\n        strokeVariant: props.strokeVariant ?? \"solid\",\n        curveType: props.curveType,\n        animationType: props.animationType,\n        connectNulls: props.connectNulls ?? false,\n        glow: props.glow ?? false,\n        isClickable: props.isClickable ?? false,\n        dotVariant,\n        activeDotVariant,\n        lineProps: props.lineProps,\n      });\n    } else if (type === XAxis) {\n      const props = child.props as XAxisProps;\n      xAxis = {\n        present: true,\n        dataKey: props.dataKey,\n        tickFormatter: props.tickFormatter,\n        label: props.label,\n        hideDots: props.hideDots ?? false,\n      };\n    } else if (type === YAxis) {\n      const props = child.props as YAxisProps;\n      yAxis = {\n        present: true,\n        dataKey: props.dataKey,\n        tickFormatter: props.tickFormatter,\n        label: props.label,\n        hideDots: props.hideDots ?? false,\n      };\n    } else if (type === Grid) {\n      showGrid = true;\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        defaultIndex: props.defaultIndex,\n        cursor: props.cursor ?? true,\n        position: props.position ?? \"variable\",\n      };\n    } else if (type === Legend) {\n      const props = child.props as LegendProps;\n      legend = {\n        present: true,\n        variant: props.variant ?? \"rounded-square\",\n        align: props.align ?? \"right\",\n        verticalAlign: props.verticalAlign ?? \"top\",\n        isClickable: props.isClickable ?? false,\n      };\n    } else if (type === Brush) {\n      const props = child.props as BrushProps;\n      brush = {\n        present: true,\n        height: props.height,\n        formatLabel: props.formatLabel,\n        onChange: props.onChange,\n      };\n    }\n  });\n\n  return { bars, lines, xAxis, yAxis, showGrid, tooltip, legend, brush };\n}\n\n// Color plumbing (ChartConfig, getColorsCount, distributeColors, buildChartCss,\n// normalizeColor, withAlpha, ResolvedColors, resolveColors, seriesPaint) now\n// lives in @/registry/ui/echarts-chart and is imported at the top of this file.\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Bar fills — the ECharts analogue of the Recharts bar variants. Bars are opaque\n// colored shapes (unlike the translucent area fills), so these paint at full\n// strength. Each is applied per-bar: ECharts gradient itemStyle defaults to\n// `global: false`, so the gradient coordinates are relative to EACH bar's own\n// bounding box — exactly what recharts' objectBoundingBox patterns do.\n// ─────────────────────────────────────────────────────────────────────────────\n\n// Diagonal two-tone hatch, tinted with the bar's color. The stripe is drawn\n// STRAIGHT (a vertical bar in a 5px tile) and the pattern itself is rotated 45° —\n// zrender applies pattern transforms the same way ECharts decals do. Baking the\n// diagonal into the tile would clip the stroke at the corners and read as\n// periodic gaps once tiled. Tiles render at devicePixelRatio and scale back down\n// so the texture stays crisp on retina canvases.\nfunction barHatchPattern(color: string): ImagePatternObject | null {\n  if (typeof document === \"undefined\") return null;\n  const dpr = Math.max(window.devicePixelRatio || 1, 1);\n  const canvas = document.createElement(\"canvas\");\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) return null;\n\n  const period = 5;\n  const stripe = 1.5;\n  canvas.width = period * dpr;\n  canvas.height = period * dpr;\n  ctx.scale(dpr, dpr);\n\n  // Dimmed field with a full-strength stripe every 5px — the recharts hatch mask\n  // is exactly this (background at 30% alpha, stripe at full).\n  ctx.fillStyle = withAlpha(color, 0.3);\n  ctx.fillRect(0, 0, period, period);\n  ctx.fillStyle = color;\n  ctx.fillRect(0, 0, stripe, period);\n\n  return {\n    image: canvas,\n    repeat: \"repeat\",\n    rotation: -Math.PI / 4,\n    scaleX: 1 / dpr,\n    scaleY: 1 / dpr,\n  };\n}\n\n// A vertical top→bottom gradient through the series color slots (recharts'\n// VerticalColorGradient). A single color collapses to a solid string.\nfunction verticalColorGradient(slots: string[]): string | echarts.graphic.LinearGradient {\n  if (slots.length <= 1) return slots[0] ?? \"rgba(120, 120, 120, 1)\";\n  return new echarts.graphic.LinearGradient(\n    0,\n    0,\n    0,\n    1,\n    slots.map((color, i) => ({ offset: i / (slots.length - 1), color })),\n  );\n}\n\n// Resolves a bar variant into an ECharts fill. Multi-color configs (only the\n// `default` variant appears multi-color in practice) run the full vertical color\n// gradient; the horizontal-split / textured variants are authored single-color in\n// the twin, so they tint from the first slot.\nfunction barFillPaint(variant: BarVariant, slots: string[]): SeriesPaint {\n  const base = slots[0] ?? \"rgba(120, 120, 120, 1)\";\n  const multi = slots.length > 1;\n\n  switch (variant) {\n    case \"gradient\": {\n      // Bar color faded toward the baseline — full near the top (≤20%), gone by\n      // 90% — recharts' GradientPattern mask, expressed directly as vertical alpha.\n      const fade = (t: number) => (t <= 0.2 ? 1 : t >= 0.9 ? 0 : 1 - (t - 0.2) / 0.7);\n      if (multi) {\n        return new echarts.graphic.LinearGradient(\n          0,\n          0,\n          0,\n          1,\n          slots.map((color, i) => {\n            const t = i / (slots.length - 1);\n            return { offset: t, color: withAlpha(color, fade(t)) };\n          }),\n        );\n      }\n      return new echarts.graphic.LinearGradient(0, 0, 0, 1, [\n        { offset: 0, color: withAlpha(base, 1) },\n        { offset: 0.2, color: withAlpha(base, 1) },\n        { offset: 0.9, color: withAlpha(base, 0) },\n        { offset: 1, color: withAlpha(base, 0) },\n      ]);\n    }\n    case \"duotone\":\n    case \"duotone-reverse\": {\n      // Horizontal two-tone split: one half full strength, the other at 40%.\n      const reverse = variant === \"duotone-reverse\";\n      const dim = withAlpha(base, 0.4);\n      const left = reverse ? base : dim;\n      const right = reverse ? dim : base;\n      return new echarts.graphic.LinearGradient(0, 0, 1, 0, [\n        { offset: 0, color: left },\n        { offset: 0.5, color: left },\n        { offset: 0.5, color: right },\n        { offset: 1, color: right },\n      ]);\n    }\n    case \"stripped\": {\n      // A faint wash (0.4 → 0.1 down the bar) under a bright top edge — the\n      // canvas analogue of recharts' low-opacity gradient plus its solid top\n      // strip. The strip lives inside the gradient (a full-alpha stop in the top\n      // ~5%) since ECharts bars can't carry a separate top-only fill.\n      return new echarts.graphic.LinearGradient(0, 0, 0, 1, [\n        { offset: 0, color: withAlpha(base, 1) },\n        { offset: 0.05, color: withAlpha(base, 0.4) },\n        { offset: 1, color: withAlpha(base, 0.1) },\n      ]);\n    }\n    case \"hatched\":\n      return barHatchPattern(base) ?? base;\n    case \"default\":\n    default:\n      return verticalColorGradient(slots);\n  }\n}\n\n// Dot helpers (DotStyle, dotItemStyle, DOT_SIZES, dotStyle, sampleGradient) now\n// live in @/registry/ui/echarts-dot and are imported at the top of this file.\n\n// Brush overlays + the dataZoom slider builder (BrushRange, BrushGeometry,\n// BrushOverlayElements, syncBrushOverlay, buildBrushDataZoom) now live in\n// @/registry/ui/echarts-brush and are imported at the top of this file. The\n// per-chart mini-series (which differ per chart type) are still built below.\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Curve mapping — linear → straight, step → step:\"middle\", everything else →\n// smooth. Recharts \"step\" is d3's curveStep: the transition happens at the\n// MIDPOINT between points, so each dot sits centered on its plateau.\n// ─────────────────────────────────────────────────────────────────────────────\n\nfunction curveConfig(curveType: CurveType): { smooth: boolean; step: \"middle\" | false } {\n  if (curveType === \"step\") return { smooth: false, step: \"middle\" };\n  if (curveType === \"linear\") return { smooth: false, step: false };\n  return { smooth: true, step: false };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Selection dim (§ recharts getOpacity / getBarOpacity) — a series dims only when\n// ANOTHER series is selected; the selected (or no-selection) series stays at full\n// strength. Line strokes and dots use seriesDim (0.3); bar FILLS use seriesFillDim\n// (0.15) so the dimmed columns recede further than the lines, like the area\n// chart's dimmed fill.\n// ─────────────────────────────────────────────────────────────────────────────\n\nfunction seriesDim(selected: string | null, key: string): number {\n  return selected === null || selected === key ? 1 : SELECTION_DIM;\n}\n\nfunction seriesFillDim(selected: string | null, key: string): number {\n  return selected === null || selected === key ? 1 : SELECTION_DIM_FILL;\n}\n\nfunction seriesLabel(config: ChartConfig, key: string): string {\n  const label = config[key]?.label;\n  return typeof label === \"string\" ? label : key;\n}\n\n// How many stagger steps a bar at `index` waits before it grows in — the order\n// encoded by `animationType`. Bars are independent rectangles, so (unlike the\n// line's single left-to-right clip) the direction values are honored here via a\n// per-datum `animationDelay`. Ported verbatim from the ECharts bar chart.\nfunction barStaggerDelay(type: ComposedAnimationType, index: number, count: number): number {\n  if (type === \"none\" || count <= 0) return 0;\n  const last = count - 1;\n  const center = last / 2;\n  let step: number;\n  switch (type) {\n    case \"right-to-left\":\n      step = last - index;\n      break;\n    case \"center-out\":\n      step = Math.abs(index - center);\n      break;\n    case \"edges-in\":\n      step = center - Math.abs(index - center);\n      break;\n    default: // left-to-right\n      step = index;\n  }\n  return step * BAR_STAGGER;\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Loading skeleton helpers\n// ─────────────────────────────────────────────────────────────────────────────\n\n// Skeleton data as a smooth random walk in a comfortable band — reads like a\n// resting chart instead of raw noise spikes.\nfunction getLoadingData(points: number): number[] {\n  const rows: number[] = [];\n  let value = 30 + Math.random() * 20;\n  for (let i = 0; i < points; i++) {\n    value = Math.min(58, Math.max(16, value + (Math.random() - 0.5) * 16));\n    rows.push(Math.round(value));\n  }\n  return rows;\n}\n\n// Gradient stops forming a hard clip window around `center`: full `peak` alpha\n// inside, zero outside, with a small feather so the edge isn't aliased.\n// `center` may run outside [0, 1] so the window fully enters and exits the frame.\nfunction shimmerWindowStops(center: number, color: string, 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 0;\n    // Sine-eased falloff — a linear ramp still reads as a hard cut.\n    return peak * Math.sin(((1 - (dist - (half - feather)) / feather) * Math.PI) / 2);\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, tooltipShell,\n// tooltipRow, tooltipIndicatorHtml, tooltipBaseOption) live in\n// @/registry/ui/echarts-tooltip; the legend overlay + its indicators\n// (indicatorBackground, legendFillStyle, legendOutlineStyle, LegendIndicator,\n// LegendOverlay) live in @/registry/ui/echarts-legend — both imported at the top.\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Option builders — pure functions from a snapshot context to ECharts option\n// fragments. The component reads its refs and renderer size ONCE per build into\n// this context; nothing below touches React state or the chart instance, so\n// each fragment can be reasoned about (and tested) in isolation.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype OptionBuildContext = {\n  data: Record<string, unknown>[];\n  config: ChartConfig;\n  bars: BarSeriesConfig[];\n  lines: LineSeriesConfig[];\n  seriesKeys: string[];\n  curveType: CurveType;\n  animationType: ComposedAnimationType; // default bar grow-in order (each bar may override)\n  barGap?: number | string;\n  barCategoryGap?: number | string;\n  selectedDataKey: string | null;\n  showGrid: boolean;\n  xAxisSlot: XAxisSlot;\n  yAxisSlot: YAxisSlot;\n  tooltipSlot: TooltipSlot;\n  legendSlot: LegendSlot;\n  isLoading: boolean;\n  loadingData: () => number[]; // skeleton BAR heights, lazily rolled per shimmer sweep\n  loadingLineData: () => number[]; // skeleton LINE values, an independent walk from the bars\n  showBrush: boolean;\n  brushHeight: number;\n  resolved: ResolvedColors;\n  categories: string[];\n  brushRange: BrushRange; // zoom window carried through rebuilds\n};\n\n// Grid insets plus the footer band reserved for the brush. ECharts 6 contains\n// axis labels automatically (the legacy `containLabel` flag now only triggers a\n// deprecation warning).\nfunction buildChartLayout({ legendSlot, xAxisSlot, showBrush, brushHeight }: OptionBuildContext): {\n  grid: GridComponentOption;\n  brushBottom: number;\n} {\n  const legendTop = legendSlot.present && legendSlot.verticalAlign === \"top\";\n  const legendBottom = legendSlot.present && legendSlot.verticalAlign === \"bottom\";\n  // Clearance covers the x-axis labels plus the same breathing room the\n  // Recharts twin leaves between them and the brush. An x-axis TITLE renders\n  // below the labels (nameGap), so it needs its own band above the brush frame.\n  const brushGap = showBrush ? brushHeight + 30 + (xAxisSlot.label ? 22 : 0) : 0;\n\n  return {\n    grid: {\n      left: 8,\n      right: 8,\n      top: legendTop ? 42 : 16,\n      bottom: 8 + brushGap + (legendBottom ? 34 : 0),\n    },\n    brushBottom: legendBottom ? 34 : 6,\n  };\n}\n\nfunction buildMainAxes(ctx: OptionBuildContext): { xAxis: XAxisOption; yAxis: YAxisOption } {\n  const { xAxisSlot, yAxisSlot, showGrid, isLoading, bars, categories, loadingData } = ctx;\n  const { tokens } = ctx.resolved;\n\n  const axisLabelColor = tokens.mutedForeground;\n  const splitLineColor = withAlpha(tokens.border, GRID_LINE_OPACITY);\n  // Gridline gray as an opaque color — see flattenColor.\n  const tickDotColor = flattenColor(splitLineColor, tokens.background);\n  // Bars need band spacing (points centered in categories); a line-only chart\n  // spans edge to edge. The loading skeleton is bars, so it always bands.\n  const hasBars = bars.length > 0 || isLoading;\n\n  const xTickFormatter = xAxisSlot.tickFormatter;\n  const yTickFormatter = yAxisSlot.tickFormatter;\n\n  const xAxis: XAxisOption = {\n    type: \"category\",\n    boundaryGap: hasBars,\n    show: true,\n    data: isLoading ? loadingData().map((_, i) => i) : categories,\n    // Axis title — same size/color as the tick labels, pushed clear of them.\n    name: isLoading ? undefined : xAxisSlot.label,\n    nameLocation: \"middle\",\n    nameGap: 30,\n    nameTextStyle: { color: axisLabelColor, fontSize: 10 },\n    axisLine: { show: false },\n    // Tick DOTS: a near-zero-length tick whose round caps form a true circle,\n    // in the gridline gray (flattened opaque so the caps don't stack).\n    axisTick: {\n      show: !isLoading && xAxisSlot.present && !xAxisSlot.hideDots,\n      length: 0.5,\n      // With bars the axis uses boundaryGap, which would drop each tick on the\n      // BOUNDARY between categories instead of under its label.\n      alignWithLabel: true,\n      lineStyle: { color: tickDotColor, width: 3, cap: \"round\" },\n    },\n    splitLine: { show: false },\n    axisLabel: {\n      show: !isLoading && xAxisSlot.present,\n      color: axisLabelColor,\n      fontSize: 10,\n      margin: 8,\n      formatter: xTickFormatter\n        ? (value: string, index: number) => xTickFormatter(value, index)\n        : undefined,\n    },\n  };\n\n  // An ECharts axis with `show: false` hides its splitLines too, but Recharts'\n  // <CartesianGrid> draws with or without a visible <YAxis>. Keep the axis on\n  // whenever <Grid/> is present and gate the LABELS on <YAxis/> instead. Bars\n  // baseline at 0, matching the Recharts twin's value axis.\n  const yAxis: YAxisOption = {\n    type: \"value\",\n    show: yAxisSlot.present || showGrid,\n    min: bars.length > 0 ? 0 : undefined,\n    // Axis title — rendered rotated alongside the tick labels, same styling.\n    name: isLoading ? undefined : yAxisSlot.label,\n    nameLocation: \"middle\",\n    nameGap: 38,\n    nameTextStyle: { color: axisLabelColor, fontSize: 10 },\n    axisLine: { show: false },\n    // Same tick dots as the x-axis, beside each value label. No alignWithLabel\n    // here: ECharts types it on the CATEGORY axis only, and a value axis already\n    // puts its ticks on the labels.\n    axisTick: {\n      show: yAxisSlot.present && !isLoading && !yAxisSlot.hideDots,\n      length: 0.5,\n      lineStyle: { color: tickDotColor, width: 3, cap: \"round\" },\n    },\n    splitLine: {\n      // Hidden while loading — the skeleton floats on a clean canvas.\n      show: showGrid && !isLoading,\n      lineStyle: { color: splitLineColor, type: [3, 3] as [number, number], width: 1 },\n    },\n    axisLabel: {\n      // Hidden while loading — skeleton values are meaningless, and the\n      // Recharts YAxis unmounts during loading too.\n      show: yAxisSlot.present && !isLoading,\n      color: axisLabelColor,\n      fontSize: 10,\n      margin: 8,\n      formatter: yTickFormatter\n        ? (value: number, index: number) => yTickFormatter(value, index)\n        : undefined,\n    },\n  };\n\n  return { xAxis, yAxis };\n}\n\n// Tooltip HTML builder, closed over the build context.\nfunction createTooltipFormatter(ctx: OptionBuildContext) {\n  const { config, selectedDataKey, tooltipSlot } = ctx;\n\n  return (params: unknown): string => {\n    const rows = Array.isArray(params) ? params : [params];\n    if (!rows.length) return \"\";\n\n    const first = rows[0] as { axisValue?: string | number; name?: string };\n    // Label shows the RAW axis value — matches ChartTooltipContent (no tick formatter).\n    const axisValue = first.axisValue ?? first.name ?? \"\";\n    const label = String(axisValue);\n\n    const body = rows\n      .map((param) => {\n        const p = param as {\n          seriesId?: string;\n          seriesName?: string;\n          value?: number | string;\n        };\n        // Internal series (the brush's mini chart, the loading skeleton) never\n        // surface in the tooltip.\n        if (String(p.seriesId ?? \"\").startsWith(\"__\")) return \"\";\n        const key = p.seriesId ?? p.seriesName ?? \"\";\n        const item = config[key];\n        const colorsCount = item ? getColorsCount(item) : 1;\n        const labelText = typeof item?.label === \"string\" ? item.label : (p.seriesName ?? key);\n        const dimmed = selectedDataKey != null && selectedDataKey !== key ? \" opacity-30\" : \"\";\n        const value =\n          typeof p.value === \"number\" ? p.value.toLocaleString() : String(p.value ?? \"\");\n\n        return tooltipRow({\n          indicatorHtml: tooltipIndicatorHtml(key, colorsCount),\n          labelText,\n          valueText: value,\n          dimmed,\n        });\n      })\n      .join(\"\");\n\n    return tooltipShell({\n      label,\n      body,\n      roundness: tooltipSlot.roundness,\n      variant: tooltipSlot.variant,\n    });\n  };\n}\n\nfunction buildTooltipOption(ctx: OptionBuildContext): TooltipComponentOption {\n  const { tooltipSlot, isLoading } = ctx;\n  const { tokens } = ctx.resolved;\n\n  return {\n    ...tooltipBaseOption({\n      present: tooltipSlot.present && !isLoading,\n      cursor: tooltipSlot.cursor,\n      tokens,\n      position: tooltipSlot.position,\n      axisPointerColor: withAlpha(tokens.border, AXIS_POINTER_OPACITY),\n      strokeWidth: AXIS_POINTER_WIDTH,\n    }),\n    formatter: createTooltipFormatter(ctx),\n  };\n}\n\n// ── Brush — the evil-brush look, canvas-style: a real mini chart of the full\n// data in a second grid, with a transparent slider dataZoom laid over it. Every\n// bar and line is mirrored as a compact area-line (the EvilBrush \"area\" variant),\n// so the footer reads as one silhouette. Both zoom entries target only the MAIN\n// x-axis, so the mini chart never filters itself. Only called when `showBrush`.\nfunction buildBrushOption(\n  ctx: OptionBuildContext,\n  brushBottom: number,\n): {\n  miniGrid: GridComponentOption;\n  miniXAxis: XAxisOption;\n  miniYAxis: YAxisOption;\n  miniSeries: LineSeriesOption[];\n  dataZoom: DataZoomComponentOption[];\n} {\n  const { data, bars, lines, curveType, selectedDataKey, brushHeight, categories } = ctx;\n  const { tokens } = ctx.resolved;\n\n  const miniGrid: GridComponentOption = {\n    left: 8,\n    right: 8,\n    bottom: brushBottom,\n    height: brushHeight,\n    // No visible axes here — opt out of label containment so the mini chart\n    // spans the full brush frame.\n    outerBoundsMode: \"none\",\n  };\n\n  const miniXAxis: XAxisOption = {\n    type: \"category\",\n    gridIndex: 1,\n    boundaryGap: false,\n    show: false,\n    data: categories,\n    axisPointer: { show: false },\n  };\n\n  const miniYAxis: YAxisOption = { type: \"value\", gridIndex: 1, show: false };\n\n  // Mirror bars and lines alike as area-lines. Bars carry no curve, so they use\n  // the chart default; lines carry their own.\n  const miniInputs = [\n    ...bars.map((bar) => ({ dataKey: bar.dataKey, curveType: undefined, connectNulls: false })),\n    ...lines.map((line) => ({\n      dataKey: line.dataKey,\n      curveType: line.curveType,\n      connectNulls: line.connectNulls,\n    })),\n  ];\n\n  const miniSeries: LineSeriesOption[] = miniInputs.map((input) => {\n    const key = input.dataKey;\n    const base = (ctx.resolved.series[key] ?? [])[0] ?? \"rgba(120, 120, 120, 1)\";\n    const curve = curveConfig(input.curveType ?? curveType);\n\n    // The mini chart mirrors the click selection: unselected series recede like\n    // the main plot — the stroke by seriesDim, the fill by the deeper fill dim.\n    const dim = seriesDim(selectedDataKey, key);\n    const fillDim = seriesFillDim(selectedDataKey, key);\n\n    return {\n      id: `__mini-${key}`,\n      type: \"line\",\n      xAxisIndex: 1,\n      yAxisIndex: 1,\n      data: data.map((row) => Number(row[key]) || 0),\n      smooth: curve.smooth,\n      step: curve.step,\n      connectNulls: input.connectNulls,\n      silent: true,\n      showSymbol: false,\n      emphasis: { disabled: true },\n      tooltip: { show: false },\n      lineStyle: { color: base, width: 1, opacity: BRUSH_STROKE_OPACITY * dim },\n      areaStyle: {\n        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [\n          { offset: 0, color: withAlpha(base, BRUSH_FILL_OPACITY * fillDim) },\n          { offset: 1, color: withAlpha(base, 0) },\n        ]),\n      },\n      z: 0,\n    };\n  });\n\n  const dataZoom = buildBrushDataZoom({\n    brushBottom,\n    brushHeight,\n    brushRange: ctx.brushRange,\n    fillerColor: withAlpha(tokens.foreground, BRUSH_FILLER_OPACITY),\n  });\n\n  return { miniGrid, miniXAxis, miniYAxis, miniSeries, dataZoom };\n}\n\n// Loading skeleton — a gray bar wave AND a gray line over it, because THIS chart\n// is bars + lines (unlike the pure bar or area chart, whose skeleton is a single\n// shape). Both are swept by the SAME diagonal shimmer window (see the shimmer rAF:\n// one shared absolute-pixel clip gradient drives the bar fill and the line stroke,\n// so they light up together).\nfunction buildLoadingOption(\n  ctx: OptionBuildContext,\n  frame: { grid: GridComponentOption; xAxis: XAxisOption; yAxis: YAxisOption },\n): EChartsOption {\n  const { tokens } = ctx.resolved;\n\n  return {\n    animation: false,\n    grid: frame.grid,\n    xAxis: frame.xAxis,\n    yAxis: frame.yAxis,\n    tooltip: { show: false },\n    series: [\n      {\n        id: \"__loading\",\n        type: \"bar\",\n        data: ctx.loadingData(),\n        barCategoryGap: \"30%\",\n        // Invisible until the first shimmer tick positions the clip window.\n        itemStyle: {\n          color: withAlpha(tokens.foreground, 0),\n          borderRadius: [DEFAULT_BAR_RADIUS, DEFAULT_BAR_RADIUS, 0, 0],\n        },\n        silent: true,\n        z: 1,\n      },\n      {\n        id: \"__loading-line\",\n        type: \"line\",\n        data: ctx.loadingLineData(),\n        smooth: true,\n        showSymbol: false,\n        symbol: \"none\",\n        // Invisible until the first shimmer tick positions the clip window.\n        lineStyle: { color: withAlpha(tokens.foreground, 0), width: LOADING_LINE_WIDTH },\n        silent: true,\n        z: 2,\n      },\n    ],\n  };\n}\n\nfunction buildBarSeries(ctx: OptionBuildContext): BarSeriesOption[] {\n  const { data, config, bars, animationType, selectedDataKey, resolved, barGap, barCategoryGap } =\n    ctx;\n  // While a series is click-selected the selection dim owns the canvas — hover\n  // highlighting is suspended until it clears (see the emphasis/blur switch below).\n  const hasSelection = selectedDataKey !== null;\n\n  return bars.map((bar) => {\n    const key = bar.dataKey;\n    const slots = resolved.series[key] ?? [\"rgba(120, 120, 120, 1)\"];\n    const base = slots[0];\n    const multiColor = slots.length > 1;\n    const fillDim = seriesFillDim(selectedDataKey, key);\n    const values = data.map((row) => Number(row[key]) || 0);\n    const barAnim = bar.animationType ?? animationType;\n\n    // Glow — a soft canvas shadow behind the bar, the analogue of the Recharts\n    // feGaussianBlur. A single-color bar tints its whole shadow with its color;\n    // a multi-color bar samples the gradient PER-DATUM so each column's halo\n    // follows the palette at its x-position (a single flat tint would be wrong).\n    const glowSeriesStyle =\n      bar.glow && !multiColor\n        ? { shadowBlur: BAR_GLOW_BLUR, shadowColor: withAlpha(base, BAR_GLOW_OPACITY) }\n        : {};\n\n    const dataPoints =\n      bar.glow && multiColor\n        ? values.map((value, i) => {\n            const t = values.length > 1 ? i / (values.length - 1) : 0;\n            return {\n              value,\n              itemStyle: {\n                shadowBlur: BAR_GLOW_BLUR,\n                shadowColor: withAlpha(sampleGradient(slots, t), BAR_GLOW_OPACITY),\n              },\n            };\n          })\n        : values;\n\n    const series: BarSeriesOption = {\n      id: key,\n      name: seriesLabel(config, key),\n      type: \"bar\",\n      data: dataPoints,\n      barGap,\n      barCategoryGap,\n      cursor: bar.isClickable ? \"pointer\" : \"default\",\n      // Bars sit behind lines (z 2 vs 3), matching the Recharts twin's JSX order.\n      z: 2,\n      itemStyle: {\n        color: barFillPaint(bar.variant, slots),\n        opacity: fillDim,\n        // Stripped bars are square (their solid top strip lives in the fill);\n        // every other variant rounds all four corners like the Recharts twin.\n        borderRadius: bar.variant === \"stripped\" ? 0 : bar.radius,\n        ...glowSeriesStyle,\n      },\n      // Per-datum grow-in: bars rise from the baseline, staggered by animationType.\n      // Only takes effect on the reveal push (top-level `animation: true`); every\n      // later push sends `animation: false`, so the stagger is dormant then.\n      animationDuration: BAR_GROW_DURATION,\n      animationEasing: \"cubicOut\",\n      animationDelay: (idx: number) => barStaggerDelay(barAnim, idx, data.length),\n      // Hover-highlight dims the OTHER columns of this bar via ECharts-native\n      // emphasis — `focus: \"self\"` blurs siblings, `blurScope: \"series\"` keeps\n      // the blur inside this bar (so lines and other bars are untouched). This is\n      // the canvas equivalent of the twin's per-column dim, driven natively so no\n      // option is pushed mid-hover. Once a series is click-SELECTED, hover\n      // highlighting is suspended: focus drops to \"none\" and the blur is removed so\n      // hovering no longer re-dims anything (the option rebuilds on selection\n      // change, so this build-time switch flips automatically and resumes on clear).\n      emphasis:\n        bar.enableHoverHighlight && !hasSelection\n          ? { focus: \"self\", blurScope: \"series\" }\n          : { focus: \"none\" },\n      blur:\n        bar.enableHoverHighlight && !hasSelection\n          ? { itemStyle: { opacity: SELECTION_DIM_FILL } }\n          : undefined,\n    };\n\n    return bar.barProps ? { ...series, ...bar.barProps } : series;\n  });\n}\n\nfunction buildLineSeries(ctx: OptionBuildContext): LineSeriesOption[] {\n  const { data, config, lines, curveType, selectedDataKey, resolved } = ctx;\n\n  return lines.map((line) => {\n    const key = line.dataKey;\n    const slots = resolved.series[key] ?? [\"rgba(120, 120, 120, 1)\"];\n    const paint = seriesPaint(slots);\n    const dim = seriesDim(selectedDataKey, key);\n    const curve = curveConfig(line.curveType ?? curveType);\n    const values = data.map((row) => Number(row[key]) || 0);\n\n    const restingDot = dotStyle(line.dotVariant, paint, resolved.tokens.background);\n    const activeDot = dotStyle(line.activeDotVariant, paint, resolved.tokens.background);\n    const restingVisible = line.dotVariant !== \"none\";\n    const multiColor = slots.length > 1;\n\n    // Multi-color lines tint each symbol with the gradient's color at its own\n    // x-position (per-datum itemStyle), like the Recharts dots. The stroke keeps\n    // the full horizontal gradient.\n    const dataPoints = !multiColor\n      ? values\n      : values.map((value, i) => {\n          const t = values.length > 1 ? i / (values.length - 1) : 0;\n          const pointColor = sampleGradient(slots, t);\n          return {\n            value,\n            itemStyle: {\n              ...dotItemStyle(\n                restingVisible ? line.dotVariant : line.activeDotVariant,\n                pointColor,\n                resolved.tokens.background,\n              ),\n              opacity: dim,\n            },\n            emphasis: {\n              itemStyle: {\n                ...dotItemStyle(\n                  line.activeDotVariant === \"none\" ? \"default\" : line.activeDotVariant,\n                  pointColor,\n                  resolved.tokens.background,\n                ),\n                opacity: 1,\n              },\n            },\n          };\n        });\n\n    const series: LineSeriesOption = {\n      id: key,\n      name: seriesLabel(config, key),\n      type: \"line\",\n      data: dataPoints,\n      smooth: curve.smooth,\n      step: curve.step,\n      connectNulls: line.connectNulls,\n      cursor: line.isClickable ? \"pointer\" : \"default\",\n      // By default ECharts fires mouse events only on the symbols — this makes\n      // the whole polyline clickable, like the Recharts <Line>.\n      // (`true` covers both; the deprecated `triggerLineEvent` did the same.)\n      triggerEvent: line.isClickable,\n      showSymbol: restingVisible,\n      symbol: \"circle\",\n      symbolSize: restingVisible ? restingDot.size : activeDot.size,\n      z: 3,\n      // The glow is NOT a shadowBlur here — a single shadowColor can't follow the\n      // horizontal gradient. It is a stack of silent wide overlay copies painted\n      // with the same gradient, added by buildLineGlowSeries and rendered under\n      // this crisp stroke.\n      lineStyle: {\n        color: paint,\n        width: STROKE_WIDTH,\n        opacity: dim,\n        type: line.strokeVariant === \"solid\" ? \"solid\" : DASH_PATTERN,\n        dashOffset: 0,\n      },\n      itemStyle: multiColor\n        ? { opacity: dim }\n        : {\n            ...(restingVisible ? restingDot.itemStyle : activeDot.itemStyle),\n            opacity: dim,\n          },\n      emphasis: {\n        focus: \"none\",\n        scale: restingVisible ? activeDot.size / Math.max(restingDot.size, 1) : 1,\n        ...(multiColor ? {} : { itemStyle: { ...activeDot.itemStyle, opacity: 1 } }),\n      },\n    };\n\n    return line.lineProps ? { ...series, ...line.lineProps } : series;\n  });\n}\n\n// Glow overlay copies for glowing lines — a few SILENT, symbol-less line series\n// painted with the SAME gradient as the real stroke, widening and fading outward\n// (see LINE_GLOW_LAYERS). Stacked UNDER the crisp line, the wide low-alpha\n// gradient strokes read as a soft colored blur that follows the series color along\n// its whole length — the canvas analogue of the Recharts feGaussianBlur, and\n// unlike a single-tint shadowColor it stays color-faithful on multi-stop series.\n//\n// These are appended AFTER the main bar+line series (so the seriesIndex→key map\n// the click handler relies on is unchanged) and pushed under the lines by z: the\n// widest/faintest layer paints first, the real line last.\nfunction buildLineGlowSeries(ctx: OptionBuildContext): LineSeriesOption[] {\n  const { data, lines, curveType, selectedDataKey, resolved } = ctx;\n\n  return lines\n    .filter((line) => line.glow)\n    .flatMap((line) => {\n      const key = line.dataKey;\n      const slots = resolved.series[key] ?? [\"rgba(120, 120, 120, 1)\"];\n      const paint = seriesPaint(slots);\n      const dim = seriesDim(selectedDataKey, key);\n      const curve = curveConfig(line.curveType ?? curveType);\n      const values = data.map((row) => Number(row[key]) || 0);\n\n      // Widest (faintest) first so it paints beneath the tighter, brighter layers.\n      return [...LINE_GLOW_LAYERS].reverse().map((layer, i) => ({\n        id: `__glow-${key}-${i}`,\n        type: \"line\" as const,\n        data: values,\n        smooth: curve.smooth,\n        step: curve.step,\n        connectNulls: line.connectNulls,\n        silent: true,\n        showSymbol: false,\n        symbol: \"none\" as const,\n        emphasis: { disabled: true },\n        tooltip: { show: false },\n        // Sits above the bars (z 2) but below the crisp line (z 3).\n        z: 2,\n        lineStyle: {\n          color: paint,\n          width: layer.width,\n          opacity: layer.opacity * dim,\n          // The halo itself. Full-alpha color: the element opacity above already\n          // scales its shadow, so pre-dimming here would square the alpha.\n          shadowBlur: layer.blur,\n          shadowColor: sampleGradient(slots, 0.5),\n          cap: \"round\" as const,\n          join: \"round\" as const,\n        },\n      }));\n    });\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Live imperative state — everything the ECharts event handlers, rAF loops, and\n// theme/resize repushes read or write OUTSIDE the React render cycle, grouped in\n// one ref-stable object so the whole imperative surface is visible at a glance.\n// None of it is render output, which is exactly why it is not React state.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype LiveState = {\n  resolved: ResolvedColors | null; // colors read off the live DOM — feeds builds and rAF loops\n  hasRevealed: boolean; // the intro draw-in already played on this chart instance\n  revealEndsAt: number; // performance.now() timestamp when the entrance settles\n  loadingRows: number[] | null; // skeleton BAR heights, lazily rolled and re-rolled per shimmer sweep\n  loadingLineRows: number[] | null; // skeleton LINE values, an independent walk from the bars\n  categories: string[]; // x labels of the last build, for the brush label pills\n  dataLength: number; // row count, for the datazoom index math\n  brushRange: BrushRange; // live zoom window — carried through every rebuild\n  brushGeom: BrushGeometry | null; // brush footer layout of the last build\n  brushOverlay: BrushOverlayElements | null; // zrender elements, owned by syncBrushOverlay\n  brushHover: { inside: boolean; left: boolean; right: boolean };\n  // Latest callbacks/flags for the imperative ECharts event handlers.\n  handlers: {\n    onBrushChange?: (range: { startIndex: number; endIndex: number }) => void;\n    onSelectionChange?: (key: string | null) => void;\n    clickableKeys: Set<string>;\n    selectedDataKey: string | null;\n    brushFormatLabel?: (value: string, index: number) => string;\n    seriesKeys: string[];\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 composed chart, exposing a\n * compound-as-config API so its JSX reads identically to the Recharts twin. The\n * root owns the data, config, selection state, loading skeleton, intro reveal,\n * and optional zoom brush; every visual part — `<Bar>`, `<Line>`, `<XAxis>`,\n * `<YAxis>`, `<Grid>`, `<Tooltip>`, `<Legend>`, plus `<Dot>` / `<ActiveDot>`\n * inside a `<Line>` — is composed as a declarative child that renders nothing.\n * The root walks those children by reference and drives a single imperative\n * ECharts instance. Fully self-contained: its only dependencies are `react`,\n * `echarts`, and `motion`.\n */\nexport function EChartsComposedChart<TData extends Record<string, unknown>>({\n  data,\n  config,\n  xDataKey,\n  className,\n  curveType = \"linear\",\n  animation = true,\n  animationType = \"left-to-right\",\n  barGap,\n  barCategoryGap,\n  defaultSelectedDataKey = null,\n  onSelectionChange,\n  isLoading = false,\n  loadingBars = LOADING_DEFAULT_BARS,\n  chartOptions,\n  children,\n}: EChartsComposedChartProps<TData>) {\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 forced an extra render pass and an effect whose\n  // only job was to trigger the option push — the \"chain of computations\"\n  // react.dev/learn/you-might-not-need-an-effect warns about. The object\n  // identity is stable for the component's lifetime.\n  const live = useRef<LiveState>({\n    resolved: null,\n    hasRevealed: false,\n    revealEndsAt: 0,\n    loadingRows: null,\n    loadingLineRows: null,\n    categories: [],\n    dataLength: 0,\n    brushRange: { start: 0, end: 100 },\n    brushGeom: null,\n    brushOverlay: null,\n    brushHover: { inside: false, left: false, right: false },\n    handlers: {\n      onBrushChange: undefined,\n      onSelectionChange,\n      clickableKeys: new Set<string>(),\n      selectedDataKey: defaultSelectedDataKey,\n      brushFormatLabel: undefined,\n      seriesKeys: [],\n    },\n    repush: () => {},\n  }).current;\n\n  // Skeleton rows roll lazily on first use — an impure useRef initializer would\n  // re-roll Math.random() on every render. The bar heights and the line values are\n  // independent walks so the skeleton line rides over the bars rather than tracing\n  // their tops; both re-roll together while the shimmer window is off-screen.\n  const loadingData = useCallback(\n    () => (live.loadingRows ??= getLoadingData(loadingBars)),\n    [live, loadingBars],\n  );\n  const loadingLineData = useCallback(\n    () => (live.loadingLineRows ??= getLoadingData(loadingBars)),\n    [live, loadingBars],\n  );\n  const shouldReduceMotion = useReducedMotion();\n\n  const [selectedDataKey, setSelectedDataKey] = useState<string | null>(defaultSelectedDataKey);\n\n  // ── Declarative config, collected from children by reference ─────────────────\n  const collected = useMemo(() => collectConfig(children), [children]);\n  const {\n    bars,\n    lines,\n    xAxis: xAxisSlot,\n    yAxis: yAxisSlot,\n    showGrid,\n    tooltip: tooltipSlot,\n    legend: legendSlot,\n    brush: brushSlot,\n  } = collected;\n\n  // Brush is declared as a <Brush> child now — presence replaces the old\n  // showBrush prop, and its props feed the same internals.\n  const showBrush = brushSlot.present;\n  const brushHeight = brushSlot.height ?? 56;\n\n  // seriesKeys are ordered bars-then-lines, matching the series array — the click\n  // handler recovers a series key from a polygon click's seriesIndex by position.\n  const seriesKeys = useMemo(\n    () => [...bars.map((bar) => bar.dataKey), ...lines.map((line) => line.dataKey)],\n    [bars, lines],\n  );\n\n  // x category key: <XAxis dataKey> → root xDataKey → first data column no series claims.\n  const xCategoryKey = useMemo(() => {\n    if (xAxisSlot.dataKey) return xAxisSlot.dataKey;\n    if (xDataKey) return xDataKey as string;\n    const firstRow = data[0];\n    if (firstRow) {\n      const claimed = new Set(seriesKeys);\n      const found = Object.keys(firstRow).find((key) => !claimed.has(key));\n      if (found) return found;\n    }\n    return \"\";\n  }, [xAxisSlot.dataKey, xDataKey, data, seriesKeys]);\n\n  // The intro draw-in follows the first declared series' setting, falling back to\n  // the root default.\n  const effectiveAnimation = bars[0]?.animationType ?? lines[0]?.animationType ?? animationType;\n\n  const css = useMemo(() => buildChartCss(chartId, config), [chartId, config]);\n\n  // Which series may be clicked to toggle selection (consulted by the click handler).\n  const clickableKeys = useMemo(\n    () =>\n      new Set([\n        ...bars.filter((bar) => bar.isClickable).map((bar) => bar.dataKey),\n        ...lines.filter((line) => line.isClickable).map((line) => line.dataKey),\n      ]),\n    [bars, lines],\n  );\n\n  // Refresh the handlers' snapshot of the latest callbacks/flags every render.\n  live.handlers = {\n    onBrushChange: brushSlot.onChange,\n    onSelectionChange,\n    clickableKeys,\n    selectedDataKey,\n    brushFormatLabel: brushSlot.formatLabel,\n    seriesKeys,\n  };\n  live.dataLength = data.length;\n\n  const toggleSelection = useCallback(\n    (key: string) => {\n      setSelectedDataKey((prev) => {\n        const next = prev === key ? null : key;\n        onSelectionChange?.(next);\n        return next;\n      });\n    },\n    [onSelectionChange],\n  );\n\n  // Reposition the brush overlays from the live refs — safe to call from drag\n  // events, hover tracking, and pushes alike, since it never touches setOption.\n  const syncBrushOverlayNow = useCallback(() => {\n    const chart = echartsRef.current;\n    if (!chart) return;\n\n    const geom = live.brushGeom;\n    const tokens = live.resolved?.tokens;\n    if (!geom || !tokens) {\n      syncBrushOverlay(chart, live, null);\n      return;\n    }\n\n    const range = live.brushRange;\n    const categories = live.categories;\n    const format = live.handlers.brushFormatLabel;\n    const lastIndex = Math.max(categories.length - 1, 0);\n    const startIndex = Math.round((range.start / 100) * lastIndex);\n    const endIndex = Math.round((range.end / 100) * lastIndex);\n    const labels =\n      format && categories.length\n        ? {\n            start: format(categories[startIndex] ?? \"\", startIndex),\n            end: format(categories[endIndex] ?? \"\", endIndex),\n          }\n        : null;\n\n    syncBrushOverlay(chart, live, {\n      range,\n      geom,\n      size: { width: chart.getWidth(), height: chart.getHeight() },\n      tokens,\n      labels,\n      showLabels: live.brushHover.inside,\n      hover: live.brushHover,\n    });\n  }, [live]);\n\n  // ── Option builder ─────────────────────────────────────────────────────────\n  // Thin orchestrator over the pure builders above: snapshot the imperative\n  // surface (refs, renderer size) into an OptionBuildContext, then assemble.\n  const buildOption = useCallback((): EChartsOption => {\n    const resolved = live.resolved;\n    if (!resolved) return {};\n\n    const categories = data.map((row) => String(row[xCategoryKey]));\n    live.categories = categories;\n\n    const ctx: OptionBuildContext = {\n      data,\n      config,\n      bars,\n      lines,\n      seriesKeys,\n      curveType,\n      animationType,\n      barGap,\n      barCategoryGap,\n      selectedDataKey,\n      showGrid,\n      xAxisSlot,\n      yAxisSlot,\n      tooltipSlot,\n      legendSlot,\n      isLoading,\n      loadingData,\n      loadingLineData,\n      showBrush,\n      brushHeight,\n      resolved,\n      categories,\n      brushRange: live.brushRange,\n    };\n\n    const { grid, brushBottom } = buildChartLayout(ctx);\n    live.brushGeom = showBrush ? { bottom: brushBottom, height: brushHeight } : null;\n\n    const { xAxis, yAxis } = buildMainAxes(ctx);\n\n    if (isLoading) return buildLoadingOption(ctx, { grid, xAxis, yAxis });\n\n    const brush = showBrush ? buildBrushOption(ctx, brushBottom) : null;\n\n    return {\n      animation: false,\n      grid: brush ? [grid, brush.miniGrid] : grid,\n      xAxis: brush ? [xAxis, brush.miniXAxis] : xAxis,\n      yAxis: brush ? [yAxis, brush.miniYAxis] : yAxis,\n      tooltip: buildTooltipOption(ctx),\n      dataZoom: brush?.dataZoom,\n      // bars before lines so the polyline strokes read above the columns. Line\n      // glow copies come AFTER the main series (their z keeps them under the lines\n      // and over the bars) so the seriesIndex→key map for clicks stays intact.\n      series: [\n        ...buildBarSeries(ctx),\n        ...buildLineSeries(ctx),\n        ...buildLineGlowSeries(ctx),\n        ...(brush?.miniSeries ?? []),\n      ],\n    };\n  }, [\n    live,\n    data,\n    config,\n    bars,\n    lines,\n    seriesKeys,\n    xCategoryKey,\n    curveType,\n    animationType,\n    barGap,\n    barCategoryGap,\n    selectedDataKey,\n    showGrid,\n    xAxisSlot,\n    yAxisSlot,\n    tooltipSlot,\n    legendSlot,\n    isLoading,\n    loadingData,\n    loadingLineData,\n    showBrush,\n    brushHeight,\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\n      // no-op fire would land one frame into the intro and stomp the reveal —\n      // only 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    chart.on(\"click\", (params) => {\n      const { clickableKeys: clickable, seriesKeys: keys } = live.handlers;\n      const p = params as { seriesId?: string; seriesIndex?: number };\n      // Symbol clicks carry seriesId; line-polygon clicks (triggerEvent)\n      // only carry seriesIndex — recover the key by position. Main series (bars\n      // then lines) come first in the series array, so the index maps directly.\n      const id =\n        p.seriesId ?? (typeof p.seriesIndex === \"number\" ? keys[p.seriesIndex] : undefined);\n      if (typeof id === \"string\" && clickable.has(id)) toggleSelection(id);\n    });\n\n    chart.on(\"datazoom\", () => {\n      const option = chart.getOption() as { dataZoom?: { start?: number; end?: number }[] };\n      const zoom = option.dataZoom?.[0];\n      if (!zoom) return;\n\n      // Ride the selection — pure zrender updates, so the drag stays 1:1.\n      live.brushRange = { start: zoom.start ?? 0, end: zoom.end ?? 100 };\n      syncBrushOverlayNow();\n\n      const { onBrushChange: onChange } = live.handlers;\n      if (!onChange) return;\n      const len = live.dataLength;\n      const startIndex = Math.round(((zoom.start ?? 0) / 100) * (len - 1));\n      const endIndex = Math.round(((zoom.end ?? 100) / 100) * (len - 1));\n      onChange({ startIndex, endIndex });\n    });\n\n    // Hover tracking for the overlay: labels show while the pointer is over the\n    // brush, and each pill brightens when the pointer is near its edge.\n    const zr = chart.getZr();\n    const applyHover = (next: { inside: boolean; left: boolean; right: boolean }) => {\n      const prev = live.brushHover;\n      if (prev.inside === next.inside && prev.left === next.left && prev.right === next.right) {\n        return;\n      }\n      live.brushHover = next;\n      syncBrushOverlayNow();\n    };\n    const onZrMove = (event: { offsetX?: number; offsetY?: number }) => {\n      const geom = live.brushGeom;\n      if (!geom) return;\n      const x = event.offsetX ?? -1;\n      const y = event.offsetY ?? -1;\n      const top = chart.getHeight() - geom.bottom - geom.height;\n      const inside = y >= top - 4 && y <= top + geom.height + 4;\n      const trackLeft = 8;\n      const trackWidth = Math.max(chart.getWidth() - 16, 1);\n      const { start, end } = live.brushRange;\n      const selectionLeft = trackLeft + (trackWidth * start) / 100;\n      const selectionRight = trackLeft + (trackWidth * end) / 100;\n      applyHover({\n        inside,\n        left: inside && Math.abs(x - selectionLeft) <= 8,\n        right: inside && Math.abs(x - selectionRight) <= 8,\n      });\n    };\n    const onZrOut = () => applyHover({ inside: false, left: false, right: false });\n    zr.on(\"mousemove\", onZrMove);\n    zr.on(\"globalout\", onZrOut);\n\n    return () => {\n      zr.off(\"mousemove\", onZrMove);\n      zr.off(\"globalout\", onZrOut);\n      resizeObserver.disconnect();\n      themeObserver.disconnect();\n      chart.dispose();\n      echartsRef.current = null;\n      // The overlay elements died with the zrender instance.\n      live.brushOverlay = 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, seriesKeys);\n\n    const push = (withEntrance: boolean) => {\n      const option = buildOption();\n      const merged = chartOptions ? { ...option, ...chartOptions } : option;\n      Object.assign(merged, {\n        animation: withEntrance,\n        animationDuration: REVEAL_DURATION,\n        animationDurationUpdate: 0,\n      });\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(merged as EChartsOption, { notMerge: true });\n      // Overlays live outside the option — reposition them after every push.\n      syncBrushOverlayNow();\n    };\n\n    // Intro reveal — ECharts' native progressive draw, enabled only for the first\n    // real render: lines trace in, bars grow up from their baseline, dots pop up\n    // as the line front passes. Every later push (selection, theme, zoom) applies\n    // instantly, since notMerge would otherwise replay the entrance on each of\n    // them. A loading cycle re-arms it: the Recharts twin unmounts its series\n    // while loading and replays the intro on remount, so data → loading → data\n    // 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 && effectiveAnimation !== \"none\" && !shouldReduceMotion;\n    if (revealEnabled) live.revealEndsAt = performance.now() + REVEAL_DURATION;\n    push(revealEnabled);\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.\n    live.repush = () => {\n      live.resolved = resolveColors(container, config, seriesKeys);\n      push(false);\n    };\n  }, [\n    live,\n    buildOption,\n    chartOptions,\n    isLoading,\n    animation,\n    effectiveAnimation,\n    shouldReduceMotion,\n    config,\n    seriesKeys,\n    syncBrushOverlayNow,\n  ]);\n\n  // ── Default tooltip index — show the tooltip at a fixed point with no hover ───\n  useEffect(() => {\n    const chart = echartsRef.current;\n    if (!chart || isLoading) return;\n    const index = tooltipSlot.defaultIndex;\n    if (!tooltipSlot.present || index == null) return;\n\n    // Let the reveal settle before parking the tooltip, so showTip doesn't fight\n    // the entrance animation.\n    const delay = Math.max(0, live.revealEndsAt - performance.now());\n    const timer = setTimeout(() => {\n      chart.dispatchAction({ type: \"showTip\", seriesIndex: 0, dataIndex: index });\n    }, delay + 60);\n\n    return () => {\n      clearTimeout(timer);\n      chart.dispatchAction({ type: \"hideTip\" });\n    };\n  }, [live, isLoading, tooltipSlot.present, tooltipSlot.defaultIndex]);\n\n  // ── Animated dashed stroke — rAF sweeps the dash offset while unselected ─────\n  useEffect(() => {\n    const chart = echartsRef.current;\n    if (!chart || isLoading) return;\n    const animatedKeys = lines\n      .filter((line) => line.strokeVariant === \"animated-dashed\")\n      .map((line) => line.dataKey);\n    const hasSelection = selectedDataKey !== null;\n    if (animatedKeys.length === 0 || hasSelection) return;\n\n    let raf = 0;\n    let delayTimer: ReturnType<typeof setTimeout> | undefined;\n    const begin = () => {\n      const loopStart = performance.now();\n      const tick = (now: number) => {\n        // 0 → -DASH_PERIOD per second, so the dashes crawl one full period a second.\n        const offset = -(((now - loopStart) / 1000) % 1) * DASH_PERIOD;\n        chart.setOption(\n          { series: animatedKeys.map((id) => ({ id, lineStyle: { dashOffset: offset } })) },\n          { silent: true, lazyUpdate: true },\n        );\n        raf = requestAnimationFrame(tick);\n      };\n      raf = requestAnimationFrame(tick);\n    };\n\n    // Per-frame setOption churn fights the intro draw-in (each update pass\n    // recomputes the reveal clip, crawling it to a standstill) — hold the dash\n    // sweep until the entrance has finished.\n    const delay = Math.max(0, live.revealEndsAt - performance.now());\n    if (delay > 0) delayTimer = setTimeout(begin, delay + 50);\n    else begin();\n\n    return () => {\n      if (delayTimer !== undefined) clearTimeout(delayTimer);\n      cancelAnimationFrame(raf);\n    };\n  }, [live, lines, selectedDataKey, isLoading]);\n\n  // ── Loading shimmer — rAF sweeps a bright clip window across the skeleton ─────\n  useEffect(() => {\n    const chart = echartsRef.current;\n    if (!chart || !isLoading) return;\n\n    let raf = 0;\n    let lastPhase = 0;\n    const start = performance.now();\n    const tick = (now: number) => {\n      const phase = ((((now - start) / LOADING_ANIMATION_DURATION) % 1) + 1) % 1;\n      // Wrapped past 1 → the window is off-screen; swap in fresh random data for\n      // BOTH the bars and the line so they regenerate together, unseen.\n      if (phase < lastPhase) {\n        live.loadingRows = getLoadingData(loadingBars);\n        live.loadingLineRows = getLoadingData(loadingBars);\n      }\n      lastPhase = phase;\n\n      // Read tokens per frame, so a theme flip mid-loading retints the shimmer.\n      const foreground = live.resolved?.tokens.foreground ?? \"rgba(120, 120, 120, 1)\";\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°. The gradient uses ABSOLUTE pixel coordinates (global\n      // gradient), so the window sits at the same place for every bar AND the line\n      // — one shared shimmer window revealing the whole skeleton as a clean\n      // diagonal band. Both clips are built from the SAME `center`, so the bars and\n      // the line light up in lockstep; only the peak alpha differs (a thin line\n      // needs more than a wide bar to read at the same brightness).\n      const maxT = (w + h) / (2 * w);\n      const center = phase * (maxT + 2 * LOADING_SHIMMER_BAND) - LOADING_SHIMMER_BAND;\n      const barClip = new echarts.graphic.LinearGradient(\n        0,\n        0,\n        w,\n        w,\n        shimmerWindowStops(center, foreground, LOADING_BAR_MAX_OPACITY),\n        true,\n      );\n      const lineClip = new echarts.graphic.LinearGradient(\n        0,\n        0,\n        w,\n        w,\n        shimmerWindowStops(center, foreground, LOADING_LINE_MAX_OPACITY),\n        true,\n      );\n      chart.setOption(\n        {\n          series: [\n            { id: \"__loading\", data: loadingData(), itemStyle: { color: barClip } },\n            { id: \"__loading-line\", data: loadingLineData(), lineStyle: { color: lineClip } },\n          ],\n        },\n        { silent: true, lazyUpdate: true },\n      );\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [live, isLoading, loadingBars, loadingData, loadingLineData]);\n\n  // ── Legend overlay position ──────────────────────────────────────────────────\n  // Insets match the Recharts legend's breathing room inside the plot frame.\n  const legendStyle: CSSProperties = {\n    position: \"absolute\",\n    left: 16,\n    right: 16,\n    pointerEvents: \"auto\",\n    ...(legendSlot.verticalAlign === \"top\"\n      ? { top: 12 }\n      : legendSlot.verticalAlign === \"bottom\"\n        ? { bottom: showBrush ? brushHeight + 16 : 12 }\n        : { top: \"50%\", transform: \"translateY(-50%)\" }),\n  };\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      {legendSlot.present && !isLoading && (\n        <LegendOverlay\n          seriesKeys={seriesKeys}\n          config={config}\n          variant={legendSlot.variant}\n          align={legendSlot.align}\n          verticalAlign={legendSlot.verticalAlign}\n          selectedKey={selectedDataKey}\n          hoveredKey={null}\n          isClickable={legendSlot.isClickable}\n          onToggle={toggleSelection}\n          style={legendStyle}\n        />\n      )}\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\n// Composible parts attached as statics, so the chart reads as a single\n// dot-notation namespace: <EChartsComposedChart.Bar />, .Line, .Brush, …\nEChartsComposedChart.Bar = Bar;\nEChartsComposedChart.Line = Line;\nEChartsComposedChart.Dot = Dot;\nEChartsComposedChart.ActiveDot = ActiveDot;\nEChartsComposedChart.XAxis = XAxis;\nEChartsComposedChart.YAxis = YAxis;\nEChartsComposedChart.Grid = Grid;\nEChartsComposedChart.Tooltip = Tooltip;\nEChartsComposedChart.Legend = Legend;\nEChartsComposedChart.Brush = Brush;\n",
      "type": "registry:component",
      "target": "components/evilcharts/charts/echarts-composed-chart.tsx"
    }
  ],
  "type": "registry:component"
}