{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "echarts-line-chart",
  "description": "Line chart component rendered with Apache ECharts",
  "dependencies": [
    "echarts",
    "motion"
  ],
  "registryDependencies": [
    "@evilcharts/echarts-chart",
    "@evilcharts/echarts-dot",
    "@evilcharts/echarts-tooltip",
    "@evilcharts/echarts-legend",
    "@evilcharts/echarts-brush"
  ],
  "files": [
    {
      "path": "src/registry/charts/echarts-line-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 {\n  dotItemStyle,\n  dotStyle,\n  sampleGradient,\n  type DotItemStyleOption,\n  type DotVariant,\n} from \"@/registry/ui/echarts-dot\";\nimport { LegendOverlay, type LegendVariant } from \"@/registry/ui/echarts-legend\";\nimport { LineChart, type LineSeriesOption } 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 {\n  ChartConfig,\n  DotVariant,\n  LegendVariant,\n  TooltipPosition,\n  TooltipRoundness,\n  TooltipVariant,\n};\n\n// Modular registration keeps the bundle lean — only the pieces this chart needs.\n// `DataZoomComponent` bundles both the slider (brush footer) and inside (wheel/drag)\n// zoom. The brush's frame/handles/labels are raw zrender elements, not the\n// graphic component — see syncBrushOverlay. No GraphicComponent is registered.\necharts.use([LineChart, GridComponent, TooltipComponent, DataZoomComponent, CanvasRenderer]);\n\ntype EChartsInstance = ReturnType<typeof echarts.init>;\n\n// The exact option surface this chart uses — line series, grid, tooltip, and\n// 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  LineSeriesOption | GridComponentOption | TooltipComponentOption | 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// DotItemStyleOption now lives in @/registry/ui/echarts-dot and is imported at\n// the top of this file.\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Constants\n// ─────────────────────────────────────────────────────────────────────────────\n\nconst STROKE_WIDTH = 0.8; // default series stroke — <Line strokeWidth> overrides it\nconst LOADING_ANIMATION_DURATION = 2000; // shimmer loop, in milliseconds\nconst REVEAL_DURATION = 1000; // intro draw-in length, in milliseconds\n// NOTE: the intro draw-in runs ECharts' RAW default entrance animation. Custom\n// easing was tried and abandoned in the area twin — ECharts hardcodes the\n// line-entrance clip to linear and ignores animationEasing at every level.\nconst LOADING_DEFAULT_POINTS = 14;\n// Buffer line: the last segment renders as this dash while the rest stays solid,\n// echoing the Recharts twin's 4px dash / 3px gap forecast tail.\nconst BUFFER_DASH: [number, number] = [4, 3];\n\n// <Line glowing> glow. Canvas has no SVG blur filter over a whole shape, so the\n// glow is built from SILENT, stacked copies of the line laid UNDER the real one.\n//\n// The layers are all the SAME NARROW WIDTH on purpose. A wide translucent stroke\n// has a HARD edge, so widening each copy (the obvious approach) paints concentric\n// contour rings, not a glow — no number of layers hides it, because every layer\n// contributes another visible boundary. Here each copy stays hidden beneath the\n// real line and the visible halo comes entirely from its canvas `shadowBlur`,\n// which is a true gaussian: edgeless by construction, and summing several at\n// different radii stays perfectly smooth.\n//\n// The trade: a canvas shadow is a single flat color, so the halo is cast in the\n// gradient's mid tone (`sampleGradient(slots, 0.5)`) rather than tracking the\n// stroke's color along its length. The stroke copies themselves still carry the\n// real gradient, so the bright core reads correctly; only the soft bloom is one\n// hue. Smooth beats hue-accurate here. `symbolPad` grows the glow disc under each\n// visible dot so haloed markers bloom too.\nconst GLOW_LAYERS: { width: number; opacity: number; blur: number; symbolPad: number }[] = [\n  { width: 2, opacity: 0.9, blur: 5, symbolPad: 2 },\n  { width: 2, opacity: 0.6, blur: 12, symbolPad: 6 },\n  { width: 2, opacity: 0.38, blur: 24, symbolPad: 11 },\n  { width: 2, opacity: 0.22, blur: 42, symbolPad: 16 },\n];\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 is CLIPPED to a small sweeping window — only the stroke section\n// inside it exists, everything outside is fully transparent, like a clip-path\n// sliding across the chart.\nconst LOADING_STROKE_OPACITY = 0.5; // outline inside the window, × foreground alpha\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 (evil-brush \"line\" variant)\nconst BRUSH_FILLER_OPACITY = 0; // selected-range wash — evil-brush draws none\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Public types\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport type StrokeVariant = \"solid\" | \"dashed\" | \"animated-dashed\";\nexport type LineAnimationType =\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 EChartsLineChartProps<TData extends Record<string, unknown>> {\n  data: TData[]; // rows rendered by the chart\n  config: ChartConfig; // series colors + labels\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?: LineAnimationType; // default intro reveal (first <Line> overrides)\n  enableHoverHighlight?: boolean; // hovering a series dims the others, like a temporary selection\n  enableHoverReveal?: boolean; // hovering colors each line up to the pointer's x and mutes the rest\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  loadingPoints?: number; // number of points in the loading skeleton\n  chartOptions?: Record<string, unknown>; // escape hatch merged over the built ECharts option\n  children?: ReactNode; // declarative config — <Line>, <XAxis>, <Grid>, <Tooltip>, <Legend>, <Brush>, …\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Composible parts — DECLARATIVE CONFIG. Every part renders `null`; the root\n// walks `children` by reference (child.type === Line, …) 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 LineProps {\n  dataKey: string; // series key — must exist on the data + config\n  strokeVariant?: StrokeVariant; // stroke style for this line\n  strokeWidth?: number; // stroke thickness in pixels for this line\n  curveType?: CurveType; // curve interpolation — falls back to the root curveType\n  animationType?: LineAnimationType; // intro reveal — first line drives the wrapper wipe\n  connectNulls?: boolean; // join segments across null/missing values\n  isClickable?: boolean; // lets this line be selected by clicking it\n  glowing?: boolean; // applies a soft outer glow to this line\n  enableBufferLine?: boolean; // renders this line's last segment as a dashed buffer\n  children?: ReactNode; // optional <Dot> and <ActiveDot> config\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 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  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.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype LineSeriesConfig = {\n  dataKey: string;\n  strokeVariant: StrokeVariant;\n  strokeWidth: number;\n  curveType?: CurveType;\n  animationType?: LineAnimationType;\n  connectNulls: boolean;\n  isClickable: boolean;\n  glowing: boolean;\n  enableBufferLine: boolean;\n  dotVariant: DotVariant; // \"none\" when no <Dot> child is present\n  activeDotVariant: DotVariant; // \"none\" when no <ActiveDot> child is present\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  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  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 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 === 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        // The Recharts twin defaults a <Line> to a solid stroke (its <Area>\n        // defaults to dashed — a divergence intentionally preserved here).\n        strokeVariant: props.strokeVariant ?? \"solid\",\n        strokeWidth: props.strokeWidth ?? STROKE_WIDTH,\n        curveType: props.curveType,\n        animationType: props.animationType,\n        connectNulls: props.connectNulls ?? false,\n        isClickable: props.isClickable ?? false,\n        glowing: props.glowing ?? false,\n        enableBufferLine: props.enableBufferLine ?? false,\n        dotVariant,\n        activeDotVariant,\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        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 { 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// Dot helpers (DotVariant, DotItemStyleOption, DotStyle, DOT_SIZES, dotItemStyle,\n// dotStyle, sampleGradient) live in @/registry/ui/echarts-dot.\n\n// Builds the stacked glow overlay series for one <Line glowing> (see\n// GLOW_LAYERS). Each copy is silent, tooltip-less, and z-ordered beneath the real\n// line, so the widening low-alpha gradient strokes read as a soft colored blur\n// that tracks the series' gradient exactly like the stroke does. When the line\n// shows resting dots, each copy also draws an oversized faint symbol — coloured\n// per-datum via sampleGradient — so the markers bloom too. `selectionDim` fades\n// the whole glow with its parent when another series is selected; the\n// emphasis/blur styles let it focus/dim WITH its parent under\n// enableHoverHighlight (the root dispatch-links these ids — see companionIdsByKey).\nfunction buildGlowSeries(params: {\n  key: string;\n  paint: string | echarts.graphic.LinearGradient;\n  slots: string[];\n  values: (number | null)[];\n  curve: { smooth: boolean; step: \"middle\" | false };\n  connectNulls: boolean;\n  z: number;\n  selectionDim: number;\n  dotSize: number;\n}): LineSeriesOption[] {\n  const { key, paint, slots, values, curve, connectNulls, z, selectionDim, dotSize } = params;\n  const multiColor = slots.length > 1;\n  const base = slots[0] ?? \"rgba(120, 120, 120, 1)\";\n  const showDots = dotSize > 0;\n\n  return GLOW_LAYERS.map((layer, i): LineSeriesOption => {\n    const glowOpacity = layer.opacity * selectionDim;\n    const blurOpacity = glowOpacity * 0.3;\n    // Per-datum halo colours so a gradient glow tints each dot at its own\n    // x-position, matching sampleGradient on the real dots.\n    const glowData: LinePoint[] =\n      !multiColor || !showDots\n        ? values\n        : values.map((value, idx): LinePoint => {\n            if (value === null) return null;\n            const t = values.length > 1 ? idx / (values.length - 1) : 0;\n            const color = sampleGradient(slots, t);\n            return {\n              value,\n              itemStyle: { color, opacity: glowOpacity },\n              emphasis: { itemStyle: { color, opacity: glowOpacity } },\n            };\n          });\n\n    return {\n      id: `__glow-${i}-${key}`,\n      type: \"line\",\n      data: glowData,\n      smooth: curve.smooth,\n      step: curve.step,\n      connectNulls,\n      silent: true,\n      showSymbol: showDots,\n      symbol: \"circle\",\n      symbolSize: showDots ? dotSize + layer.symbolPad : 0,\n      tooltip: { show: false },\n      z,\n      lineStyle: {\n        color: paint,\n        width: layer.width,\n        opacity: glowOpacity,\n        // Feathers this layer's edge so the stack reads as one smooth falloff\n        // rather than concentric bands (see GLOW_LAYERS).\n        shadowBlur: layer.blur,\n        // Full-alpha shadow color: the element's own `opacity` above already\n        // scales its shadow, so pre-dimming here squares the alpha and washes\n        // the halo out.\n        shadowColor: sampleGradient(slots, 0.5),\n        cap: \"round\",\n        join: \"round\",\n      },\n      itemStyle: multiColor ? { opacity: glowOpacity } : { color: base, opacity: glowOpacity },\n      // Focus/dim WITH the parent line: on the parent's hover the root highlights\n      // these ids (emphasis → normal glow); when another series is hovered the\n      // parent's focus:\"series\" blurs these to a fainter still.\n      emphasis: {\n        focus: \"none\",\n        scale: false,\n        lineStyle: { opacity: glowOpacity },\n        itemStyle: { opacity: glowOpacity },\n      },\n      blur: { lineStyle: { opacity: blurOpacity }, itemStyle: { opacity: blurOpacity } },\n    };\n  });\n}\n\n// Brush overlays (BrushRange, BrushGeometry, BrushOverlayElements,\n// BrushOverlayParams, syncBrushOverlay) live in @/registry/ui/echarts-brush\n// and are imported at the top of this file.\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Curve mapping — linear → straight, step → step:\"middle\", everything else → smooth.\n// ─────────────────────────────────────────────────────────────────────────────\n\nfunction curveConfig(curveType: CurveType): { smooth: boolean; step: \"middle\" | false } {\n  // Recharts \"step\" is d3's curveStep: the transition happens at the MIDPOINT\n  // between points, so each dot sits centered on its plateau.\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 opacities — dims a series only when another one is selected. Lines\n// have no fill, so only the stroke and dots carry an opacity here.\n// ─────────────────────────────────────────────────────────────────────────────\n\nfunction getOpacity(selected: string | null, key: string) {\n  if (selected === null || selected === key) return { stroke: 1, dot: 1 };\n  return { stroke: 0.3, dot: 0.3 };\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,\n// tooltipIndicatorHtml, tooltipRow, tooltipShell) live in\n// @/registry/ui/echarts-tooltip; indicatorBackground lives in\n// @/registry/ui/echarts-chart. Both are imported at the top of this file.\n\n// The `__buffer-` prefix marks the dashed forecast overlay of a buffer line; it\n// carries the SAME key's value, so the tooltip recovers the key from it (see\n// createTooltipFormatter). Every other `__`-prefixed series (mini chart, loading\n// skeleton, hover-reveal base) is truly internal and never surfaces.\nconst BUFFER_PREFIX = \"__buffer-\";\n// The `__reveal-` prefix marks the muted base layer of a hover-reveal line — see\n// buildLineSeries. Internal, so the tooltip drops it like the mini/loading rows.\nconst REVEAL_PREFIX = \"__reveal-\";\n\n// Legend overlay (legendFillStyle, legendOutlineStyle, LegendIndicator,\n// LegendOverlay) lives in @/registry/ui/echarts-legend and is imported at the\n// top of this file.\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  lines: LineSeriesConfig[];\n  curveType: CurveType;\n  selectedDataKey: string | null;\n  hasSelection: boolean;\n  showGrid: boolean;\n  xAxisSlot: XAxisSlot;\n  yAxisSlot: YAxisSlot;\n  tooltipSlot: TooltipSlot;\n  legendSlot: LegendSlot;\n  isLoading: boolean;\n  loadingData: () => number[];\n  showBrush: boolean;\n  brushHeight: number;\n  enableHoverHighlight: boolean;\n  enableHoverReveal: boolean; // hover colors each line up to the pointer, mutes the rest\n  revealIndex: number | null; // pointer's x-index while revealing (null = idle → chart looks normal)\n  revealSink: Record<string, unknown[]>; // buildLineSeries writes each line's full per-datum points here for the hover handler\n  resolved: ResolvedColors;\n  rendererSize: { width: number; height: number }; // anchored reveal stroke gradients span the plot in absolute pixels\n  categories: string[];\n  brushRange: BrushRange; // zoom window carried through rebuilds\n  getHoveredKey: () => string | null; // read per tooltip render — hover never repushes the option\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, 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\n  const xTickFormatter = xAxisSlot.tickFormatter;\n  const yTickFormatter = yAxisSlot.tickFormatter;\n\n  const xAxis: XAxisOption = {\n    type: \"category\",\n    boundaryGap: false,\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      // Category ticks default to the BOUNDARY between categories, which on a\n      // boundaryGap axis drops the dot in the gap instead of under its label. A\n      // no-op here (boundaryGap is false) — set for parity with the bar/composed\n      // charts. The y-axis is always type:\"value\", which has no such option.\n      alignWithLabel: true,\n      length: 0.5,\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.\n  const yAxis: YAxisOption = {\n    type: \"value\",\n    show: yAxisSlot.present || showGrid,\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. `trigger: \"axis\"` hands\n// the formatter every series' value at the hovered x; buffer overlays and the\n// mini/loading series are folded out here (see BUFFER_PREFIX).\nfunction createTooltipFormatter(ctx: OptionBuildContext) {\n  const { config, selectedDataKey, tooltipSlot, getHoveredKey } = 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    // Dedupe by effective key: a buffer line contributes both its solid part\n    // (id=key) and its dashed overlay (id=`__buffer-{key}`) at the shared\n    // second-to-last point. Keep the first non-null value seen per key so the\n    // final point (only the overlay has data there) still shows its number.\n    const seen = new Set<string>();\n    const body = rows\n      .map((param) => {\n        const p = param as {\n          seriesId?: string;\n          seriesName?: string;\n          value?: number | string | null;\n        };\n        const rawId = String(p.seriesId ?? \"\");\n        // Map the dashed buffer overlay back onto its series; drop every other\n        // internal series (mini chart, loading skeleton).\n        const key = rawId.startsWith(BUFFER_PREFIX)\n          ? rawId.slice(BUFFER_PREFIX.length)\n          : rawId.startsWith(\"__\")\n            ? \"\"\n            : (p.seriesId ?? p.seriesName ?? \"\");\n        if (!key) return \"\";\n        // A null value means this series does not reach the hovered x (a buffer\n        // line's solid part stops before the last point) — skip it, and let the\n        // overlay row for the same key stand in.\n        if (p.value === null || p.value === undefined) return \"\";\n        if (seen.has(key)) return \"\";\n        seen.add(key);\n\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 hovered = getHoveredKey();\n        const dimmed =\n          (selectedDataKey != null && selectedDataKey !== key) ||\n          (hovered != null && hovered !== key)\n            ? \" opacity-30\"\n            : \"\";\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: STROKE_WIDTH,\n    }),\n    formatter: createTooltipFormatter(ctx),\n  };\n}\n\n// ── Brush — the evil-brush \"line\" look, canvas-style: a real mini chart of the\n// full data (strokes only, no fill, like EvilBrush variant=\"line\") in a second\n// grid, with a transparent slider dataZoom laid over it. Both zoom entries target\n// only the MAIN x-axis, so the mini chart never filters itself. Only called when\n// `showBrush` is set.\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, 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  const miniSeries: LineSeriesOption[] = lines.map((line) => {\n    const key = line.dataKey;\n    const base = (ctx.resolved.series[key] ?? [])[0] ?? \"rgba(120, 120, 120, 1)\";\n    const curve = curveConfig(line.curveType ?? curveType);\n\n    // The mini chart mirrors the click selection: unselected series recede\n    // by the same ratio as the main plot.\n    const strokeDim = getOpacity(selectedDataKey, key).stroke;\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: line.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 * strokeDim },\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 — ONE gray wave regardless of declared lines (Recharts\n// parity: its skeleton is a single stroke-only LoadingLine), swept by the\n// shimmer rAF. No fill: a <Line> has no area, so the skeleton is stroke-only too.\nfunction buildLoadingOption(\n  ctx: OptionBuildContext,\n  frame: { grid: GridComponentOption; xAxis: XAxisOption; yAxis: YAxisOption },\n): EChartsOption {\n  const { tokens } = ctx.resolved;\n  const curve = curveConfig(ctx.curveType);\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: \"line\",\n        data: ctx.loadingData(),\n        smooth: curve.smooth,\n        step: curve.step,\n        showSymbol: false,\n        silent: true,\n        // Invisible until the first shimmer tick positions the clip window.\n        lineStyle: { color: withAlpha(tokens.foreground, 0), width: 1 },\n        z: 1,\n      },\n    ],\n  };\n}\n\n// The plotted values for a line, optionally decorated per-datum. Multi-color\n// lines tint each symbol with the gradient's color at its own x-position (like\n// the Recharts dots); single-color lines return the raw numbers. `null` entries\n// pass through untouched — they carve the gap a buffer line's two parts leave.\ntype LinePoint =\n  | number\n  | null\n  | {\n      value: number | null;\n      itemStyle: DotItemStyleOption;\n      emphasis: { itemStyle: DotItemStyleOption };\n    };\n\nfunction buildLineSeries(ctx: OptionBuildContext): LineSeriesOption[] {\n  const {\n    data,\n    config,\n    lines,\n    curveType,\n    selectedDataKey,\n    hasSelection,\n    enableHoverHighlight,\n    enableHoverReveal,\n    revealIndex,\n    revealSink,\n    resolved,\n    rendererSize,\n  } = ctx;\n  const background = resolved.tokens.background;\n\n  return lines.flatMap((line): LineSeriesOption[] => {\n    const key = line.dataKey;\n    const slots = resolved.series[key] ?? [\"rgba(120, 120, 120, 1)\"];\n    const paint = seriesPaint(slots);\n    const isSelected = selectedDataKey === key;\n    const opacity = getOpacity(selectedDataKey, key);\n    const curve = curveConfig(line.curveType ?? curveType);\n    const multiColor = slots.length > 1;\n\n    const restingDot = dotStyle(line.dotVariant, paint, background);\n    const activeDot = dotStyle(line.activeDotVariant, paint, background);\n    const restingVisible = line.dotVariant !== \"none\";\n    const dotOpacity = opacity.dot;\n\n    const values = data.map((row) => Number(row[key]) || 0);\n    const n = values.length;\n    // Hover-reveal is a root-level mode and owns the whole line rendering, so it\n    // takes precedence over a per-line buffer tail (and the glow overlay) when\n    // both are set.\n    const reveal = enableHoverReveal;\n    const buffer = !reveal && line.enableBufferLine && n >= 2;\n    const revealActive = reveal && revealIndex !== null;\n\n    // The dash pattern for the MAIN line. A buffer line keeps its body solid and\n    // dashes only the tail overlay, so its main part is always solid regardless\n    // of strokeVariant (matches the Recharts twin, which suppresses the base\n    // dasharray while the buffer shape manages its own).\n    const mainDash: \"solid\" | [number, number] =\n      buffer || line.strokeVariant === \"solid\" ? \"solid\" : [3, 3];\n\n    // The reveal truncates the line to the cursor, which would COMPRESS a\n    // bbox-relative stroke gradient into the shorter span — misaligning it from\n    // the index-sampled dots. Anchor the stroke to the plot in absolute pixels so\n    // every x keeps its own color even when the line stops short.\n    const strokePaint =\n      reveal && multiColor\n        ? new echarts.graphic.LinearGradient(\n            8,\n            0,\n            Math.max(rendererSize.width - 8, 9),\n            0,\n            slots.map((color, i) => ({ offset: i / (slots.length - 1), color })),\n            true,\n          )\n        : paint;\n\n    // Turn a value list into ECharts data — attaching per-datum symbol colors\n    // for multi-color lines, and passing `null` gaps through so a buffer line's\n    // two parts each draw only their own segment.\n    const toPoints = (vals: (number | null)[]): LinePoint[] =>\n      !multiColor\n        ? vals\n        : vals.map((value, i): LinePoint => {\n            if (value === null) return null;\n            const t = vals.length > 1 ? i / (vals.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                  background,\n                ),\n                opacity: dotOpacity,\n              },\n              emphasis: {\n                itemStyle: {\n                  ...dotItemStyle(\n                    line.activeDotVariant === \"none\" ? \"default\" : line.activeDotVariant,\n                    pointColor,\n                    background,\n                  ),\n                  opacity: 1,\n                },\n              },\n            };\n          });\n\n    // Snapshot the FULL per-datum points (with the multi-color dot itemStyle) so\n    // the reveal hover handler can slice them without losing each dot's sampled\n    // gradient color — plain values would fall back to the default palette.\n    if (reveal) revealSink[key] = toPoints(values);\n\n    // Buffer line: the solid MAIN part drops the last point (its final segment\n    // becomes the dashed overlay). Reveal instead TRUNCATES the real series at the\n    // cursor's x-index (points beyond it null'd), so its line stops there and the\n    // muted base layer shows through past it. When idle (revealIndex null) the\n    // real series carries its full data — the chart looks completely normal.\n    const mainValues: (number | null)[] = buffer\n      ? values.map((v, i) => (i === n - 1 ? null : v))\n      : revealActive\n        ? sliceToNull(values, revealIndex as number)\n        : values;\n\n    const z = isSelected ? 3 : hasSelection ? 1 : 2;\n\n    // Glow overlays sit UNDER the real line (built first, same z; equal-z series\n    // paint in array order). They follow the FULL solid path so the halo stays\n    // continuous even beneath a dashed or buffer tail. Suppressed under reveal:\n    // a full-length colored halo would bleed past the cursor and defeat the mute.\n    const glowSeries =\n      line.glowing && !reveal\n        ? buildGlowSeries({\n            key,\n            paint,\n            slots,\n            values,\n            curve,\n            connectNulls: line.connectNulls,\n            z,\n            selectionDim: opacity.stroke,\n            dotSize: restingVisible ? restingDot.size : 0,\n          })\n        : [];\n\n    const mainSeries: LineSeriesOption = {\n      id: key,\n      name: typeof config[key]?.label === \"string\" ? config[key]?.label : key,\n      type: \"line\",\n      data: toPoints(mainValues),\n      smooth: curve.smooth,\n      step: curve.step,\n      connectNulls: line.connectNulls,\n      cursor: line.isClickable ? \"pointer\" : \"default\",\n      // By default ECharts only fires mouse events on the symbols — this makes\n      // the line itself clickable too, 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,\n      lineStyle: {\n        // Anchored plot-wide gradient while revealing a multi-color line (see\n        // strokePaint), the normal series paint otherwise.\n        color: strokePaint,\n        width: line.strokeWidth,\n        opacity: opacity.stroke,\n        type: mainDash,\n        dashOffset: 0,\n      },\n      itemStyle: multiColor\n        ? { opacity: dotOpacity }\n        : {\n            ...(restingVisible ? restingDot.itemStyle : activeDot.itemStyle),\n            opacity: dotOpacity,\n          },\n      emphasis: {\n        // focus \"series\" blurs every other series in this grid while one is\n        // hovered — the hover twin of the click selection (opt-in via\n        // enableHoverHighlight). Suppressed entirely while a series is\n        // click-selected: the selection dim owns the canvas, so hover\n        // highlighting stops until the selection clears (the option rebuilds on\n        // selection change, making this a build-time conditional). Reveal owns the\n        // hover visual, so native focus-blur stands down when it is on (they must\n        // not blend). Otherwise the active dot is the only emphasis.\n        focus: enableHoverHighlight && !enableHoverReveal && !hasSelection ? \"series\" : \"none\",\n        scale: restingVisible ? activeDot.size / Math.max(restingDot.size, 1) : 1,\n        ...(multiColor ? {} : { itemStyle: { ...activeDot.itemStyle, opacity: 1 } }),\n      },\n      // Blur styling mirrors the click-selection dim (stroke 0.3 / dot 0.3);\n      // inert unless a series is focused via enableHoverHighlight.\n      blur: {\n        lineStyle: { opacity: 0.3 },\n        itemStyle: { opacity: 0.3 },\n      },\n    };\n\n    // Hover-reveal: a muted gray BASE line of the FULL series sits one z below the\n    // real one. It is invisible while idle (opacity 0 → the chart looks normal)\n    // and fades in only while hovering, so the region PAST the cursor — where the\n    // truncated real line has stopped — shows as neutral gray. Lines have no fill,\n    // so the base is a line only (no areaStyle) and needs no stack mirror.\n    if (reveal) {\n      const muted = resolved.tokens.mutedForeground;\n      const revealBase: LineSeriesOption = {\n        id: `${REVEAL_PREFIX}${key}`,\n        type: \"line\",\n        // Only the region FROM the cursor onward (null before it), so the gray\n        // never sits under the colored part — the two meet exactly at the pointer\n        // and their colors can't mix.\n        data: revealActive ? sliceFrom(values, revealIndex as number) : values,\n        smooth: curve.smooth,\n        step: curve.step,\n        connectNulls: false,\n        silent: true,\n        showSymbol: false,\n        symbol: \"circle\",\n        z: z - 1,\n        // Neutral gray, SAME dash pattern as the colored line, no fill.\n        lineStyle: {\n          color: muted,\n          width: line.strokeWidth,\n          type: mainDash,\n          opacity: revealActive ? 0.3 : 0,\n        },\n        emphasis: { disabled: true },\n        blur: { lineStyle: { opacity: revealActive ? 0.3 : 0 } },\n        tooltip: { show: false },\n      };\n      return [revealBase, mainSeries];\n    }\n\n    if (!buffer) return [...glowSeries, mainSeries];\n\n    // Dashed forecast overlay — draws ONLY the last segment. Silent, so it never\n    // intercepts clicks/hover; it still feeds the axis tooltip (silent series\n    // are aggregated by axis), which is why the last point keeps its number.\n    const bufferValues: (number | null)[] = values.map((v, i) => (i >= n - 2 ? v : null));\n    const bufferSeries: LineSeriesOption = {\n      id: `${BUFFER_PREFIX}${key}`,\n      type: \"line\",\n      data: toPoints(bufferValues),\n      smooth: curve.smooth,\n      step: curve.step,\n      connectNulls: true,\n      silent: true,\n      showSymbol: restingVisible,\n      symbol: \"circle\",\n      symbolSize: restingVisible ? restingDot.size : activeDot.size,\n      z,\n      lineStyle: {\n        color: paint,\n        width: line.strokeWidth,\n        opacity: opacity.stroke,\n        type: BUFFER_DASH,\n      },\n      itemStyle: multiColor\n        ? { opacity: dotOpacity }\n        : {\n            ...(restingVisible ? restingDot.itemStyle : activeDot.itemStyle),\n            opacity: dotOpacity,\n          },\n      // The dashed tail is a separate silent series, so focus:\"series\" on its\n      // parent would blur it apart from the line it belongs to. The root\n      // dispatch-links this id (see companionIdsByKey) so it focuses WITH its\n      // parent; these styles give it the parent's look while focused and the\n      // click-selection dim while another series is hovered.\n      emphasis: {\n        focus: \"none\",\n        scale: false,\n        lineStyle: { opacity: opacity.stroke },\n        itemStyle: { opacity: dotOpacity },\n      },\n      blur: { lineStyle: { opacity: 0.3 }, itemStyle: { opacity: 0.3 } },\n    };\n\n    return [...glowSeries, mainSeries, bufferSeries];\n  });\n}\n\n// Copy a value list with everything AFTER `idx` nulled — the hover-reveal cut:\n// the colored real line keeps its data up to the cursor and drops the rest, so\n// (with connectNulls false) its stroke stops dead at the pointer.\nfunction sliceToNull<T>(vals: readonly T[], idx: number): (T | null)[] {\n  return vals.map((v, i) => (i > idx ? null : v));\n}\n\n// Copy a value list with everything BEFORE `idx` nulled — the reveal's gray tail.\n// The muted base keeps only the region from the cursor onward, so it never sits\n// under the colored part; both include `idx` so they meet at the pointer.\n// Generic so it preserves per-datum point objects (multi-color dot itemStyle).\nfunction sliceFrom<T>(vals: readonly T[], idx: number): (T | null)[] {\n  return vals.map((v, i) => (i < idx ? null : v));\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  hoveredKey: string | null; // tooltip's view of hover — the legend's twin lives in React state\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 data, lazily rolled and re-rolled per shimmer sweep\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  // seriesIndex → clickable key for the last build, `undefined` for internal\n  // series. A line-body click (triggerEvent) reports only a seriesIndex, and\n  // buffer lines add a second (`__buffer-`) series per key — so the index no\n  // longer equals the key's position in seriesKeys and must be mapped explicitly.\n  seriesKeyByIndex: (string | undefined)[];\n  // key → its silent companion series ids (glow overlays + buffer tail) in the\n  // main grid. Under enableHoverHighlight the root highlights/downplays these\n  // together with the hovered parent, so focus:\"series\" can't strand a line's own\n  // glow or forecast tail apart from it.\n  companionIdsByKey: Map<string, string[]>;\n  revealIndex: number | null; // hover-reveal pointer x-index (null = idle); read by builds and the reveal hover handler\n  revealValues: Record<string, unknown[]>; // per-line FULL per-datum points (with dot itemStyle), sliced to the cursor on hover without a rebuild\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    enableHoverHighlight: boolean;\n    enableHoverReveal: boolean;\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 line chart, exposing a compound-as-config\n * API so its JSX reads identically to the Recharts twin. The root owns the data,\n * config, selection state, loading skeleton, intro reveal, and optional zoom\n * brush; every visual part — `<Line>`, `<XAxis>`, `<YAxis>`, `<Grid>`,\n * `<Tooltip>`, `<Legend>` — is composed as a declarative child that renders\n * nothing. The root walks those children by reference and drives a single\n * imperative ECharts instance. Fully self-contained: its only dependencies are\n * `react`, `echarts`, and `motion`.\n */\nexport function EChartsLineChart<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  enableHoverHighlight = false,\n  enableHoverReveal = false,\n  defaultSelectedDataKey = null,\n  onSelectionChange,\n  isLoading = false,\n  loadingPoints = LOADING_DEFAULT_POINTS,\n  chartOptions,\n  children,\n}: EChartsLineChartProps<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    hoveredKey: null,\n    hasRevealed: false,\n    revealEndsAt: 0,\n    loadingRows: 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    seriesKeyByIndex: [],\n    companionIdsByKey: new Map(),\n    revealIndex: null,\n    revealValues: {},\n    handlers: {\n      onBrushChange: undefined, // set per-render from the <Brush> child's onChange\n      onSelectionChange,\n      clickableKeys: new Set<string>(),\n      selectedDataKey: defaultSelectedDataKey,\n      brushFormatLabel: undefined, // set per-render from the <Brush> child's formatLabel\n      seriesKeys: [],\n      enableHoverHighlight,\n      enableHoverReveal,\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.\n  const loadingData = useCallback(\n    () => (live.loadingRows ??= getLoadingData(loadingPoints)),\n    [live, loadingPoints],\n  );\n  const shouldReduceMotion = useReducedMotion();\n\n  const [selectedDataKey, setSelectedDataKey] = useState<string | null>(defaultSelectedDataKey);\n\n  // Hover-highlight mirrors into the legend (React state) and tooltip\n  // (live.hoveredKey — its formatter runs on every hover, and pushing an option\n  // to sync it would reset ECharts' native blur state mid-hover).\n  const [hoveredDataKey, setHoveredDataKey] = useState<string | null>(null);\n\n  // ── Declarative config, collected from children by reference ─────────────────\n  const collected = useMemo(() => collectConfig(children), [children]);\n  const {\n    lines,\n    xAxis: xAxisSlot,\n    yAxis: yAxisSlot,\n    showGrid,\n    tooltip: tooltipSlot,\n    legend: legendSlot,\n    brush: brushSlot,\n  } = collected;\n  // Brush is a <Brush> child now (not props): presence turns it on, its props\n  // carry height/formatLabel/onChange.\n  const showBrush = brushSlot.present;\n  const brushHeight = brushSlot.height ?? 56;\n\n  const seriesKeys = useMemo(() => lines.map((line) => line.dataKey), [lines]);\n\n  // x category key: <XAxis dataKey> → root xDataKey → first data column no <Line> 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 line's setting, falling back to the root default.\n  const effectiveAnimation = lines[0]?.animationType ?? animationType;\n\n  const css = useMemo(() => buildChartCss(chartId, config), [chartId, config]);\n\n  const hasSelection = selectedDataKey !== null;\n\n  // Which series may be clicked to toggle selection (consulted by the click handler).\n  const clickableKeys = useMemo(\n    () => new Set(lines.filter((line) => line.isClickable).map((line) => line.dataKey)),\n    [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    enableHoverHighlight,\n    enableHoverReveal,\n  };\n  live.dataLength = data.length;\n\n  const toggleSelection = useCallback(\n    (key: string) => {\n      // A new click selection takes over the canvas dim, so any live hover\n      // highlight is torn down at this moment — the mouseover guard then keeps it\n      // from re-arming while the selection stands. Hover is only ever active\n      // while NO selection exists (see that guard), so a hovered key here always\n      // means this click is establishing a selection.\n      if (live.hoveredKey !== null) {\n        const chart = echartsRef.current;\n        const companions = chart ? live.companionIdsByKey.get(live.hoveredKey) : undefined;\n        if (chart && companions) {\n          for (const seriesId of companions) chart.dispatchAction({ type: \"downplay\", seriesId });\n        }\n        live.hoveredKey = null;\n        setHoveredDataKey(null);\n      }\n      setSelectedDataKey((prev) => {\n        const next = prev === key ? null : key;\n        onSelectionChange?.(next);\n        return next;\n      });\n    },\n    [live, 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    // buildLineSeries fills this with each line's full per-datum points (with the\n    // multi-color dot itemStyle) so the reveal hover handler slices real data.\n    const revealSink: Record<string, unknown[]> = {};\n\n    const ctx: OptionBuildContext = {\n      data,\n      config,\n      lines,\n      curveType,\n      selectedDataKey,\n      hasSelection,\n      showGrid,\n      xAxisSlot,\n      yAxisSlot,\n      tooltipSlot,\n      legendSlot,\n      isLoading,\n      loadingData,\n      showBrush,\n      brushHeight,\n      enableHoverHighlight,\n      enableHoverReveal,\n      revealIndex: live.revealIndex,\n      revealSink,\n      resolved,\n      rendererSize: {\n        width: echartsRef.current?.getWidth() ?? mountRef.current?.clientWidth ?? 0,\n        height: echartsRef.current?.getHeight() ?? mountRef.current?.clientHeight ?? 0,\n      },\n      categories,\n      brushRange: live.brushRange,\n      getHoveredKey: () => live.hoveredKey,\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    const series = [...buildLineSeries(ctx), ...(brush?.miniSeries ?? [])];\n    // buildLineSeries has now filled revealSink with each line's full per-datum\n    // points — hand them to the hover handler for slicing.\n    if (enableHoverReveal) live.revealValues = revealSink;\n    // Record the exact series order so a line-body click (which reports only a\n    // seriesIndex) can recover its key — buffer/reveal/mini/loading series break\n    // the \"index === key position\" shortcut, so map each index to its id here.\n    live.seriesKeyByIndex = series.map((s) => {\n      const id = String(s.id ?? \"\");\n      return id && !id.startsWith(\"__\") ? id : undefined;\n    });\n    // Map each key to its silent companion series ids (glow overlays, buffer tail,\n    // hover-reveal base), mirroring exactly what buildLineSeries emits — the hover\n    // handlers highlight/downplay these together with the parent so focus:\"series\"\n    // never strands a line's own glow or forecast tail apart from it.\n    const companionIdsByKey = new Map<string, string[]>();\n    for (const line of lines) {\n      const ids: string[] = [];\n      if (line.glowing) {\n        for (let i = 0; i < GLOW_LAYERS.length; i++) ids.push(`__glow-${i}-${line.dataKey}`);\n      }\n      if (line.enableBufferLine && data.length >= 2) ids.push(`${BUFFER_PREFIX}${line.dataKey}`);\n      if (enableHoverReveal) ids.push(`${REVEAL_PREFIX}${line.dataKey}`);\n      if (ids.length) companionIdsByKey.set(line.dataKey, ids);\n    }\n    live.companionIdsByKey = companionIdsByKey;\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      series,\n    };\n  }, [\n    live,\n    data,\n    config,\n    lines,\n    xCategoryKey,\n    curveType,\n    selectedDataKey,\n    hasSelection,\n    showGrid,\n    xAxisSlot,\n    yAxisSlot,\n    tooltipSlot,\n    legendSlot,\n    isLoading,\n    loadingData,\n    showBrush,\n    brushHeight,\n    enableHoverHighlight,\n    enableHoverReveal,\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 line's\n      // reveal clip — 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 } = live.handlers;\n      const p = params as { seriesId?: string; seriesIndex?: number };\n      // Symbol clicks carry seriesId; line clicks (triggerEvent) only carry\n      // seriesIndex — recover the key from the last build's index map, which\n      // accounts for the extra `__buffer-`/`__mini-` series interleaved between\n      // the main ones (a raw seriesKeys lookup would land on the wrong key).\n      const id =\n        p.seriesId ??\n        (typeof p.seriesIndex === \"number\" ? live.seriesKeyByIndex[p.seriesIndex] : undefined);\n      if (typeof id === \"string\" && clickable.has(id)) toggleSelection(id);\n    });\n\n    // Hover-highlight bookkeeping — the canvas blur is ECharts-native\n    // (emphasis.focus:\"series\" + blur), but the HTML legend and tooltip need to\n    // know which series is hovered, and the silent glow/buffer overlays must be\n    // focus-linked to their parent (they are separate series, so focus:\"series\"\n    // would otherwise blur a line's own glow or forecast tail).\n    chart.on(\"mouseover\", (params) => {\n      const { enableHoverHighlight: hoverOn, enableHoverReveal: revealOn } = live.handlers;\n      // Reveal owns the hover visual, so native highlight stands down while it is on.\n      if (!hoverOn || revealOn) return;\n      // While a series is click-selected, hover highlighting is disabled — the\n      // selection dim owns the canvas, so never arm hover emphasis/blur (or the\n      // legend/tooltip hover dimming) until the selection clears.\n      if (live.handlers.selectedDataKey !== null) return;\n      const p = params as { seriesId?: string; seriesIndex?: number; componentType?: string };\n      if (p.componentType !== \"series\") return;\n      const id =\n        p.seriesId ??\n        (typeof p.seriesIndex === \"number\" ? live.seriesKeyByIndex[p.seriesIndex] : undefined);\n      if (typeof id !== \"string\" || id.startsWith(\"__\")) return;\n      live.hoveredKey = id;\n      setHoveredDataKey(id);\n      const companions = live.companionIdsByKey.get(id);\n      if (companions) {\n        for (const seriesId of companions) chart.dispatchAction({ type: \"highlight\", seriesId });\n      }\n    });\n    chart.on(\"mouseout\", () => {\n      const prev = live.hoveredKey;\n      if (prev === null) return;\n      live.hoveredKey = null;\n      setHoveredDataKey(null);\n      const companions = live.companionIdsByKey.get(prev);\n      if (companions) {\n        for (const seriesId of companions) chart.dispatchAction({ type: \"downplay\", seriesId });\n      }\n    });\n\n    // Hover-reveal: color each line up to the pointer's x-index, mute the rest.\n    // Purely TARGETED series updates (real series data + muted base opacity) — we\n    // NEVER rebuild the whole option on mousemove, which would replay transitions\n    // and fight the tooltip's axis pointer.\n    const zrReveal = chart.getZr();\n    const pushReveal = (idx: number | null) => {\n      const keys = live.handlers.seriesKeys;\n      const on = idx !== null;\n      chart.setOption(\n        {\n          series: keys.flatMap((key) => [\n            {\n              id: key,\n              data: on\n                ? sliceToNull(live.revealValues[key] ?? [], idx)\n                : (live.revealValues[key] ?? []),\n            },\n            {\n              id: `${REVEAL_PREFIX}${key}`,\n              // Gray tail keeps only the region from the cursor onward.\n              data: on\n                ? sliceFrom(live.revealValues[key] ?? [], idx)\n                : (live.revealValues[key] ?? []),\n              lineStyle: { opacity: on ? 0.3 : 0 },\n            },\n          ]),\n        },\n        // NOT lazy: the highlight dispatched just below re-draws the active dot\n        // the setOption wipes, so the option must be committed first — a queued\n        // (lazy) update would land after the dispatch and erase the dot again.\n        { silent: true },\n      );\n      // The per-frame setOption above cancels the axis tooltip's transient hover\n      // symbol, so the <ActiveDot> never lands at the cursor. Re-assert it:\n      // highlighting a real series at the cursor index draws its emphasis symbol\n      // (the active dot) even with showSymbol:false; downplay clears it on exit.\n      for (const key of keys) {\n        chart.dispatchAction(\n          on\n            ? { type: \"highlight\", seriesId: key, dataIndex: idx as number }\n            : { type: \"downplay\", seriesId: key },\n        );\n      }\n    };\n    const clearReveal = () => {\n      if (live.revealIndex === null) return;\n      live.revealIndex = null;\n      pushReveal(null);\n    };\n    const applyReveal = (event: { offsetX?: number; offsetY?: number }) => {\n      const len = live.dataLength;\n      if (len < 1) return;\n      const x = event.offsetX ?? -1;\n      const y = event.offsetY ?? -1;\n      if (!chart.containPixel({ gridIndex: 0 }, [x, y])) {\n        clearReveal();\n        return;\n      }\n      const raw = chart.convertFromPixel({ gridIndex: 0 }, [x, y])[0];\n      const idx = Math.max(0, Math.min(len - 1, Math.round(raw)));\n      if (idx === live.revealIndex) return;\n      live.revealIndex = idx;\n      pushReveal(idx);\n    };\n    const onZrRevealMove = (event: { offsetX?: number; offsetY?: number }) => {\n      if (!live.handlers.enableHoverReveal) return;\n      applyReveal(event);\n    };\n    const onZrRevealOut = () => {\n      if (live.handlers.enableHoverReveal) clearReveal();\n    };\n    zrReveal.on(\"mousemove\", onZrRevealMove);\n    zrReveal.on(\"globalout\", onZrRevealOut);\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      zrReveal.off(\"mousemove\", onZrRevealMove);\n      zrReveal.off(\"globalout\", onZrRevealOut);\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: the line traces in, dots pop up as its front passes. Every\n    // later push (selection, theme, zoom) applies instantly, since notMerge would\n    // otherwise replay the entrance on each of them. A loading cycle re-arms it:\n    // the Recharts twin unmounts its <Line>s while loading and replays the intro\n    // on remount, 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 && 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  // ── 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      // A buffer line's body is solid (only its tail dashes), so the sweep skips it.\n      .filter((line) => line.strokeVariant === \"animated-dashed\" && !line.enableBufferLine)\n      .map((line) => line.dataKey);\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        const offset = -(((now - loopStart) / 1000) % 1) * 6; // 0 → -6 per second\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, hasSelection, isLoading]);\n\n  // ── Loading shimmer — rAF sweeps a bright band, regenerating data off-screen ─\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 band is off-screen; swap in fresh random data.\n      if (phase < lastPhase) live.loadingRows = getLoadingData(loadingPoints);\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      // Sweep the clip window from fully off-screen left to fully off-screen\n      // right, leaned 45°. The gradient uses ABSOLUTE pixel coordinates so the\n      // window lands at the same place along the stroke regardless of the wave's\n      // bounding box.\n      const w = chart.getWidth();\n      const h = chart.getHeight();\n      if (!w || !h) {\n        raf = requestAnimationFrame(tick);\n        return;\n      }\n      // Farthest plot corner projected onto the 45° axis — keeps the sweep\n      // tight instead of dawdling off-plot at the end of each loop.\n      const maxT = (w + h) / (2 * w);\n      const center = phase * (maxT + 2 * LOADING_SHIMMER_BAND) - LOADING_SHIMMER_BAND;\n      const clip = (peak: number) =>\n        new echarts.graphic.LinearGradient(\n          0,\n          0,\n          w,\n          w,\n          shimmerWindowStops(center, foreground, peak),\n          true,\n        );\n      chart.setOption(\n        {\n          series: [\n            {\n              id: \"__loading\",\n              data: loadingData(),\n              lineStyle: { color: clip(LOADING_STROKE_OPACITY), width: 1 },\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, loadingPoints, loadingData]);\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={hoveredDataKey}\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// Compound API: every part hangs off the root as a static member, so a consumer\n// writes <EChartsLineChart.Line/>, <EChartsLineChart.Tooltip/>, … from a single\n// import — no colliding named marker exports when several charts share one file.\nEChartsLineChart.Line = Line;\nEChartsLineChart.Dot = Dot;\nEChartsLineChart.ActiveDot = ActiveDot;\nEChartsLineChart.XAxis = XAxis;\nEChartsLineChart.YAxis = YAxis;\nEChartsLineChart.Grid = Grid;\nEChartsLineChart.Tooltip = Tooltip;\nEChartsLineChart.Legend = Legend;\nEChartsLineChart.Brush = Brush;\n",
      "type": "registry:component",
      "target": "components/evilcharts/charts/echarts-line-chart.tsx"
    }
  ],
  "type": "registry:component"
}