---
title: Line Chart
description: Static, beautifully designed line charts powered by Apache ECharts
image: /og/og-image.png
links:
  github: https://github.com/legions-developer/evilcharts/blob/main/src/registry/charts/echarts-line-chart.tsx
  doc: https://echarts.apache.org/en/index.html
  api: https://echarts.apache.org/en/option.html
---

### Basic Chart

```tsx
"use client";

import { EChartsLineChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-line-chart";

const data = [
  { month: "January", desktop: 342, mobile: 184 },
  { month: "February", desktop: 876, mobile: 491 },
  { month: "March", desktop: 512, mobile: 290 },
  { month: "April", desktop: 629, mobile: 391 },
  { month: "May", desktop: 458, mobile: 309 },
  { month: "June", desktop: 781, mobile: 449 },
  { month: "July", desktop: 394, mobile: 234 },
  { month: "August", desktop: 925, mobile: 557 },
  { month: "September", desktop: 647, mobile: 367 },
  { month: "October", desktop: 532, mobile: 357 },
  { month: "November", desktop: 803, mobile: 515 },
  { month: "December", desktop: 271, mobile: 149 },
];

const chartConfig = {
  desktop: {
    label: "Desktop",
    colors: {
      light: ["#047857"],
      dark: ["#10b981"],
    },
  },
  mobile: {
    label: "Mobile",
    colors: {
      light: ["#be123c"],
      dark: ["#f43f5e"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleLineChart() {
  return (
    <EChartsLineChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      xDataKey="month"
    >
      <EChartsLineChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsLineChart.Brush formatLabel={(value) => String(value).substring(0, 3)} />
      <EChartsLineChart.Legend isClickable />
      <EChartsLineChart.Tooltip />
      <EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable>
        <EChartsLineChart.Dot variant="border" />
        <EChartsLineChart.ActiveDot variant="colored-border" />
      </EChartsLineChart.Line>
      <EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" isClickable>
        <EChartsLineChart.Dot variant="border" />
        <EChartsLineChart.ActiveDot variant="colored-border" />
      </EChartsLineChart.Line>
    </EChartsLineChart>
  );
}

```

## Installation


  
  
    ### npm

```bash
npx shadcn@latest add @evilcharts/echarts-line-chart
```

### yarn

```bash
yarn shadcn@latest add @evilcharts/echarts-line-chart
```

### bun

```bash
bunx --bun shadcn@latest add @evilcharts/echarts-line-chart
```

### pnpm

```bash
pnpm dlx shadcn@latest add @evilcharts/echarts-line-chart
```
  
  
    
      
        ### Install the following dependencies:
        
          ### npm

```bash
npm install echarts motion
```

### yarn

```bash
yarn add echarts motion
```

### bun

```bash
bun add echarts motion
```

### pnpm

```bash
pnpm add echarts motion
```
        
      
      
        ### Copy and paste the following code snippets into your project.
        
          Create the folder `evilcharts` with a `charts` subfolder in your `components` directory, then paste the line-chart code into a new `echarts-line-chart.tsx` file there.
        
        
          ### components/evilcharts/charts/echarts-line-chart.tsx

```tsx
"use client";

import {
  tooltipBaseOption,
  tooltipIndicatorHtml,
  tooltipRow,
  tooltipShell,
  type TooltipPosition,
  type TooltipRoundness,
  type TooltipVariant,
} from "@/components/evilcharts/ui/echarts-tooltip";
import {
  Brush,
  buildBrushDataZoom,
  syncBrushOverlay,
  type BrushGeometry,
  type BrushOverlayElements,
  type BrushProps,
  type BrushRange,
} from "@/components/evilcharts/ui/echarts-brush";
import {
  DataZoomComponent,
  GridComponent,
  TooltipComponent,
  type DataZoomComponentOption,
  type GridComponentOption,
  type TooltipComponentOption,
} from "echarts/components";
import {
  buildChartCss,
  flattenColor,
  getColorsCount,
  resolveColors,
  seriesPaint,
  withAlpha,
  type ChartConfig,
  type ResolvedColors,
} from "@/components/evilcharts/ui/echarts-chart";
import {
  Children,
  isValidElement,
  useCallback,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type FC,
  type ReactNode,
} from "react";
import {
  dotItemStyle,
  dotStyle,
  sampleGradient,
  type DotItemStyleOption,
  type DotVariant,
} from "@/components/evilcharts/ui/echarts-dot";
import { LegendOverlay, type LegendVariant } from "@/components/evilcharts/ui/echarts-legend";
import { LineChart, type LineSeriesOption } from "echarts/charts";
import { motion, useReducedMotion } from "motion/react";
import { CanvasRenderer } from "echarts/renderers";
import type { ComposeOption } from "echarts/core";
import * as echarts from "echarts/core";

// Re-export the shared types that were previously declared inline here, so
// existing consumers/examples keep importing them from the chart module.
export type {
  ChartConfig,
  DotVariant,
  LegendVariant,
  TooltipPosition,
  TooltipRoundness,
  TooltipVariant,
};

// Modular registration keeps the bundle lean — only the pieces this chart needs.
// `DataZoomComponent` bundles both the slider (brush footer) and inside (wheel/drag)
// zoom. The brush's frame/handles/labels are raw zrender elements, not the
// graphic component — see syncBrushOverlay. No GraphicComponent is registered.
echarts.use([LineChart, GridComponent, TooltipComponent, DataZoomComponent, CanvasRenderer]);

type EChartsInstance = ReturnType<typeof echarts.init>;

// The exact option surface this chart uses — line series, grid, tooltip, and
// dataZoom, plus the axis options they pull in as dependencies. Narrower than
// echarts' full EChartsOption, so a misspelled key fails the compile instead of
// silently reaching setOption.
type EChartsOption = ComposeOption<
  LineSeriesOption | GridComponentOption | TooltipComponentOption | DataZoomComponentOption
>;

// Single-entry views of the composed option's array-or-single fields — the
// modular entry points don't export the axis option types directly.
type ArrayItem<T> = T extends readonly (infer U)[] ? U : T;
type XAxisOption = ArrayItem<NonNullable<EChartsOption["xAxis"]>>;
type YAxisOption = ArrayItem<NonNullable<EChartsOption["yAxis"]>>;

// DotItemStyleOption now lives in @/components/evilcharts/ui/echarts-dot and is imported at
// the top of this file.

// ─────────────────────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────────────────────

const STROKE_WIDTH = 0.8; // default series stroke — <Line strokeWidth> overrides it
const LOADING_ANIMATION_DURATION = 2000; // shimmer loop, in milliseconds
const REVEAL_DURATION = 1000; // intro draw-in length, in milliseconds
// NOTE: the intro draw-in runs ECharts' RAW default entrance animation. Custom
// easing was tried and abandoned in the area twin — ECharts hardcodes the
// line-entrance clip to linear and ignores animationEasing at every level.
const LOADING_DEFAULT_POINTS = 14;
// Buffer line: the last segment renders as this dash while the rest stays solid,
// echoing the Recharts twin's 4px dash / 3px gap forecast tail.
const BUFFER_DASH: [number, number] = [4, 3];

// <Line glowing> glow. Canvas has no SVG blur filter over a whole shape, so the
// glow is built from SILENT, stacked copies of the line laid UNDER the real one.
//
// The layers are all the SAME NARROW WIDTH on purpose. A wide translucent stroke
// has a HARD edge, so widening each copy (the obvious approach) paints concentric
// contour rings, not a glow — no number of layers hides it, because every layer
// contributes another visible boundary. Here each copy stays hidden beneath the
// real line and the visible halo comes entirely from its canvas `shadowBlur`,
// which is a true gaussian: edgeless by construction, and summing several at
// different radii stays perfectly smooth.
//
// The trade: a canvas shadow is a single flat color, so the halo is cast in the
// gradient's mid tone (`sampleGradient(slots, 0.5)`) rather than tracking the
// stroke's color along its length. The stroke copies themselves still carry the
// real gradient, so the bright core reads correctly; only the soft bloom is one
// hue. Smooth beats hue-accurate here. `symbolPad` grows the glow disc under each
// visible dot so haloed markers bloom too.
const GLOW_LAYERS: { width: number; opacity: number; blur: number; symbolPad: number }[] = [
  { width: 2, opacity: 0.9, blur: 5, symbolPad: 2 },
  { width: 2, opacity: 0.6, blur: 12, symbolPad: 6 },
  { width: 2, opacity: 0.38, blur: 24, symbolPad: 11 },
  { width: 2, opacity: 0.22, blur: 42, symbolPad: 16 },
];

// ─────────────────────────────────────────────────────────────────────────────
// Theme knobs — every neutral line in the chart draws from these. Base colors
// come from the consumer's CSS tokens (resolved from the live DOM), so only the
// opacity factors live here. Factors MULTIPLY the token's own alpha — a border
// token that is already 10%-white stays subtle. Tune here, not in the builder.
// ─────────────────────────────────────────────────────────────────────────────
// Recharts draws its grid at border/50, but SVG dashes render pixel-crisp while
// canvas at 2× DPR spreads a 1px line across device pixels — roughly halving
// perceived intensity. Using the border token's full alpha lands both engines at
// the same apparent brightness.
const GRID_LINE_OPACITY = 1; // dashed y-axis split lines, × border alpha
const AXIS_POINTER_OPACITY = 1; // tooltip cursor line, × border alpha
// The skeleton is CLIPPED to a small sweeping window — only the stroke section
// inside it exists, everything outside is fully transparent, like a clip-path
// sliding across the chart.
const LOADING_STROKE_OPACITY = 0.5; // outline inside the window, × foreground alpha
const LOADING_SHIMMER_BAND = 0.2; // window half-width, fraction of chart width
const LOADING_SHIMMER_FEATHER = 0.2; // eased edge softening of the clip window
const BRUSH_STROKE_OPACITY = 0.5; // mini-chart series stroke (evil-brush "line" variant)
const BRUSH_FILLER_OPACITY = 0; // selected-range wash — evil-brush draws none

// ─────────────────────────────────────────────────────────────────────────────
// Public types
// ─────────────────────────────────────────────────────────────────────────────

export type StrokeVariant = "solid" | "dashed" | "animated-dashed";
export type LineAnimationType =
  | "none"
  | "left-to-right"
  | "right-to-left"
  | "center-out"
  | "edges-in";
export type CurveType =
  | "linear"
  | "smooth"
  | "bump"
  | "monotone"
  | "monotoneX"
  | "monotoneY"
  | "natural"
  | "step";
// DotVariant, TooltipVariant, TooltipRoundness, LegendVariant, and ChartConfig
// now live in the shared @/components/evilcharts/ui/echarts/* modules and are imported +
// re-exported at the top of this file.

export interface EChartsLineChartProps<TData extends Record<string, unknown>> {
  data: TData[]; // rows rendered by the chart
  config: ChartConfig; // series colors + labels
  xDataKey?: keyof TData & string; // x category key — falls back to the <XAxis> dataKey / first free column
  className?: string; // extra classes for the chart container
  curveType?: CurveType; // default curve interpolation each <Line> inherits
  animation?: boolean; // master switch for the intro draw-in — false renders instantly
  animationType?: LineAnimationType; // default intro reveal (first <Line> overrides)
  enableHoverHighlight?: boolean; // hovering a series dims the others, like a temporary selection
  enableHoverReveal?: boolean; // hovering colors each line up to the pointer's x and mutes the rest
  defaultSelectedDataKey?: string | null; // series selected on first render
  onSelectionChange?: (key: string | null) => void; // fires when the selected series changes
  isLoading?: boolean; // shows the animated loading skeleton
  loadingPoints?: number; // number of points in the loading skeleton
  chartOptions?: Record<string, unknown>; // escape hatch merged over the built ECharts option
  children?: ReactNode; // declarative config — <Line>, <XAxis>, <Grid>, <Tooltip>, <Legend>, <Brush>, …
}

// ─────────────────────────────────────────────────────────────────────────────
// Composible parts — DECLARATIVE CONFIG. Every part renders `null`; the root
// walks `children` by reference (child.type === Line, …) to collect its props.
// Presence semantics mirror the Recharts twin: omit a child and that part does
// not render. These are never mounted into the tree — they only carry props.
// ─────────────────────────────────────────────────────────────────────────────

export interface LineProps {
  dataKey: string; // series key — must exist on the data + config
  strokeVariant?: StrokeVariant; // stroke style for this line
  strokeWidth?: number; // stroke thickness in pixels for this line
  curveType?: CurveType; // curve interpolation — falls back to the root curveType
  animationType?: LineAnimationType; // intro reveal — first line drives the wrapper wipe
  connectNulls?: boolean; // join segments across null/missing values
  isClickable?: boolean; // lets this line be selected by clicking it
  glowing?: boolean; // applies a soft outer glow to this line
  enableBufferLine?: boolean; // renders this line's last segment as a dashed buffer
  children?: ReactNode; // optional <Dot> and <ActiveDot> config
}

/**
 * A single line series. Declares its own stroke/curve/glow/clickability and,
 * optionally, resting/active point markers via composed <Dot> / <ActiveDot>.
 * Renders nothing — the root reads these props to build the ECharts series.
 */
const Line: FC<LineProps> = () => null;

export interface DotProps {
  variant?: DotVariant; // visual style of the point marker
}

/** Declares the resting point marker for the enclosing <Line>. Renders nothing. */
const Dot: FC<DotProps> = () => null;

/** Declares the hovered/active point marker for the enclosing <Line>. Renders nothing. */
const ActiveDot: FC<DotProps> = () => null;

export interface XAxisProps {
  dataKey?: string; // x category key — overrides the root xDataKey
  // Category-axis values are always stringified, so the formatter sees a string —
  // letting examples share `(value) => value.substring(0, 3)` with the Recharts twin.
  tickFormatter?: (value: string, index: number) => string; // formats x tick labels
  label?: string; // axis title, centered below the tick labels
  hideDots?: boolean; // hides the tick dots beside this axis's labels
}

/** Presence shows the x-axis category labels. Renders nothing. */
const XAxis: FC<XAxisProps> = () => null;

export interface YAxisProps {
  dataKey?: string; // reserved for parity with the Recharts twin
  tickFormatter?: (value: number, index: number) => string; // formats y tick labels
  label?: string; // axis title, rotated alongside the tick labels
  hideDots?: boolean; // hides the tick dots beside this axis's labels
}

/** Presence shows the y value axis. Renders nothing. */
const YAxis: FC<YAxisProps> = () => null;

/** Presence shows the dashed horizontal split lines. Renders nothing. */
const Grid: FC = () => null;

export interface TooltipProps {
  variant?: TooltipVariant; // visual style of the tooltip surface
  roundness?: TooltipRoundness; // border-radius of the tooltip
  cursor?: boolean; // whether the vertical cursor line follows the pointer
  position?: TooltipPosition; // "variable" follows both axes (default); "fixed" pins the tooltip near the top and tracks the pointer's X
}

/** Presence enables the hover tooltip. Renders nothing. */
const Tooltip: FC<TooltipProps> = () => null;

export interface LegendProps {
  variant?: LegendVariant; // visual style of the legend indicators
  align?: "left" | "center" | "right"; // horizontal placement
  verticalAlign?: "top" | "middle" | "bottom"; // vertical placement
  isClickable?: boolean; // lets each entry toggle selection of its series
}

/** Presence enables the HTML legend overlay. Renders nothing. */
const Legend: FC<LegendProps> = () => null;

// ─────────────────────────────────────────────────────────────────────────────
// Children collection — walk the declarative config into plain objects the
// option builder consumes. <Dot> / <ActiveDot> are read from each <Line>'s own
// children; a missing dot child means that marker does not render.
// ─────────────────────────────────────────────────────────────────────────────

type LineSeriesConfig = {
  dataKey: string;
  strokeVariant: StrokeVariant;
  strokeWidth: number;
  curveType?: CurveType;
  animationType?: LineAnimationType;
  connectNulls: boolean;
  isClickable: boolean;
  glowing: boolean;
  enableBufferLine: boolean;
  dotVariant: DotVariant; // "none" when no <Dot> child is present
  activeDotVariant: DotVariant; // "none" when no <ActiveDot> child is present
};

type XAxisSlot = {
  present: boolean;
  dataKey?: string;
  tickFormatter?: (value: string, index: number) => string;
  label?: string;
  hideDots: boolean;
};
type YAxisSlot = {
  present: boolean;
  dataKey?: string;
  tickFormatter?: (value: number, index: number) => string;
  label?: string;
  hideDots: boolean;
};
type TooltipSlot = {
  present: boolean;
  variant: TooltipVariant;
  roundness: TooltipRoundness;
  cursor: boolean;
  position: TooltipPosition;
};
type LegendSlot = {
  present: boolean;
  variant: LegendVariant;
  align: "left" | "center" | "right";
  verticalAlign: "top" | "middle" | "bottom";
  isClickable: boolean;
};
type BrushSlot = {
  present: boolean; // a <Brush> child was passed — replaces the old showBrush prop
  height?: number;
  formatLabel?: (value: string, index: number) => string;
  onChange?: (range: { startIndex: number; endIndex: number }) => void;
};

type CollectedConfig = {
  lines: LineSeriesConfig[];
  xAxis: XAxisSlot;
  yAxis: YAxisSlot;
  showGrid: boolean;
  tooltip: TooltipSlot;
  legend: LegendSlot;
  brush: BrushSlot;
};

function collectConfig(children: ReactNode): CollectedConfig {
  const lines: LineSeriesConfig[] = [];
  let xAxis: XAxisSlot = { present: false, hideDots: false };
  let yAxis: YAxisSlot = { present: false, hideDots: false };
  let showGrid = false;
  let tooltip: TooltipSlot = {
    present: false,
    variant: "default",
    roundness: "lg",
    cursor: true,
    position: "variable",
  };
  let legend: LegendSlot = {
    present: false,
    variant: "rounded-square",
    align: "right",
    verticalAlign: "top",
    isClickable: false,
  };
  let brush: BrushSlot = { present: false };

  Children.forEach(children, (child) => {
    if (!isValidElement(child)) return;
    const type = child.type;

    if (type === Line) {
      const props = child.props as LineProps;
      let dotVariant: DotVariant = "none";
      let activeDotVariant: DotVariant = "none";
      Children.forEach(props.children, (dotChild) => {
        if (!isValidElement(dotChild)) return;
        if (dotChild.type === Dot) {
          dotVariant = (dotChild.props as DotProps).variant ?? "default";
        } else if (dotChild.type === ActiveDot) {
          activeDotVariant = (dotChild.props as DotProps).variant ?? "default";
        }
      });
      lines.push({
        dataKey: props.dataKey,
        // The Recharts twin defaults a <Line> to a solid stroke (its <Area>
        // defaults to dashed — a divergence intentionally preserved here).
        strokeVariant: props.strokeVariant ?? "solid",
        strokeWidth: props.strokeWidth ?? STROKE_WIDTH,
        curveType: props.curveType,
        animationType: props.animationType,
        connectNulls: props.connectNulls ?? false,
        isClickable: props.isClickable ?? false,
        glowing: props.glowing ?? false,
        enableBufferLine: props.enableBufferLine ?? false,
        dotVariant,
        activeDotVariant,
      });
    } else if (type === XAxis) {
      const props = child.props as XAxisProps;
      xAxis = {
        present: true,
        dataKey: props.dataKey,
        tickFormatter: props.tickFormatter,
        label: props.label,
        hideDots: props.hideDots ?? false,
      };
    } else if (type === YAxis) {
      const props = child.props as YAxisProps;
      yAxis = {
        present: true,
        dataKey: props.dataKey,
        tickFormatter: props.tickFormatter,
        label: props.label,
        hideDots: props.hideDots ?? false,
      };
    } else if (type === Grid) {
      showGrid = true;
    } else if (type === Tooltip) {
      const props = child.props as TooltipProps;
      tooltip = {
        present: true,
        variant: props.variant ?? "default",
        roundness: props.roundness ?? "lg",
        cursor: props.cursor ?? true,
        position: props.position ?? "variable",
      };
    } else if (type === Legend) {
      const props = child.props as LegendProps;
      legend = {
        present: true,
        variant: props.variant ?? "rounded-square",
        align: props.align ?? "right",
        verticalAlign: props.verticalAlign ?? "top",
        isClickable: props.isClickable ?? false,
      };
    } else if (type === Brush) {
      const props = child.props as BrushProps;
      brush = {
        present: true,
        height: props.height,
        formatLabel: props.formatLabel,
        onChange: props.onChange,
      };
    }
  });

  return { lines, xAxis, yAxis, showGrid, tooltip, legend, brush };
}

// Color plumbing (ChartConfig, getColorsCount, distributeColors, buildChartCss,
// normalizeColor, withAlpha, ResolvedColors, resolveColors, seriesPaint) now
// lives in @/components/evilcharts/ui/echarts-chart and is imported at the top of this file.
// Dot helpers (DotVariant, DotItemStyleOption, DotStyle, DOT_SIZES, dotItemStyle,
// dotStyle, sampleGradient) live in @/components/evilcharts/ui/echarts-dot.

// Builds the stacked glow overlay series for one <Line glowing> (see
// GLOW_LAYERS). Each copy is silent, tooltip-less, and z-ordered beneath the real
// line, so the widening low-alpha gradient strokes read as a soft colored blur
// that tracks the series' gradient exactly like the stroke does. When the line
// shows resting dots, each copy also draws an oversized faint symbol — coloured
// per-datum via sampleGradient — so the markers bloom too. `selectionDim` fades
// the whole glow with its parent when another series is selected; the
// emphasis/blur styles let it focus/dim WITH its parent under
// enableHoverHighlight (the root dispatch-links these ids — see companionIdsByKey).
function buildGlowSeries(params: {
  key: string;
  paint: string | echarts.graphic.LinearGradient;
  slots: string[];
  values: (number | null)[];
  curve: { smooth: boolean; step: "middle" | false };
  connectNulls: boolean;
  z: number;
  selectionDim: number;
  dotSize: number;
}): LineSeriesOption[] {
  const { key, paint, slots, values, curve, connectNulls, z, selectionDim, dotSize } = params;
  const multiColor = slots.length > 1;
  const base = slots[0] ?? "rgba(120, 120, 120, 1)";
  const showDots = dotSize > 0;

  return GLOW_LAYERS.map((layer, i): LineSeriesOption => {
    const glowOpacity = layer.opacity * selectionDim;
    const blurOpacity = glowOpacity * 0.3;
    // Per-datum halo colours so a gradient glow tints each dot at its own
    // x-position, matching sampleGradient on the real dots.
    const glowData: LinePoint[] =
      !multiColor || !showDots
        ? values
        : values.map((value, idx): LinePoint => {
            if (value === null) return null;
            const t = values.length > 1 ? idx / (values.length - 1) : 0;
            const color = sampleGradient(slots, t);
            return {
              value,
              itemStyle: { color, opacity: glowOpacity },
              emphasis: { itemStyle: { color, opacity: glowOpacity } },
            };
          });

    return {
      id: `__glow-${i}-${key}`,
      type: "line",
      data: glowData,
      smooth: curve.smooth,
      step: curve.step,
      connectNulls,
      silent: true,
      showSymbol: showDots,
      symbol: "circle",
      symbolSize: showDots ? dotSize + layer.symbolPad : 0,
      tooltip: { show: false },
      z,
      lineStyle: {
        color: paint,
        width: layer.width,
        opacity: glowOpacity,
        // Feathers this layer's edge so the stack reads as one smooth falloff
        // rather than concentric bands (see GLOW_LAYERS).
        shadowBlur: layer.blur,
        // Full-alpha shadow color: the element's own `opacity` above already
        // scales its shadow, so pre-dimming here squares the alpha and washes
        // the halo out.
        shadowColor: sampleGradient(slots, 0.5),
        cap: "round",
        join: "round",
      },
      itemStyle: multiColor ? { opacity: glowOpacity } : { color: base, opacity: glowOpacity },
      // Focus/dim WITH the parent line: on the parent's hover the root highlights
      // these ids (emphasis → normal glow); when another series is hovered the
      // parent's focus:"series" blurs these to a fainter still.
      emphasis: {
        focus: "none",
        scale: false,
        lineStyle: { opacity: glowOpacity },
        itemStyle: { opacity: glowOpacity },
      },
      blur: { lineStyle: { opacity: blurOpacity }, itemStyle: { opacity: blurOpacity } },
    };
  });
}

// Brush overlays (BrushRange, BrushGeometry, BrushOverlayElements,
// BrushOverlayParams, syncBrushOverlay) live in @/components/evilcharts/ui/echarts-brush
// and are imported at the top of this file.

// ─────────────────────────────────────────────────────────────────────────────
// Curve mapping — linear → straight, step → step:"middle", everything else → smooth.
// ─────────────────────────────────────────────────────────────────────────────

function curveConfig(curveType: CurveType): { smooth: boolean; step: "middle" | false } {
  // Recharts "step" is d3's curveStep: the transition happens at the MIDPOINT
  // between points, so each dot sits centered on its plateau.
  if (curveType === "step") return { smooth: false, step: "middle" };
  if (curveType === "linear") return { smooth: false, step: false };
  return { smooth: true, step: false };
}

// ─────────────────────────────────────────────────────────────────────────────
// Selection opacities — dims a series only when another one is selected. Lines
// have no fill, so only the stroke and dots carry an opacity here.
// ─────────────────────────────────────────────────────────────────────────────

function getOpacity(selected: string | null, key: string) {
  if (selected === null || selected === key) return { stroke: 1, dot: 1 };
  return { stroke: 0.3, dot: 0.3 };
}

// ─────────────────────────────────────────────────────────────────────────────
// Loading skeleton helpers
// ─────────────────────────────────────────────────────────────────────────────

// Skeleton data as a smooth random walk in a comfortable band — reads like a
// resting chart instead of raw noise spikes.
function getLoadingData(points: number): number[] {
  const rows: number[] = [];
  let value = 30 + Math.random() * 20;
  for (let i = 0; i < points; i++) {
    value = Math.min(58, Math.max(16, value + (Math.random() - 0.5) * 16));
    rows.push(Math.round(value));
  }
  return rows;
}

// Gradient stops forming a hard clip window around `center`: full `peak` alpha
// inside, zero outside, with a small feather so the edge isn't aliased.
// `center` may run outside [0, 1] so the window fully enters and exits the frame.
function shimmerWindowStops(center: number, color: string, peak: number) {
  const half = LOADING_SHIMMER_BAND;
  const feather = LOADING_SHIMMER_FEATHER;

  const alphaAt = (x: number) => {
    const dist = Math.abs(x - center);
    if (dist <= half - feather) return peak;
    if (dist >= half) return 0;
    // Sine-eased falloff — a linear ramp still reads as a hard cut.
    return peak * Math.sin(((1 - (dist - (half - feather)) / feather) * Math.PI) / 2);
  };

  const offsets = [
    0,
    center - half,
    center - half + feather,
    center,
    center + half - feather,
    center + half,
    1,
  ]
    .filter((x) => x >= 0 && x <= 1)
    .sort((a, b) => a - b);

  const stops: { offset: number; color: string }[] = [];
  for (const offset of offsets) {
    if (stops.length === 0 || offset - stops[stops.length - 1].offset > 1e-4) {
      stops.push({ offset, color: withAlpha(color, alphaAt(offset)) });
    }
  }
  return stops;
}

// Tooltip HTML primitives (roundnessClass, tooltipVariantClass,
// tooltipIndicatorHtml, tooltipRow, tooltipShell) live in
// @/components/evilcharts/ui/echarts-tooltip; indicatorBackground lives in
// @/components/evilcharts/ui/echarts-chart. Both are imported at the top of this file.

// The `__buffer-` prefix marks the dashed forecast overlay of a buffer line; it
// carries the SAME key's value, so the tooltip recovers the key from it (see
// createTooltipFormatter). Every other `__`-prefixed series (mini chart, loading
// skeleton, hover-reveal base) is truly internal and never surfaces.
const BUFFER_PREFIX = "__buffer-";
// The `__reveal-` prefix marks the muted base layer of a hover-reveal line — see
// buildLineSeries. Internal, so the tooltip drops it like the mini/loading rows.
const REVEAL_PREFIX = "__reveal-";

// Legend overlay (legendFillStyle, legendOutlineStyle, LegendIndicator,
// LegendOverlay) lives in @/components/evilcharts/ui/echarts-legend and is imported at the
// top of this file.

// ─────────────────────────────────────────────────────────────────────────────
// Option builders — pure functions from a snapshot context to ECharts option
// fragments. The component reads its refs and renderer size ONCE per build into
// this context; nothing below touches React state or the chart instance, so
// each fragment can be reasoned about (and tested) in isolation.
// ─────────────────────────────────────────────────────────────────────────────

type OptionBuildContext = {
  data: Record<string, unknown>[];
  config: ChartConfig;
  lines: LineSeriesConfig[];
  curveType: CurveType;
  selectedDataKey: string | null;
  hasSelection: boolean;
  showGrid: boolean;
  xAxisSlot: XAxisSlot;
  yAxisSlot: YAxisSlot;
  tooltipSlot: TooltipSlot;
  legendSlot: LegendSlot;
  isLoading: boolean;
  loadingData: () => number[];
  showBrush: boolean;
  brushHeight: number;
  enableHoverHighlight: boolean;
  enableHoverReveal: boolean; // hover colors each line up to the pointer, mutes the rest
  revealIndex: number | null; // pointer's x-index while revealing (null = idle → chart looks normal)
  revealSink: Record<string, unknown[]>; // buildLineSeries writes each line's full per-datum points here for the hover handler
  resolved: ResolvedColors;
  rendererSize: { width: number; height: number }; // anchored reveal stroke gradients span the plot in absolute pixels
  categories: string[];
  brushRange: BrushRange; // zoom window carried through rebuilds
  getHoveredKey: () => string | null; // read per tooltip render — hover never repushes the option
};

// Grid insets plus the footer band reserved for the brush. ECharts 6 contains
// axis labels automatically (the legacy `containLabel` flag now only triggers a
// deprecation warning).
function buildChartLayout({ legendSlot, xAxisSlot, showBrush, brushHeight }: OptionBuildContext): {
  grid: GridComponentOption;
  brushBottom: number;
} {
  const legendTop = legendSlot.present && legendSlot.verticalAlign === "top";
  const legendBottom = legendSlot.present && legendSlot.verticalAlign === "bottom";
  // Clearance covers the x-axis labels plus the same breathing room the
  // Recharts twin leaves between them and the brush. An x-axis TITLE renders
  // below the labels (nameGap), so it needs its own band above the brush frame.
  const brushGap = showBrush ? brushHeight + 30 + (xAxisSlot.label ? 22 : 0) : 0;

  return {
    grid: {
      left: 8,
      right: 8,
      top: legendTop ? 42 : 16,
      bottom: 8 + brushGap + (legendBottom ? 34 : 0),
    },
    brushBottom: legendBottom ? 34 : 6,
  };
}

function buildMainAxes(ctx: OptionBuildContext): { xAxis: XAxisOption; yAxis: YAxisOption } {
  const { xAxisSlot, yAxisSlot, showGrid, isLoading, categories, loadingData } = ctx;
  const { tokens } = ctx.resolved;

  const axisLabelColor = tokens.mutedForeground;
  const splitLineColor = withAlpha(tokens.border, GRID_LINE_OPACITY);
  // Gridline gray as an opaque color — see flattenColor.
  const tickDotColor = flattenColor(splitLineColor, tokens.background);

  const xTickFormatter = xAxisSlot.tickFormatter;
  const yTickFormatter = yAxisSlot.tickFormatter;

  const xAxis: XAxisOption = {
    type: "category",
    boundaryGap: false,
    show: true,
    data: isLoading ? loadingData().map((_, i) => i) : categories,
    // Axis title — same size/color as the tick labels, pushed clear of them.
    name: isLoading ? undefined : xAxisSlot.label,
    nameLocation: "middle",
    nameGap: 30,
    nameTextStyle: { color: axisLabelColor, fontSize: 10 },
    axisLine: { show: false },
    // Tick DOTS: a near-zero-length tick whose round caps form a true circle,
    // in the gridline gray (flattened opaque so the caps don't stack).
    axisTick: {
      show: !isLoading && xAxisSlot.present && !xAxisSlot.hideDots,
      // Category ticks default to the BOUNDARY between categories, which on a
      // boundaryGap axis drops the dot in the gap instead of under its label. A
      // no-op here (boundaryGap is false) — set for parity with the bar/composed
      // charts. The y-axis is always type:"value", which has no such option.
      alignWithLabel: true,
      length: 0.5,
      lineStyle: { color: tickDotColor, width: 3, cap: "round" },
    },
    splitLine: { show: false },
    axisLabel: {
      show: !isLoading && xAxisSlot.present,
      color: axisLabelColor,
      fontSize: 10,
      margin: 8,
      formatter: xTickFormatter
        ? (value: string, index: number) => xTickFormatter(value, index)
        : undefined,
    },
  };

  // An ECharts axis with `show: false` hides its splitLines too, but Recharts'
  // <CartesianGrid> draws with or without a visible <YAxis>. Keep the axis on
  // whenever <Grid/> is present and gate the LABELS on <YAxis/> instead.
  const yAxis: YAxisOption = {
    type: "value",
    show: yAxisSlot.present || showGrid,
    // Axis title — rendered rotated alongside the tick labels, same styling.
    name: isLoading ? undefined : yAxisSlot.label,
    nameLocation: "middle",
    nameGap: 38,
    nameTextStyle: { color: axisLabelColor, fontSize: 10 },
    axisLine: { show: false },
    // Same tick dots as the x-axis, beside each value label. No alignWithLabel
    // here: ECharts types it on the CATEGORY axis only, and a value axis already
    // puts its ticks on the labels.
    axisTick: {
      show: yAxisSlot.present && !isLoading && !yAxisSlot.hideDots,
      length: 0.5,
      lineStyle: { color: tickDotColor, width: 3, cap: "round" },
    },
    splitLine: {
      // Hidden while loading — the skeleton floats on a clean canvas.
      show: showGrid && !isLoading,
      lineStyle: { color: splitLineColor, type: [3, 3] as [number, number], width: 1 },
    },
    axisLabel: {
      // Hidden while loading — skeleton values are meaningless, and the
      // Recharts YAxis unmounts during loading too.
      show: yAxisSlot.present && !isLoading,
      color: axisLabelColor,
      fontSize: 10,
      margin: 8,
      formatter: yTickFormatter
        ? (value: number, index: number) => yTickFormatter(value, index)
        : undefined,
    },
  };

  return { xAxis, yAxis };
}

// Tooltip HTML builder, closed over the build context. `trigger: "axis"` hands
// the formatter every series' value at the hovered x; buffer overlays and the
// mini/loading series are folded out here (see BUFFER_PREFIX).
function createTooltipFormatter(ctx: OptionBuildContext) {
  const { config, selectedDataKey, tooltipSlot, getHoveredKey } = ctx;

  return (params: unknown): string => {
    const rows = Array.isArray(params) ? params : [params];
    if (!rows.length) return "";

    const first = rows[0] as { axisValue?: string | number; name?: string };
    // Label shows the RAW axis value — matches ChartTooltipContent (no tick formatter).
    const axisValue = first.axisValue ?? first.name ?? "";
    const label = String(axisValue);

    // Dedupe by effective key: a buffer line contributes both its solid part
    // (id=key) and its dashed overlay (id=`__buffer-{key}`) at the shared
    // second-to-last point. Keep the first non-null value seen per key so the
    // final point (only the overlay has data there) still shows its number.
    const seen = new Set<string>();
    const body = rows
      .map((param) => {
        const p = param as {
          seriesId?: string;
          seriesName?: string;
          value?: number | string | null;
        };
        const rawId = String(p.seriesId ?? "");
        // Map the dashed buffer overlay back onto its series; drop every other
        // internal series (mini chart, loading skeleton).
        const key = rawId.startsWith(BUFFER_PREFIX)
          ? rawId.slice(BUFFER_PREFIX.length)
          : rawId.startsWith("__")
            ? ""
            : (p.seriesId ?? p.seriesName ?? "");
        if (!key) return "";
        // A null value means this series does not reach the hovered x (a buffer
        // line's solid part stops before the last point) — skip it, and let the
        // overlay row for the same key stand in.
        if (p.value === null || p.value === undefined) return "";
        if (seen.has(key)) return "";
        seen.add(key);

        const item = config[key];
        const colorsCount = item ? getColorsCount(item) : 1;
        const labelText = typeof item?.label === "string" ? item.label : (p.seriesName ?? key);
        const hovered = getHoveredKey();
        const dimmed =
          (selectedDataKey != null && selectedDataKey !== key) ||
          (hovered != null && hovered !== key)
            ? " opacity-30"
            : "";
        const value =
          typeof p.value === "number" ? p.value.toLocaleString() : String(p.value ?? "");

        return tooltipRow({
          indicatorHtml: tooltipIndicatorHtml(key, colorsCount),
          labelText,
          valueText: value,
          dimmed,
        });
      })
      .join("");

    return tooltipShell({
      label,
      body,
      roundness: tooltipSlot.roundness,
      variant: tooltipSlot.variant,
    });
  };
}

function buildTooltipOption(ctx: OptionBuildContext): TooltipComponentOption {
  const { tooltipSlot, isLoading } = ctx;
  const { tokens } = ctx.resolved;

  return {
    ...tooltipBaseOption({
      present: tooltipSlot.present && !isLoading,
      cursor: tooltipSlot.cursor,
      tokens,
      position: tooltipSlot.position,
      axisPointerColor: withAlpha(tokens.border, AXIS_POINTER_OPACITY),
      strokeWidth: STROKE_WIDTH,
    }),
    formatter: createTooltipFormatter(ctx),
  };
}

// ── Brush — the evil-brush "line" look, canvas-style: a real mini chart of the
// full data (strokes only, no fill, like EvilBrush variant="line") in a second
// grid, with a transparent slider dataZoom laid over it. Both zoom entries target
// only the MAIN x-axis, so the mini chart never filters itself. Only called when
// `showBrush` is set.
function buildBrushOption(
  ctx: OptionBuildContext,
  brushBottom: number,
): {
  miniGrid: GridComponentOption;
  miniXAxis: XAxisOption;
  miniYAxis: YAxisOption;
  miniSeries: LineSeriesOption[];
  dataZoom: DataZoomComponentOption[];
} {
  const { data, lines, curveType, selectedDataKey, brushHeight, categories } = ctx;
  const { tokens } = ctx.resolved;

  const miniGrid: GridComponentOption = {
    left: 8,
    right: 8,
    bottom: brushBottom,
    height: brushHeight,
    // No visible axes here — opt out of label containment so the mini chart
    // spans the full brush frame.
    outerBoundsMode: "none",
  };

  const miniXAxis: XAxisOption = {
    type: "category",
    gridIndex: 1,
    boundaryGap: false,
    show: false,
    data: categories,
    axisPointer: { show: false },
  };

  const miniYAxis: YAxisOption = { type: "value", gridIndex: 1, show: false };

  const miniSeries: LineSeriesOption[] = lines.map((line) => {
    const key = line.dataKey;
    const base = (ctx.resolved.series[key] ?? [])[0] ?? "rgba(120, 120, 120, 1)";
    const curve = curveConfig(line.curveType ?? curveType);

    // The mini chart mirrors the click selection: unselected series recede
    // by the same ratio as the main plot.
    const strokeDim = getOpacity(selectedDataKey, key).stroke;

    return {
      id: `__mini-${key}`,
      type: "line",
      xAxisIndex: 1,
      yAxisIndex: 1,
      data: data.map((row) => Number(row[key]) || 0),
      smooth: curve.smooth,
      step: curve.step,
      connectNulls: line.connectNulls,
      silent: true,
      showSymbol: false,
      emphasis: { disabled: true },
      tooltip: { show: false },
      lineStyle: { color: base, width: 1, opacity: BRUSH_STROKE_OPACITY * strokeDim },
      z: 0,
    };
  });

  const dataZoom = buildBrushDataZoom({
    brushBottom,
    brushHeight,
    brushRange: ctx.brushRange,
    fillerColor: withAlpha(tokens.foreground, BRUSH_FILLER_OPACITY),
  });

  return { miniGrid, miniXAxis, miniYAxis, miniSeries, dataZoom };
}

// Loading skeleton — ONE gray wave regardless of declared lines (Recharts
// parity: its skeleton is a single stroke-only LoadingLine), swept by the
// shimmer rAF. No fill: a <Line> has no area, so the skeleton is stroke-only too.
function buildLoadingOption(
  ctx: OptionBuildContext,
  frame: { grid: GridComponentOption; xAxis: XAxisOption; yAxis: YAxisOption },
): EChartsOption {
  const { tokens } = ctx.resolved;
  const curve = curveConfig(ctx.curveType);

  return {
    animation: false,
    grid: frame.grid,
    xAxis: frame.xAxis,
    yAxis: frame.yAxis,
    tooltip: { show: false },
    series: [
      {
        id: "__loading",
        type: "line",
        data: ctx.loadingData(),
        smooth: curve.smooth,
        step: curve.step,
        showSymbol: false,
        silent: true,
        // Invisible until the first shimmer tick positions the clip window.
        lineStyle: { color: withAlpha(tokens.foreground, 0), width: 1 },
        z: 1,
      },
    ],
  };
}

// The plotted values for a line, optionally decorated per-datum. Multi-color
// lines tint each symbol with the gradient's color at its own x-position (like
// the Recharts dots); single-color lines return the raw numbers. `null` entries
// pass through untouched — they carve the gap a buffer line's two parts leave.
type LinePoint =
  | number
  | null
  | {
      value: number | null;
      itemStyle: DotItemStyleOption;
      emphasis: { itemStyle: DotItemStyleOption };
    };

function buildLineSeries(ctx: OptionBuildContext): LineSeriesOption[] {
  const {
    data,
    config,
    lines,
    curveType,
    selectedDataKey,
    hasSelection,
    enableHoverHighlight,
    enableHoverReveal,
    revealIndex,
    revealSink,
    resolved,
    rendererSize,
  } = ctx;
  const background = resolved.tokens.background;

  return lines.flatMap((line): LineSeriesOption[] => {
    const key = line.dataKey;
    const slots = resolved.series[key] ?? ["rgba(120, 120, 120, 1)"];
    const paint = seriesPaint(slots);
    const isSelected = selectedDataKey === key;
    const opacity = getOpacity(selectedDataKey, key);
    const curve = curveConfig(line.curveType ?? curveType);
    const multiColor = slots.length > 1;

    const restingDot = dotStyle(line.dotVariant, paint, background);
    const activeDot = dotStyle(line.activeDotVariant, paint, background);
    const restingVisible = line.dotVariant !== "none";
    const dotOpacity = opacity.dot;

    const values = data.map((row) => Number(row[key]) || 0);
    const n = values.length;
    // Hover-reveal is a root-level mode and owns the whole line rendering, so it
    // takes precedence over a per-line buffer tail (and the glow overlay) when
    // both are set.
    const reveal = enableHoverReveal;
    const buffer = !reveal && line.enableBufferLine && n >= 2;
    const revealActive = reveal && revealIndex !== null;

    // The dash pattern for the MAIN line. A buffer line keeps its body solid and
    // dashes only the tail overlay, so its main part is always solid regardless
    // of strokeVariant (matches the Recharts twin, which suppresses the base
    // dasharray while the buffer shape manages its own).
    const mainDash: "solid" | [number, number] =
      buffer || line.strokeVariant === "solid" ? "solid" : [3, 3];

    // The reveal truncates the line to the cursor, which would COMPRESS a
    // bbox-relative stroke gradient into the shorter span — misaligning it from
    // the index-sampled dots. Anchor the stroke to the plot in absolute pixels so
    // every x keeps its own color even when the line stops short.
    const strokePaint =
      reveal && multiColor
        ? new echarts.graphic.LinearGradient(
            8,
            0,
            Math.max(rendererSize.width - 8, 9),
            0,
            slots.map((color, i) => ({ offset: i / (slots.length - 1), color })),
            true,
          )
        : paint;

    // Turn a value list into ECharts data — attaching per-datum symbol colors
    // for multi-color lines, and passing `null` gaps through so a buffer line's
    // two parts each draw only their own segment.
    const toPoints = (vals: (number | null)[]): LinePoint[] =>
      !multiColor
        ? vals
        : vals.map((value, i): LinePoint => {
            if (value === null) return null;
            const t = vals.length > 1 ? i / (vals.length - 1) : 0;
            const pointColor = sampleGradient(slots, t);
            return {
              value,
              itemStyle: {
                ...dotItemStyle(
                  restingVisible ? line.dotVariant : line.activeDotVariant,
                  pointColor,
                  background,
                ),
                opacity: dotOpacity,
              },
              emphasis: {
                itemStyle: {
                  ...dotItemStyle(
                    line.activeDotVariant === "none" ? "default" : line.activeDotVariant,
                    pointColor,
                    background,
                  ),
                  opacity: 1,
                },
              },
            };
          });

    // Snapshot the FULL per-datum points (with the multi-color dot itemStyle) so
    // the reveal hover handler can slice them without losing each dot's sampled
    // gradient color — plain values would fall back to the default palette.
    if (reveal) revealSink[key] = toPoints(values);

    // Buffer line: the solid MAIN part drops the last point (its final segment
    // becomes the dashed overlay). Reveal instead TRUNCATES the real series at the
    // cursor's x-index (points beyond it null'd), so its line stops there and the
    // muted base layer shows through past it. When idle (revealIndex null) the
    // real series carries its full data — the chart looks completely normal.
    const mainValues: (number | null)[] = buffer
      ? values.map((v, i) => (i === n - 1 ? null : v))
      : revealActive
        ? sliceToNull(values, revealIndex as number)
        : values;

    const z = isSelected ? 3 : hasSelection ? 1 : 2;

    // Glow overlays sit UNDER the real line (built first, same z; equal-z series
    // paint in array order). They follow the FULL solid path so the halo stays
    // continuous even beneath a dashed or buffer tail. Suppressed under reveal:
    // a full-length colored halo would bleed past the cursor and defeat the mute.
    const glowSeries =
      line.glowing && !reveal
        ? buildGlowSeries({
            key,
            paint,
            slots,
            values,
            curve,
            connectNulls: line.connectNulls,
            z,
            selectionDim: opacity.stroke,
            dotSize: restingVisible ? restingDot.size : 0,
          })
        : [];

    const mainSeries: LineSeriesOption = {
      id: key,
      name: typeof config[key]?.label === "string" ? config[key]?.label : key,
      type: "line",
      data: toPoints(mainValues),
      smooth: curve.smooth,
      step: curve.step,
      connectNulls: line.connectNulls,
      cursor: line.isClickable ? "pointer" : "default",
      // By default ECharts only fires mouse events on the symbols — this makes
      // the line itself clickable too, like the Recharts <Line>.
      // (`true` covers both; the deprecated `triggerLineEvent` did the same.)
      triggerEvent: line.isClickable,
      showSymbol: restingVisible,
      symbol: "circle",
      symbolSize: restingVisible ? restingDot.size : activeDot.size,
      z,
      lineStyle: {
        // Anchored plot-wide gradient while revealing a multi-color line (see
        // strokePaint), the normal series paint otherwise.
        color: strokePaint,
        width: line.strokeWidth,
        opacity: opacity.stroke,
        type: mainDash,
        dashOffset: 0,
      },
      itemStyle: multiColor
        ? { opacity: dotOpacity }
        : {
            ...(restingVisible ? restingDot.itemStyle : activeDot.itemStyle),
            opacity: dotOpacity,
          },
      emphasis: {
        // focus "series" blurs every other series in this grid while one is
        // hovered — the hover twin of the click selection (opt-in via
        // enableHoverHighlight). Suppressed entirely while a series is
        // click-selected: the selection dim owns the canvas, so hover
        // highlighting stops until the selection clears (the option rebuilds on
        // selection change, making this a build-time conditional). Reveal owns the
        // hover visual, so native focus-blur stands down when it is on (they must
        // not blend). Otherwise the active dot is the only emphasis.
        focus: enableHoverHighlight && !enableHoverReveal && !hasSelection ? "series" : "none",
        scale: restingVisible ? activeDot.size / Math.max(restingDot.size, 1) : 1,
        ...(multiColor ? {} : { itemStyle: { ...activeDot.itemStyle, opacity: 1 } }),
      },
      // Blur styling mirrors the click-selection dim (stroke 0.3 / dot 0.3);
      // inert unless a series is focused via enableHoverHighlight.
      blur: {
        lineStyle: { opacity: 0.3 },
        itemStyle: { opacity: 0.3 },
      },
    };

    // Hover-reveal: a muted gray BASE line of the FULL series sits one z below the
    // real one. It is invisible while idle (opacity 0 → the chart looks normal)
    // and fades in only while hovering, so the region PAST the cursor — where the
    // truncated real line has stopped — shows as neutral gray. Lines have no fill,
    // so the base is a line only (no areaStyle) and needs no stack mirror.
    if (reveal) {
      const muted = resolved.tokens.mutedForeground;
      const revealBase: LineSeriesOption = {
        id: `${REVEAL_PREFIX}${key}`,
        type: "line",
        // Only the region FROM the cursor onward (null before it), so the gray
        // never sits under the colored part — the two meet exactly at the pointer
        // and their colors can't mix.
        data: revealActive ? sliceFrom(values, revealIndex as number) : values,
        smooth: curve.smooth,
        step: curve.step,
        connectNulls: false,
        silent: true,
        showSymbol: false,
        symbol: "circle",
        z: z - 1,
        // Neutral gray, SAME dash pattern as the colored line, no fill.
        lineStyle: {
          color: muted,
          width: line.strokeWidth,
          type: mainDash,
          opacity: revealActive ? 0.3 : 0,
        },
        emphasis: { disabled: true },
        blur: { lineStyle: { opacity: revealActive ? 0.3 : 0 } },
        tooltip: { show: false },
      };
      return [revealBase, mainSeries];
    }

    if (!buffer) return [...glowSeries, mainSeries];

    // Dashed forecast overlay — draws ONLY the last segment. Silent, so it never
    // intercepts clicks/hover; it still feeds the axis tooltip (silent series
    // are aggregated by axis), which is why the last point keeps its number.
    const bufferValues: (number | null)[] = values.map((v, i) => (i >= n - 2 ? v : null));
    const bufferSeries: LineSeriesOption = {
      id: `${BUFFER_PREFIX}${key}`,
      type: "line",
      data: toPoints(bufferValues),
      smooth: curve.smooth,
      step: curve.step,
      connectNulls: true,
      silent: true,
      showSymbol: restingVisible,
      symbol: "circle",
      symbolSize: restingVisible ? restingDot.size : activeDot.size,
      z,
      lineStyle: {
        color: paint,
        width: line.strokeWidth,
        opacity: opacity.stroke,
        type: BUFFER_DASH,
      },
      itemStyle: multiColor
        ? { opacity: dotOpacity }
        : {
            ...(restingVisible ? restingDot.itemStyle : activeDot.itemStyle),
            opacity: dotOpacity,
          },
      // The dashed tail is a separate silent series, so focus:"series" on its
      // parent would blur it apart from the line it belongs to. The root
      // dispatch-links this id (see companionIdsByKey) so it focuses WITH its
      // parent; these styles give it the parent's look while focused and the
      // click-selection dim while another series is hovered.
      emphasis: {
        focus: "none",
        scale: false,
        lineStyle: { opacity: opacity.stroke },
        itemStyle: { opacity: dotOpacity },
      },
      blur: { lineStyle: { opacity: 0.3 }, itemStyle: { opacity: 0.3 } },
    };

    return [...glowSeries, mainSeries, bufferSeries];
  });
}

// Copy a value list with everything AFTER `idx` nulled — the hover-reveal cut:
// the colored real line keeps its data up to the cursor and drops the rest, so
// (with connectNulls false) its stroke stops dead at the pointer.
function sliceToNull<T>(vals: readonly T[], idx: number): (T | null)[] {
  return vals.map((v, i) => (i > idx ? null : v));
}

// Copy a value list with everything BEFORE `idx` nulled — the reveal's gray tail.
// The muted base keeps only the region from the cursor onward, so it never sits
// under the colored part; both include `idx` so they meet at the pointer.
// Generic so it preserves per-datum point objects (multi-color dot itemStyle).
function sliceFrom<T>(vals: readonly T[], idx: number): (T | null)[] {
  return vals.map((v, i) => (i < idx ? null : v));
}

// ─────────────────────────────────────────────────────────────────────────────
// Live imperative state — everything the ECharts event handlers, rAF loops, and
// theme/resize repushes read or write OUTSIDE the React render cycle, grouped in
// one ref-stable object so the whole imperative surface is visible at a glance.
// None of it is render output, which is exactly why it is not React state.
// ─────────────────────────────────────────────────────────────────────────────

type LiveState = {
  resolved: ResolvedColors | null; // colors read off the live DOM — feeds builds and rAF loops
  hoveredKey: string | null; // tooltip's view of hover — the legend's twin lives in React state
  hasRevealed: boolean; // the intro draw-in already played on this chart instance
  revealEndsAt: number; // performance.now() timestamp when the entrance settles
  loadingRows: number[] | null; // skeleton data, lazily rolled and re-rolled per shimmer sweep
  categories: string[]; // x labels of the last build, for the brush label pills
  dataLength: number; // row count, for the datazoom index math
  brushRange: BrushRange; // live zoom window — carried through every rebuild
  brushGeom: BrushGeometry | null; // brush footer layout of the last build
  brushOverlay: BrushOverlayElements | null; // zrender elements, owned by syncBrushOverlay
  brushHover: { inside: boolean; left: boolean; right: boolean };
  // seriesIndex → clickable key for the last build, `undefined` for internal
  // series. A line-body click (triggerEvent) reports only a seriesIndex, and
  // buffer lines add a second (`__buffer-`) series per key — so the index no
  // longer equals the key's position in seriesKeys and must be mapped explicitly.
  seriesKeyByIndex: (string | undefined)[];
  // key → its silent companion series ids (glow overlays + buffer tail) in the
  // main grid. Under enableHoverHighlight the root highlights/downplays these
  // together with the hovered parent, so focus:"series" can't strand a line's own
  // glow or forecast tail apart from it.
  companionIdsByKey: Map<string, string[]>;
  revealIndex: number | null; // hover-reveal pointer x-index (null = idle); read by builds and the reveal hover handler
  revealValues: Record<string, unknown[]>; // per-line FULL per-datum points (with dot itemStyle), sliced to the cursor on hover without a rebuild
  // Latest callbacks/flags for the imperative ECharts event handlers.
  handlers: {
    onBrushChange?: (range: { startIndex: number; endIndex: number }) => void;
    onSelectionChange?: (key: string | null) => void;
    clickableKeys: Set<string>;
    selectedDataKey: string | null;
    brushFormatLabel?: (value: string, index: number) => string;
    seriesKeys: string[];
    enableHoverHighlight: boolean;
    enableHoverReveal: boolean;
  };
  // Update-style re-push for paths that bypass React entirely (theme flips,
  // resizes) — set by the sync effect.
  repush: () => void;
};

// ─────────────────────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Apache ECharts port of the EvilCharts line chart, exposing a compound-as-config
 * API so its JSX reads identically to the Recharts twin. The root owns the data,
 * config, selection state, loading skeleton, intro reveal, and optional zoom
 * brush; every visual part — `<Line>`, `<XAxis>`, `<YAxis>`, `<Grid>`,
 * `<Tooltip>`, `<Legend>` — is composed as a declarative child that renders
 * nothing. The root walks those children by reference and drives a single
 * imperative ECharts instance. Fully self-contained: its only dependencies are
 * `react`, `echarts`, and `motion`.
 */
export function EChartsLineChart<TData extends Record<string, unknown>>({
  data,
  config,
  xDataKey,
  className,
  curveType = "linear",
  animation = true,
  animationType = "left-to-right",
  enableHoverHighlight = false,
  enableHoverReveal = false,
  defaultSelectedDataKey = null,
  onSelectionChange,
  isLoading = false,
  loadingPoints = LOADING_DEFAULT_POINTS,
  chartOptions,
  children,
}: EChartsLineChartProps<TData>) {
  const rawId = useId();
  const chartId = `chart-${rawId.replace(/:/g, "")}`;

  const containerRef = useRef<HTMLDivElement>(null);
  const mountRef = useRef<HTMLDivElement>(null);
  const echartsRef = useRef<EChartsInstance | null>(null);

  // The single imperative surface (see LiveState). `resolved` lives here rather
  // than in state: as state it forced an extra render pass and an effect whose
  // only job was to trigger the option push — the "chain of computations"
  // react.dev/learn/you-might-not-need-an-effect warns about. The object
  // identity is stable for the component's lifetime.
  const live = useRef<LiveState>({
    resolved: null,
    hoveredKey: null,
    hasRevealed: false,
    revealEndsAt: 0,
    loadingRows: null,
    categories: [],
    dataLength: 0,
    brushRange: { start: 0, end: 100 },
    brushGeom: null,
    brushOverlay: null,
    brushHover: { inside: false, left: false, right: false },
    seriesKeyByIndex: [],
    companionIdsByKey: new Map(),
    revealIndex: null,
    revealValues: {},
    handlers: {
      onBrushChange: undefined, // set per-render from the <Brush> child's onChange
      onSelectionChange,
      clickableKeys: new Set<string>(),
      selectedDataKey: defaultSelectedDataKey,
      brushFormatLabel: undefined, // set per-render from the <Brush> child's formatLabel
      seriesKeys: [],
      enableHoverHighlight,
      enableHoverReveal,
    },
    repush: () => {},
  }).current;

  // Skeleton rows roll lazily on first use — an impure useRef initializer would
  // re-roll Math.random() on every render.
  const loadingData = useCallback(
    () => (live.loadingRows ??= getLoadingData(loadingPoints)),
    [live, loadingPoints],
  );
  const shouldReduceMotion = useReducedMotion();

  const [selectedDataKey, setSelectedDataKey] = useState<string | null>(defaultSelectedDataKey);

  // Hover-highlight mirrors into the legend (React state) and tooltip
  // (live.hoveredKey — its formatter runs on every hover, and pushing an option
  // to sync it would reset ECharts' native blur state mid-hover).
  const [hoveredDataKey, setHoveredDataKey] = useState<string | null>(null);

  // ── Declarative config, collected from children by reference ─────────────────
  const collected = useMemo(() => collectConfig(children), [children]);
  const {
    lines,
    xAxis: xAxisSlot,
    yAxis: yAxisSlot,
    showGrid,
    tooltip: tooltipSlot,
    legend: legendSlot,
    brush: brushSlot,
  } = collected;
  // Brush is a <Brush> child now (not props): presence turns it on, its props
  // carry height/formatLabel/onChange.
  const showBrush = brushSlot.present;
  const brushHeight = brushSlot.height ?? 56;

  const seriesKeys = useMemo(() => lines.map((line) => line.dataKey), [lines]);

  // x category key: <XAxis dataKey> → root xDataKey → first data column no <Line> claims.
  const xCategoryKey = useMemo(() => {
    if (xAxisSlot.dataKey) return xAxisSlot.dataKey;
    if (xDataKey) return xDataKey as string;
    const firstRow = data[0];
    if (firstRow) {
      const claimed = new Set(seriesKeys);
      const found = Object.keys(firstRow).find((key) => !claimed.has(key));
      if (found) return found;
    }
    return "";
  }, [xAxisSlot.dataKey, xDataKey, data, seriesKeys]);

  // The intro draw-in follows the first line's setting, falling back to the root default.
  const effectiveAnimation = lines[0]?.animationType ?? animationType;

  const css = useMemo(() => buildChartCss(chartId, config), [chartId, config]);

  const hasSelection = selectedDataKey !== null;

  // Which series may be clicked to toggle selection (consulted by the click handler).
  const clickableKeys = useMemo(
    () => new Set(lines.filter((line) => line.isClickable).map((line) => line.dataKey)),
    [lines],
  );

  // Refresh the handlers' snapshot of the latest callbacks/flags every render.
  live.handlers = {
    onBrushChange: brushSlot.onChange,
    onSelectionChange,
    clickableKeys,
    selectedDataKey,
    brushFormatLabel: brushSlot.formatLabel,
    seriesKeys,
    enableHoverHighlight,
    enableHoverReveal,
  };
  live.dataLength = data.length;

  const toggleSelection = useCallback(
    (key: string) => {
      // A new click selection takes over the canvas dim, so any live hover
      // highlight is torn down at this moment — the mouseover guard then keeps it
      // from re-arming while the selection stands. Hover is only ever active
      // while NO selection exists (see that guard), so a hovered key here always
      // means this click is establishing a selection.
      if (live.hoveredKey !== null) {
        const chart = echartsRef.current;
        const companions = chart ? live.companionIdsByKey.get(live.hoveredKey) : undefined;
        if (chart && companions) {
          for (const seriesId of companions) chart.dispatchAction({ type: "downplay", seriesId });
        }
        live.hoveredKey = null;
        setHoveredDataKey(null);
      }
      setSelectedDataKey((prev) => {
        const next = prev === key ? null : key;
        onSelectionChange?.(next);
        return next;
      });
    },
    [live, onSelectionChange],
  );

  // Reposition the brush overlays from the live refs — safe to call from drag
  // events, hover tracking, and pushes alike, since it never touches setOption.
  const syncBrushOverlayNow = useCallback(() => {
    const chart = echartsRef.current;
    if (!chart) return;

    const geom = live.brushGeom;
    const tokens = live.resolved?.tokens;
    if (!geom || !tokens) {
      syncBrushOverlay(chart, live, null);
      return;
    }

    const range = live.brushRange;
    const categories = live.categories;
    const format = live.handlers.brushFormatLabel;
    const lastIndex = Math.max(categories.length - 1, 0);
    const startIndex = Math.round((range.start / 100) * lastIndex);
    const endIndex = Math.round((range.end / 100) * lastIndex);
    const labels =
      format && categories.length
        ? {
            start: format(categories[startIndex] ?? "", startIndex),
            end: format(categories[endIndex] ?? "", endIndex),
          }
        : null;

    syncBrushOverlay(chart, live, {
      range,
      geom,
      size: { width: chart.getWidth(), height: chart.getHeight() },
      tokens,
      labels,
      showLabels: live.brushHover.inside,
      hover: live.brushHover,
    });
  }, [live]);

  // ── Option builder ─────────────────────────────────────────────────────────
  // Thin orchestrator over the pure builders above: snapshot the imperative
  // surface (refs, renderer size) into an OptionBuildContext, then assemble.
  const buildOption = useCallback((): EChartsOption => {
    const resolved = live.resolved;
    if (!resolved) return {};

    const categories = data.map((row) => String(row[xCategoryKey]));
    live.categories = categories;

    // buildLineSeries fills this with each line's full per-datum points (with the
    // multi-color dot itemStyle) so the reveal hover handler slices real data.
    const revealSink: Record<string, unknown[]> = {};

    const ctx: OptionBuildContext = {
      data,
      config,
      lines,
      curveType,
      selectedDataKey,
      hasSelection,
      showGrid,
      xAxisSlot,
      yAxisSlot,
      tooltipSlot,
      legendSlot,
      isLoading,
      loadingData,
      showBrush,
      brushHeight,
      enableHoverHighlight,
      enableHoverReveal,
      revealIndex: live.revealIndex,
      revealSink,
      resolved,
      rendererSize: {
        width: echartsRef.current?.getWidth() ?? mountRef.current?.clientWidth ?? 0,
        height: echartsRef.current?.getHeight() ?? mountRef.current?.clientHeight ?? 0,
      },
      categories,
      brushRange: live.brushRange,
      getHoveredKey: () => live.hoveredKey,
    };

    const { grid, brushBottom } = buildChartLayout(ctx);
    live.brushGeom = showBrush ? { bottom: brushBottom, height: brushHeight } : null;

    const { xAxis, yAxis } = buildMainAxes(ctx);

    if (isLoading) return buildLoadingOption(ctx, { grid, xAxis, yAxis });

    const brush = showBrush ? buildBrushOption(ctx, brushBottom) : null;

    const series = [...buildLineSeries(ctx), ...(brush?.miniSeries ?? [])];
    // buildLineSeries has now filled revealSink with each line's full per-datum
    // points — hand them to the hover handler for slicing.
    if (enableHoverReveal) live.revealValues = revealSink;
    // Record the exact series order so a line-body click (which reports only a
    // seriesIndex) can recover its key — buffer/reveal/mini/loading series break
    // the "index === key position" shortcut, so map each index to its id here.
    live.seriesKeyByIndex = series.map((s) => {
      const id = String(s.id ?? "");
      return id && !id.startsWith("__") ? id : undefined;
    });
    // Map each key to its silent companion series ids (glow overlays, buffer tail,
    // hover-reveal base), mirroring exactly what buildLineSeries emits — the hover
    // handlers highlight/downplay these together with the parent so focus:"series"
    // never strands a line's own glow or forecast tail apart from it.
    const companionIdsByKey = new Map<string, string[]>();
    for (const line of lines) {
      const ids: string[] = [];
      if (line.glowing) {
        for (let i = 0; i < GLOW_LAYERS.length; i++) ids.push(`__glow-${i}-${line.dataKey}`);
      }
      if (line.enableBufferLine && data.length >= 2) ids.push(`${BUFFER_PREFIX}${line.dataKey}`);
      if (enableHoverReveal) ids.push(`${REVEAL_PREFIX}${line.dataKey}`);
      if (ids.length) companionIdsByKey.set(line.dataKey, ids);
    }
    live.companionIdsByKey = companionIdsByKey;

    return {
      animation: false,
      grid: brush ? [grid, brush.miniGrid] : grid,
      xAxis: brush ? [xAxis, brush.miniXAxis] : xAxis,
      yAxis: brush ? [yAxis, brush.miniYAxis] : yAxis,
      tooltip: buildTooltipOption(ctx),
      dataZoom: brush?.dataZoom,
      series,
    };
  }, [
    live,
    data,
    config,
    lines,
    xCategoryKey,
    curveType,
    selectedDataKey,
    hasSelection,
    showGrid,
    xAxisSlot,
    yAxisSlot,
    tooltipSlot,
    legendSlot,
    isLoading,
    loadingData,
    showBrush,
    brushHeight,
    enableHoverHighlight,
    enableHoverReveal,
  ]);

  // ── Init + resize + theme observer (once) ────────────────────────────────────
  useEffect(() => {
    const mount = mountRef.current;
    const container = containerRef.current;
    if (!mount || !container) return;

    const chart = echarts.init(mount);
    echartsRef.current = chart;

    const resizeObserver = new ResizeObserver(() => {
      // Observers always fire once right after observe(). Repushing on that
      // no-op fire would land one frame into the intro and stomp the line's
      // reveal clip — only react when the renderer size actually changed.
      if (mount.clientWidth === chart.getWidth() && mount.clientHeight === chart.getHeight()) {
        return;
      }
      chart.resize();
      live.repush();
    });
    resizeObserver.observe(mount);

    // Light/dark flips change no React state — re-resolve and push directly.
    const themeObserver = new MutationObserver(() => {
      live.repush();
    });
    themeObserver.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    chart.on("click", (params) => {
      const { clickableKeys: clickable } = live.handlers;
      const p = params as { seriesId?: string; seriesIndex?: number };
      // Symbol clicks carry seriesId; line clicks (triggerEvent) only carry
      // seriesIndex — recover the key from the last build's index map, which
      // accounts for the extra `__buffer-`/`__mini-` series interleaved between
      // the main ones (a raw seriesKeys lookup would land on the wrong key).
      const id =
        p.seriesId ??
        (typeof p.seriesIndex === "number" ? live.seriesKeyByIndex[p.seriesIndex] : undefined);
      if (typeof id === "string" && clickable.has(id)) toggleSelection(id);
    });

    // Hover-highlight bookkeeping — the canvas blur is ECharts-native
    // (emphasis.focus:"series" + blur), but the HTML legend and tooltip need to
    // know which series is hovered, and the silent glow/buffer overlays must be
    // focus-linked to their parent (they are separate series, so focus:"series"
    // would otherwise blur a line's own glow or forecast tail).
    chart.on("mouseover", (params) => {
      const { enableHoverHighlight: hoverOn, enableHoverReveal: revealOn } = live.handlers;
      // Reveal owns the hover visual, so native highlight stands down while it is on.
      if (!hoverOn || revealOn) return;
      // While a series is click-selected, hover highlighting is disabled — the
      // selection dim owns the canvas, so never arm hover emphasis/blur (or the
      // legend/tooltip hover dimming) until the selection clears.
      if (live.handlers.selectedDataKey !== null) return;
      const p = params as { seriesId?: string; seriesIndex?: number; componentType?: string };
      if (p.componentType !== "series") return;
      const id =
        p.seriesId ??
        (typeof p.seriesIndex === "number" ? live.seriesKeyByIndex[p.seriesIndex] : undefined);
      if (typeof id !== "string" || id.startsWith("__")) return;
      live.hoveredKey = id;
      setHoveredDataKey(id);
      const companions = live.companionIdsByKey.get(id);
      if (companions) {
        for (const seriesId of companions) chart.dispatchAction({ type: "highlight", seriesId });
      }
    });
    chart.on("mouseout", () => {
      const prev = live.hoveredKey;
      if (prev === null) return;
      live.hoveredKey = null;
      setHoveredDataKey(null);
      const companions = live.companionIdsByKey.get(prev);
      if (companions) {
        for (const seriesId of companions) chart.dispatchAction({ type: "downplay", seriesId });
      }
    });

    // Hover-reveal: color each line up to the pointer's x-index, mute the rest.
    // Purely TARGETED series updates (real series data + muted base opacity) — we
    // NEVER rebuild the whole option on mousemove, which would replay transitions
    // and fight the tooltip's axis pointer.
    const zrReveal = chart.getZr();
    const pushReveal = (idx: number | null) => {
      const keys = live.handlers.seriesKeys;
      const on = idx !== null;
      chart.setOption(
        {
          series: keys.flatMap((key) => [
            {
              id: key,
              data: on
                ? sliceToNull(live.revealValues[key] ?? [], idx)
                : (live.revealValues[key] ?? []),
            },
            {
              id: `${REVEAL_PREFIX}${key}`,
              // Gray tail keeps only the region from the cursor onward.
              data: on
                ? sliceFrom(live.revealValues[key] ?? [], idx)
                : (live.revealValues[key] ?? []),
              lineStyle: { opacity: on ? 0.3 : 0 },
            },
          ]),
        },
        // NOT lazy: the highlight dispatched just below re-draws the active dot
        // the setOption wipes, so the option must be committed first — a queued
        // (lazy) update would land after the dispatch and erase the dot again.
        { silent: true },
      );
      // The per-frame setOption above cancels the axis tooltip's transient hover
      // symbol, so the <ActiveDot> never lands at the cursor. Re-assert it:
      // highlighting a real series at the cursor index draws its emphasis symbol
      // (the active dot) even with showSymbol:false; downplay clears it on exit.
      for (const key of keys) {
        chart.dispatchAction(
          on
            ? { type: "highlight", seriesId: key, dataIndex: idx as number }
            : { type: "downplay", seriesId: key },
        );
      }
    };
    const clearReveal = () => {
      if (live.revealIndex === null) return;
      live.revealIndex = null;
      pushReveal(null);
    };
    const applyReveal = (event: { offsetX?: number; offsetY?: number }) => {
      const len = live.dataLength;
      if (len < 1) return;
      const x = event.offsetX ?? -1;
      const y = event.offsetY ?? -1;
      if (!chart.containPixel({ gridIndex: 0 }, [x, y])) {
        clearReveal();
        return;
      }
      const raw = chart.convertFromPixel({ gridIndex: 0 }, [x, y])[0];
      const idx = Math.max(0, Math.min(len - 1, Math.round(raw)));
      if (idx === live.revealIndex) return;
      live.revealIndex = idx;
      pushReveal(idx);
    };
    const onZrRevealMove = (event: { offsetX?: number; offsetY?: number }) => {
      if (!live.handlers.enableHoverReveal) return;
      applyReveal(event);
    };
    const onZrRevealOut = () => {
      if (live.handlers.enableHoverReveal) clearReveal();
    };
    zrReveal.on("mousemove", onZrRevealMove);
    zrReveal.on("globalout", onZrRevealOut);

    chart.on("datazoom", () => {
      const option = chart.getOption() as { dataZoom?: { start?: number; end?: number }[] };
      const zoom = option.dataZoom?.[0];
      if (!zoom) return;

      // Ride the selection — pure zrender updates, so the drag stays 1:1.
      live.brushRange = { start: zoom.start ?? 0, end: zoom.end ?? 100 };
      syncBrushOverlayNow();

      const { onBrushChange: onChange } = live.handlers;
      if (!onChange) return;
      const len = live.dataLength;
      const startIndex = Math.round(((zoom.start ?? 0) / 100) * (len - 1));
      const endIndex = Math.round(((zoom.end ?? 100) / 100) * (len - 1));
      onChange({ startIndex, endIndex });
    });

    // Hover tracking for the overlay: labels show while the pointer is over the
    // brush, and each pill brightens when the pointer is near its edge.
    const zr = chart.getZr();
    const applyHover = (next: { inside: boolean; left: boolean; right: boolean }) => {
      const prev = live.brushHover;
      if (prev.inside === next.inside && prev.left === next.left && prev.right === next.right) {
        return;
      }
      live.brushHover = next;
      syncBrushOverlayNow();
    };
    const onZrMove = (event: { offsetX?: number; offsetY?: number }) => {
      const geom = live.brushGeom;
      if (!geom) return;
      const x = event.offsetX ?? -1;
      const y = event.offsetY ?? -1;
      const top = chart.getHeight() - geom.bottom - geom.height;
      const inside = y >= top - 4 && y <= top + geom.height + 4;
      const trackLeft = 8;
      const trackWidth = Math.max(chart.getWidth() - 16, 1);
      const { start, end } = live.brushRange;
      const selectionLeft = trackLeft + (trackWidth * start) / 100;
      const selectionRight = trackLeft + (trackWidth * end) / 100;
      applyHover({
        inside,
        left: inside && Math.abs(x - selectionLeft) <= 8,
        right: inside && Math.abs(x - selectionRight) <= 8,
      });
    };
    const onZrOut = () => applyHover({ inside: false, left: false, right: false });
    zr.on("mousemove", onZrMove);
    zr.on("globalout", onZrOut);

    return () => {
      zrReveal.off("mousemove", onZrRevealMove);
      zrReveal.off("globalout", onZrRevealOut);
      zr.off("mousemove", onZrMove);
      zr.off("globalout", onZrOut);
      resizeObserver.disconnect();
      themeObserver.disconnect();
      chart.dispose();
      echartsRef.current = null;
      // The overlay elements died with the zrender instance.
      live.brushOverlay = null;
      // The reveal guard belongs to the chart instance it guarded. Without this
      // reset, StrictMode's dev-only mount→unmount→remount plays the entrance on
      // the throwaway instance and the surviving one renders without it.
      live.hasRevealed = false;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // ── Sync ECharts with props/theme/selection — resolve, build, push ────────────
  useEffect(() => {
    const chart = echartsRef.current;
    const container = containerRef.current;
    if (!chart || !container) return;

    // Colors come from the <style> committed just before this effect ran — read
    // them here, right before the push, rather than round-tripping through state.
    live.resolved = resolveColors(container, config, seriesKeys);

    const push = (withEntrance: boolean) => {
      const option = buildOption();
      const merged = chartOptions ? { ...option, ...chartOptions } : option;
      Object.assign(merged, {
        animation: withEntrance,
        animationDuration: REVEAL_DURATION,
        animationDurationUpdate: 0,
      });
      // chartOptions is an untyped escape hatch — the spread erases the option's
      // shape, so re-assert it. The only cast in the file.
      chart.setOption(merged as EChartsOption, { notMerge: true });
      // Overlays live outside the option — reposition them after every push.
      syncBrushOverlayNow();
    };

    // Intro reveal — ECharts' native progressive draw, enabled only for the first
    // real render: the line traces in, dots pop up as its front passes. Every
    // later push (selection, theme, zoom) applies instantly, since notMerge would
    // otherwise replay the entrance on each of them. A loading cycle re-arms it:
    // the Recharts twin unmounts its <Line>s while loading and replays the intro
    // on remount, so data → loading → data draws in again here too.
    if (isLoading) live.hasRevealed = false;
    const shouldReveal = !live.hasRevealed && !isLoading;
    if (shouldReveal) live.hasRevealed = true;
    const revealEnabled =
      animation && shouldReveal && effectiveAnimation !== "none" && !shouldReduceMotion;
    if (revealEnabled) live.revealEndsAt = performance.now() + REVEAL_DURATION;
    push(revealEnabled);

    // Theme flips and resizes re-enter here without touching React: re-read the
    // tokens (the .dark class changed, or the renderer resized) and push an
    // update-style option.
    live.repush = () => {
      live.resolved = resolveColors(container, config, seriesKeys);
      push(false);
    };
  }, [
    live,
    buildOption,
    chartOptions,
    isLoading,
    animation,
    effectiveAnimation,
    shouldReduceMotion,
    config,
    seriesKeys,
    syncBrushOverlayNow,
  ]);

  // ── Animated dashed stroke — rAF sweeps the dash offset while unselected ─────
  useEffect(() => {
    const chart = echartsRef.current;
    if (!chart || isLoading) return;
    const animatedKeys = lines
      // A buffer line's body is solid (only its tail dashes), so the sweep skips it.
      .filter((line) => line.strokeVariant === "animated-dashed" && !line.enableBufferLine)
      .map((line) => line.dataKey);
    if (animatedKeys.length === 0 || hasSelection) return;

    let raf = 0;
    let delayTimer: ReturnType<typeof setTimeout> | undefined;
    const begin = () => {
      const loopStart = performance.now();
      const tick = (now: number) => {
        const offset = -(((now - loopStart) / 1000) % 1) * 6; // 0 → -6 per second
        chart.setOption(
          { series: animatedKeys.map((id) => ({ id, lineStyle: { dashOffset: offset } })) },
          { silent: true, lazyUpdate: true },
        );
        raf = requestAnimationFrame(tick);
      };
      raf = requestAnimationFrame(tick);
    };

    // Per-frame setOption churn fights the intro draw-in (each update pass
    // recomputes the reveal clip, crawling it to a standstill) — hold the dash
    // sweep until the entrance has finished.
    const delay = Math.max(0, live.revealEndsAt - performance.now());
    if (delay > 0) delayTimer = setTimeout(begin, delay + 50);
    else begin();

    return () => {
      if (delayTimer !== undefined) clearTimeout(delayTimer);
      cancelAnimationFrame(raf);
    };
  }, [live, lines, hasSelection, isLoading]);

  // ── Loading shimmer — rAF sweeps a bright band, regenerating data off-screen ─
  useEffect(() => {
    const chart = echartsRef.current;
    if (!chart || !isLoading) return;

    let raf = 0;
    let lastPhase = 0;
    const start = performance.now();
    const tick = (now: number) => {
      const phase = ((((now - start) / LOADING_ANIMATION_DURATION) % 1) + 1) % 1;
      // Wrapped past 1 → the band is off-screen; swap in fresh random data.
      if (phase < lastPhase) live.loadingRows = getLoadingData(loadingPoints);
      lastPhase = phase;

      // Read tokens per frame, so a theme flip mid-loading retints the shimmer.
      const foreground = live.resolved?.tokens.foreground ?? "rgba(120, 120, 120, 1)";
      // Sweep the clip window from fully off-screen left to fully off-screen
      // right, leaned 45°. The gradient uses ABSOLUTE pixel coordinates so the
      // window lands at the same place along the stroke regardless of the wave's
      // bounding box.
      const w = chart.getWidth();
      const h = chart.getHeight();
      if (!w || !h) {
        raf = requestAnimationFrame(tick);
        return;
      }
      // Farthest plot corner projected onto the 45° axis — keeps the sweep
      // tight instead of dawdling off-plot at the end of each loop.
      const maxT = (w + h) / (2 * w);
      const center = phase * (maxT + 2 * LOADING_SHIMMER_BAND) - LOADING_SHIMMER_BAND;
      const clip = (peak: number) =>
        new echarts.graphic.LinearGradient(
          0,
          0,
          w,
          w,
          shimmerWindowStops(center, foreground, peak),
          true,
        );
      chart.setOption(
        {
          series: [
            {
              id: "__loading",
              data: loadingData(),
              lineStyle: { color: clip(LOADING_STROKE_OPACITY), width: 1 },
            },
          ],
        },
        { silent: true, lazyUpdate: true },
      );
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [live, isLoading, loadingPoints, loadingData]);

  // ── Legend overlay position ──────────────────────────────────────────────────
  // Insets match the Recharts legend's breathing room inside the plot frame.
  const legendStyle: CSSProperties = {
    position: "absolute",
    left: 16,
    right: 16,
    pointerEvents: "auto",
    ...(legendSlot.verticalAlign === "top"
      ? { top: 12 }
      : legendSlot.verticalAlign === "bottom"
        ? { bottom: showBrush ? brushHeight + 16 : 12 }
        : { top: "50%", transform: "translateY(-50%)" }),
  };

  return (
    <div
      ref={containerRef}
      data-chart={chartId}
      className={`relative flex flex-col text-xs ${className ?? ""}`}
    >
      <style dangerouslySetInnerHTML={{ __html: css }} />

      <div className="relative min-h-0 w-full flex-1">
        <div ref={mountRef} className="h-full min-h-0 w-full" />
      </div>

      {legendSlot.present && !isLoading && (
        <LegendOverlay
          seriesKeys={seriesKeys}
          config={config}
          variant={legendSlot.variant}
          align={legendSlot.align}
          verticalAlign={legendSlot.verticalAlign}
          selectedKey={selectedDataKey}
          hoveredKey={hoveredDataKey}
          isClickable={legendSlot.isClickable}
          onToggle={toggleSelection}
          style={legendStyle}
        />
      )}

      {isLoading && (
        <div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
          <motion.div
            initial={shouldReduceMotion ? false : { opacity: 0, scale: 0.92 }}
            animate={{ opacity: 1, scale: 1 }}
            transition={{ duration: 0.25, ease: "easeOut" }}
            className="text-primary bg-background flex items-center justify-center gap-2 rounded-md border px-2 py-0.5 text-sm"
          >
            <div className="border-border border-t-primary h-3 w-3 animate-spin rounded-full border" />
            <span>Loading</span>
          </motion.div>
        </div>
      )}
    </div>
  );
}

// Compound API: every part hangs off the root as a static member, so a consumer
// writes <EChartsLineChart.Line/>, <EChartsLineChart.Tooltip/>, … from a single
// import — no colliding named marker exports when several charts share one file.
EChartsLineChart.Line = Line;
EChartsLineChart.Dot = Dot;
EChartsLineChart.ActiveDot = ActiveDot;
EChartsLineChart.XAxis = XAxis;
EChartsLineChart.YAxis = YAxis;
EChartsLineChart.Grid = Grid;
EChartsLineChart.Tooltip = Tooltip;
EChartsLineChart.Legend = Legend;
EChartsLineChart.Brush = Brush;

```
        
      
      
        ### Add the shared chart module.
        
          Create a `ui` folder inside `evilcharts` and paste this one in first — it resolves your config's colors from the page's CSS variables, and every sub-component below imports from it.
        
        
          ### components/evilcharts/ui/echarts-chart.tsx

```tsx
import type { ComponentType, ReactNode } from "react";
import * as echarts from "echarts/core";

// ─────────────────────────────────────────────────────────────────────────────
// Theme keys + config — replicated from the repo's <ChartStyle> so the ECharts
// charts stay self-contained (no recharts ui imports). Shared by every ECharts
// chart and the tooltip/legend/brush ui modules.
// ─────────────────────────────────────────────────────────────────────────────

// Theme selectors mirror the repo's <ChartStyle>: light is the bare root, dark is `.dark`.
export const THEMES = { light: "", dark: ".dark" } as const;
export type ThemeKey = keyof typeof THEMES;
export const THEME_KEYS = Object.keys(THEMES) as ThemeKey[];

// Require at least one theme key — identical constraint to the repo's ChartConfig.
export type AtLeastOneThemeColor =
  | { light: string[]; dark?: string[] }
  | { light?: string[]; dark: string[] };

export type ChartConfig = Record<
  string,
  {
    label?: ReactNode;
    icon?: ComponentType;
    colors?: AtLeastOneThemeColor;
  }
>;

// ─────────────────────────────────────────────────────────────────────────────
// Color plumbing — replicated from the repo's <ChartStyle> so the charts stay
// self-contained (no @/components/evilcharts/ui/recharts imports).
// ─────────────────────────────────────────────────────────────────────────────

// Max slots a key needs = longest color array across themes (min 1). Both themes
// always emit the same number of `--color-{key}-{n}` vars.
export function getColorsCount(item: ChartConfig[string]): number {
  if (!item.colors) return 1;
  const counts = THEME_KEYS.map((theme) => item.colors?.[theme]?.length ?? 0);
  return Math.max(...counts, 1);
}

// Distribute colors evenly across slots; extra slots go to the LAST color(s).
// 2 colors / 4 slots → [c0, c0, c1, c1]; 3 colors / 4 slots → [c0, c1, c2, c2].
export function distributeColors(colors: string[], maxCount: number): string[] {
  const available = colors.length;
  if (available >= maxCount) return colors.slice(0, maxCount);

  const result: string[] = [];
  const baseSlots = Math.floor(maxCount / available);
  const extraSlots = maxCount % available;

  for (let i = 0; i < available; i++) {
    const isExtra = i >= available - extraSlots;
    const slots = baseSlots + (isExtra ? 1 : 0);
    for (let j = 0; j < slots; j++) result.push(colors[i]);
  }

  return result;
}

// Emits the same CSS <ChartStyle> would: `--color-{key}-{n}` scoped to
// `[data-chart={id}]` (light) and `.dark [data-chart={id}]` (dark).
export function buildChartCss(id: string, config: ChartConfig): string {
  const colorConfig = Object.entries(config).filter(([, item]) => item.colors);
  if (!colorConfig.length) return "";

  const varsFor = (theme: ThemeKey) =>
    colorConfig
      .flatMap(([key, item]) => {
        const authored = item.colors?.[theme];
        if (!authored || authored.length === 0) return [];
        return distributeColors(authored, getColorsCount(item)).map(
          (color, index) => `  --color-${key}-${index}: ${color};`,
        );
      })
      .join("\n");

  return Object.entries(THEMES)
    .map(([theme, prefix]) => `${prefix} [data-chart=${id}] {\n${varsFor(theme as ThemeKey)}\n}`)
    .join("\n");
}

// A single reusable 1×1 canvas normalizes ANY CSS color (hex, named, oklch, …)
// to a concrete rgba string by painting it and reading the pixel back.
let normalizerCtx: CanvasRenderingContext2D | null = null;
export function normalizeColor(value: string): string {
  const raw = value.trim();
  if (!raw || typeof document === "undefined") return raw;

  if (!normalizerCtx) {
    const canvas = document.createElement("canvas");
    canvas.width = 1;
    canvas.height = 1;
    normalizerCtx = canvas.getContext("2d", { willReadFrequently: true });
  }
  if (!normalizerCtx) return raw;

  normalizerCtx.clearRect(0, 0, 1, 1);
  normalizerCtx.fillStyle = "#000";
  normalizerCtx.fillStyle = raw; // invalid values leave the sentinel in place
  normalizerCtx.fillRect(0, 0, 1, 1);
  const [r, g, b, a] = normalizerCtx.getImageData(0, 0, 1, 1).data;
  return `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
}

// Scales the alpha of a normalized `rgba(r, g, b, a)` string. Multiplying (not
// replacing) keeps translucent theme tokens honest: a border that is 10%-white
// at `withAlpha(border, 0.5)` lands at 5%, matching Tailwind's `border/50`.
export function withAlpha(color: string, alpha: number): string {
  const match = color.match(/rgba?\(([^)]+)\)/);
  if (!match) return color;
  const [r, g, b, a] = match[1].split(",").map((p) => p.trim());
  const base = a === undefined ? 1 : Number.parseFloat(a) || 0;
  return `rgba(${r}, ${g}, ${b}, ${(base * alpha).toFixed(3)})`;
}

export type ResolvedColors = {
  series: Record<string, string[]>; // normalized `--color-{key}-{n}` slots per key
  tokens: {
    mutedForeground: string;
    border: string;
    foreground: string;
    background: string;
  };
};

// Reads the injected CSS vars + theme tokens from the live DOM. Series slots come
// from `getComputedStyle` on the container; tokens are read off a throwaway probe
// carrying the matching Tailwind class (robust to the var naming a theme uses).
export function resolveColors(
  container: HTMLElement,
  config: ChartConfig,
  seriesKeys: string[],
): ResolvedColors {
  const computed = getComputedStyle(container);
  const series: Record<string, string[]> = {};

  for (const key of seriesKeys) {
    const count = getColorsCount(config[key] ?? {});
    const slots: string[] = [];
    for (let n = 0; n < count; n++) {
      const raw = computed.getPropertyValue(`--color-${key}-${n}`).trim();
      slots.push(raw ? normalizeColor(raw) : "rgba(120, 120, 120, 1)");
    }
    series[key] = slots;
  }

  const probe = document.createElement("span");
  probe.style.cssText = "position:absolute;width:0;height:0;visibility:hidden;pointer-events:none;";
  container.appendChild(probe);
  const readToken = (className: string) => {
    probe.className = className;
    return normalizeColor(getComputedStyle(probe).color);
  };
  const tokens = {
    mutedForeground: readToken("text-muted-foreground"),
    border: readToken("text-border"),
    foreground: readToken("text-foreground"),
    background: readToken("text-background"),
  };
  container.removeChild(probe);

  return { series, tokens };
}

// Horizontal multi-stop color for a series — a solid string when there is only
// one color, else an evenly-distributed left→right LinearGradient. Reused for the
// stroke, symbol fills, and as the base tint for the area fill.
export function seriesPaint(slots: string[]): string | echarts.graphic.LinearGradient {
  if (slots.length <= 1) return slots[0] ?? "rgba(120, 120, 120, 1)";
  const stops = slots.map((color, i) => ({ offset: i / (slots.length - 1), color }));
  return new echarts.graphic.LinearGradient(0, 0, 1, 0, stops);
}

// Solid var / gradient of vars for a series indicator — mirrors getIndicatorColorStyle.
// Used by BOTH the tooltip rows and the legend indicators.
export function indicatorBackground(key: string, colorsCount: number): string {
  if (colorsCount <= 1) return `var(--color-${key}-0)`;
  const stops = Array.from({ length: colorsCount }, (_, i) => {
    const offset = (i / (colorsCount - 1)) * 100;
    return `var(--color-${key}-${i}) ${offset}%`;
  }).join(", ");
  return `linear-gradient(to right, ${stops})`;
}

// Composites a translucent color over an opaque base into a FLAT color. The
// tick dots need this: a translucent stroke double-paints where its round caps
// overlap the line body, which reads as two stacked colors.
export function flattenColor(color: string, base: string): string {
  const parse = (value: string) =>
    value
      .match(/rgba?\(([^)]+)\)/)?.[1]
      .split(",")
      .map((part) => Number.parseFloat(part)) ?? [0, 0, 0, 1];
  const [r, g, b, a = 1] = parse(color);
  const [baseR, baseG, baseB] = parse(base);
  const mix = (channel: number, baseChannel: number) =>
    Math.round(channel * a + baseChannel * (1 - a));
  return `rgb(${mix(r, baseR)}, ${mix(g, baseG)}, ${mix(b, baseB)})`;
}

```
        
      
      
        ### Add the sub-components.
        
          Create `echarts-tooltip.tsx` in the same `ui` folder and paste the tooltip surface and its variants there.
        
        
          ### components/evilcharts/ui/echarts-tooltip.tsx

```tsx
import { indicatorBackground, type ResolvedColors } from "@/components/evilcharts/ui/echarts-chart";
import type { TooltipComponentOption } from "echarts/components";

// ─────────────────────────────────────────────────────────────────────────────
// Tooltip — the shared HTML shell/row primitives and the chart-agnostic option
// fields. The tooltip DOM lives inside `[data-chart={id}]`, so the injected
// `--color-*` vars and Tailwind classes resolve directly (no color read). Each
// chart composes its own rows but shares the shell/styling and base option.
// ─────────────────────────────────────────────────────────────────────────────

export type TooltipVariant = "default" | "frosted-glass";
export type TooltipRoundness = "sm" | "md" | "lg" | "xl";
// Tooltip anchoring: "variable" follows both axes (ECharts default, current
// behavior); "fixed" tracks the pointer's X (centered) but stays pinned near
// the top (fixed Y).
export type TooltipPosition = "fixed" | "variable";

export const roundnessClass: Record<TooltipRoundness, string> = {
  sm: "rounded-sm",
  md: "rounded-md",
  lg: "rounded-lg",
  xl: "rounded-xl",
};

export const tooltipVariantClass: Record<TooltipVariant, string> = {
  default: "bg-background",
  "frosted-glass": "bg-background/50 backdrop-blur-md",
};

// The standard series indicator swatch — a rounded square filled with the
// series' solid var or multi-stop gradient (indicatorBackground). A chart drops
// this into a tooltipRow's `indicatorHtml`.
export function tooltipIndicatorHtml(key: string, colorsCount: number): string {
  return `<div class="h-2.5 w-2.5 shrink-0 rounded-[2px]" style="background:${indicatorBackground(key, colorsCount)}"></div>`;
}

// One tooltip row: indicator swatch + label/value pair. `dimmed` is a class
// fragment (e.g. " opacity-30") appended to the row so the selection/hover dim
// stays byte-identical to the inlined markup.
export function tooltipRow({
  indicatorHtml,
  labelText,
  valueText,
  dimmed,
}: {
  indicatorHtml: string;
  labelText: string;
  valueText: string;
  dimmed: string;
}): string {
  return `<div class="flex w-full flex-wrap items-center gap-2${dimmed}">
          ${indicatorHtml}
          <div class="flex flex-1 items-center justify-between gap-4 leading-none">
            <span class="text-muted-foreground">${labelText}</span>
            <span class="text-foreground font-mono font-medium tabular-nums">${valueText}</span>
          </div>
        </div>`;
}

// The outer tooltip surface — border, padding, shadow, roundness + variant
// classes — wrapping the axis label and the composed rows.
export function tooltipShell({
  label,
  body,
  roundness,
  variant,
}: {
  label: string;
  body: string;
  roundness: TooltipRoundness;
  variant: TooltipVariant;
}): string {
  return `<div class="grid min-w-32 items-start gap-1.5 border border-border/50 px-2.5 py-1.5 text-xs shadow-xl ${roundnessClass[roundness]} ${tooltipVariantClass[variant]}">
      <div class="font-medium text-primary">${label}</div>
      <div class="grid gap-1.5">${body}</div>
    </div>`;
}

// Maps the TooltipPosition prop onto the ECharts tooltip `position` field.
// "variable" → undefined (default follow-both-axes, current behavior); "fixed" →
// a callback that centers the tooltip on the pointer's X but pins it near the
// top (fixed Y).
export function resolveTooltipPosition(
  position: TooltipPosition,
): TooltipComponentOption["position"] {
  if (position === "variable") return undefined;
  return (point, _params, _dom, _rect, size) => [point[0] - size.contentSize[0] / 2, 8];
}

// The chart-agnostic tooltip option fields (show, trigger, confine,
// background/border/padding/extraCssText, axisPointer, position). The chart
// supplies only `formatter` and spreads this in. `axisPointerColor` is the
// pre-resolved cursor-line color, so this helper needs no live token read.
export function tooltipBaseOption(params: {
  present: boolean;
  cursor: boolean;
  tokens: ResolvedColors["tokens"];
  position: TooltipPosition;
  axisPointerColor: string;
  strokeWidth: number;
}): TooltipComponentOption {
  const { present, cursor, position, axisPointerColor, strokeWidth } = params;

  return {
    show: present,
    trigger: "axis",
    confine: true,
    backgroundColor: "transparent",
    borderWidth: 0,
    padding: 0,
    extraCssText: "box-shadow:none;",
    axisPointer: cursor
      ? {
          type: "line",
          lineStyle: {
            color: axisPointerColor,
            width: strokeWidth,
            type: [3, 3] as [number, number],
          },
        }
      : { type: "none" },
    position: resolveTooltipPosition(position),
  };
}

```
        
        
          Next, create `echarts-legend.tsx` in the same `ui` folder and paste the legend overlay there.
        
        
          ### components/evilcharts/ui/echarts-legend.tsx

```tsx
"use client";

import { getColorsCount, indicatorBackground, type ChartConfig } from "@/components/evilcharts/ui/echarts-chart";
import type { CSSProperties } from "react";

// ─────────────────────────────────────────────────────────────────────────────
// Legend overlay (React) — replicates ChartLegendContent + its 7 indicators.
// The legend is HTML inside `[data-chart={id}]`, so it uses the injected
// `--color-*` vars directly. Shared by every ECharts chart.
// ─────────────────────────────────────────────────────────────────────────────

export type LegendVariant =
  | "square"
  | "circle"
  | "circle-outline"
  | "rounded-square"
  | "rounded-square-outline"
  | "vertical-bar"
  | "horizontal-bar";

export function legendFillStyle(key: string, colorsCount: number): CSSProperties {
  if (colorsCount <= 1) return { backgroundColor: `var(--color-${key}-0)` };
  return { background: indicatorBackground(key, colorsCount) };
}

// Punches out the centre with a mask-composite so only the "border" shows —
// works with gradients and border-radius, unlike plain border-color.
export function legendOutlineStyle(key: string, colorsCount: number): CSSProperties {
  const mask: CSSProperties = {
    WebkitMask: "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
    WebkitMaskComposite: "xor",
    mask: "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
    maskComposite: "exclude",
  };
  return { ...legendFillStyle(key, colorsCount), ...mask };
}

export function LegendIndicator({
  variant,
  dataKey,
  colorsCount,
}: {
  variant: LegendVariant;
  dataKey: string;
  colorsCount: number;
}) {
  const fill = legendFillStyle(dataKey, colorsCount);
  const outline = legendOutlineStyle(dataKey, colorsCount);

  switch (variant) {
    case "square":
      return <div className="h-2 w-2 shrink-0" style={fill} />;
    case "circle":
      return <div className="h-2 w-2 shrink-0 rounded-full" style={fill} />;
    case "circle-outline":
      return <div className="h-2.5 w-2.5 shrink-0 rounded-full p-[1.5px]" style={outline} />;
    case "vertical-bar":
      return <div className="h-3 w-1 shrink-0 rounded-[2px]" style={fill} />;
    case "horizontal-bar":
      return <div className="h-1 w-3 shrink-0 rounded-[2px]" style={fill} />;
    case "rounded-square-outline":
      return <div className="h-2.5 w-2.5 shrink-0 rounded-[3px] p-[1.5px]" style={outline} />;
    case "rounded-square":
    default:
      return <div className="h-2 w-2 shrink-0 rounded-[2px]" style={fill} />;
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// LegendOverlay — the positioned HTML legend row. The chart computes the
// absolute-positioned `style` (it owns the brush/verticalAlign layout math) and
// passes it in; this renders the entries, their indicators, and the
// selection/hover dim. `verticalAlign` is carried on the props for parity with
// the chart's LegendSlot even though positioning arrives fully via `style`.
// ─────────────────────────────────────────────────────────────────────────────

type LegendOverlayProps = {
  seriesKeys: string[];
  config: ChartConfig;
  variant: LegendVariant;
  align: "left" | "center" | "right";
  verticalAlign: "top" | "middle" | "bottom";
  selectedKey: string | null;
  hoveredKey: string | null;
  isClickable: boolean;
  onToggle: (key: string) => void;
  style: CSSProperties;
};

export function LegendOverlay({
  seriesKeys,
  config,
  variant,
  align,
  selectedKey,
  hoveredKey,
  isClickable,
  onToggle,
  style,
}: LegendOverlayProps) {
  const legendJustify =
    align === "left" ? "justify-start" : align === "center" ? "justify-center" : "justify-end";

  return (
    <div style={style} className={`flex items-center gap-4 select-none ${legendJustify}`}>
      {seriesKeys.map((key) => {
        const item = config[key];
        const colorsCount = item ? getColorsCount(item) : 1;
        const isSelected =
          (selectedKey === null || selectedKey === key) &&
          (hoveredKey === null || hoveredKey === key);
        return (
          // No entrance here — the Recharts legend appears instantly, and a
          // fade-in reads as disconnected from the canvas draw-in.
          <div
            key={key}
            className={`flex items-center gap-1.5 transition-opacity ${
              !isSelected ? "opacity-30" : ""
            } ${isClickable ? "cursor-pointer" : ""}`}
            onClick={() => {
              if (isClickable) onToggle(key);
            }}
          >
            <LegendIndicator variant={variant} dataKey={key} colorsCount={colorsCount} />
            {item?.label}
          </div>
        );
      })}
    </div>
  );
}

```
        
        
          Next, create `echarts-dot.tsx` in the same `ui` folder and paste the dot styles the series markers draw with there.
        
        
          ### components/evilcharts/ui/echarts-dot.tsx

```tsx
import type * as echarts from "echarts/core";

// ─────────────────────────────────────────────────────────────────────────────
// Dots — map the resting/active variants onto ECharts symbols. Shared by every
// ECharts chart that draws point markers.
// ─────────────────────────────────────────────────────────────────────────────

export type DotVariant = "none" | "default" | "border" | "colored-border" | "ping";

// Dot marker paint — structurally assignable to BOTH the series-level and the
// per-datum itemStyle (the per-datum variant forbids callback color paints, so
// the broader LineSeriesOption["itemStyle"] cannot be reused for it).
export type DotItemStyleOption = {
  color?: string | echarts.graphic.LinearGradient;
  borderColor?: string | echarts.graphic.LinearGradient;
  borderWidth?: number;
  opacity?: number;
};

export type DotStyle = { size: number; itemStyle: DotItemStyleOption };

// Re-color a solid paint at a given alpha (hex #rgb/#rrggbb or rgb/rgba in, rgba
// out; named colors pass through). Used for the translucent "ping" halo ring.
function withAlpha(color: string, alpha: number): string {
  if (color.startsWith("#")) {
    let hex = color.slice(1);
    if (hex.length === 3)
      hex = hex
        .split("")
        .map((c) => c + c)
        .join("");
    const n = parseInt(hex, 16);
    return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
  }
  const m = color.match(/rgba?\(([^)]+)\)/);
  if (m) {
    const [r, g, b] = m[1].split(",").map((s) => parseFloat(s));
    return `rgba(${r}, ${g}, ${b}, ${alpha})`;
  }
  return color;
}

export function dotItemStyle(
  variant: DotVariant,
  paint: string | echarts.graphic.LinearGradient,
  background: string,
): DotItemStyleOption {
  switch (variant) {
    case "border":
      // Series-colored core with a thick background halo (Recharts r6 / sw5).
      return { color: paint, borderColor: background, borderWidth: 2 };
    case "colored-border":
      // Background-filled core with a thin colored ring (Recharts r3 / sw1).
      return { color: background, borderColor: paint, borderWidth: 1 };
    case "ping": {
      // Solid core wrapped in a wide, translucent same-color ring — a static
      // "ping". The ring is the symbol's BORDER (a stroke ~1.25× the core
      // radius), which fills out to a soft halo disc; a genuinely large symbol
      // would silently fail to render on a category-axis line, so we stroke a
      // small one instead.
      const halo = typeof paint === "string" ? withAlpha(paint, 0.28) : paint;
      return { color: paint, borderColor: halo, borderWidth: 10 };
    }
    case "default":
      return { color: paint, borderWidth: 0 };
    default:
      return {};
  }
}

// Sizes mirror the Recharts markers: default r3, border r6 (mostly halo), and
// colored-border r3+ring. Flattening these to one size makes the hover ring read
// LARGER than a haloed resting dot — the opposite of the Recharts twin.
export const DOT_SIZES: Record<DotVariant, number> = {
  none: 0,
  default: 6,
  border: 8,
  "colored-border": 6,
  ping: 8,
};

export function dotStyle(
  variant: DotVariant,
  paint: string | echarts.graphic.LinearGradient,
  background: string,
): DotStyle {
  return { size: DOT_SIZES[variant], itemStyle: dotItemStyle(variant, paint, background) };
}

// The color the horizontal series gradient shows at position t ∈ [0, 1]. ECharts
// paints a gradient itemStyle relative to each symbol's own bounding box — a full
// rainbow inside every dot — while the Recharts dots clip a chart-wide gradient,
// so each takes the gradient's color at its x-position. Sampling reproduces that.
export function sampleGradient(slots: string[], t: number): string {
  if (slots.length <= 1) return slots[0] ?? "rgba(120, 120, 120, 1)";

  const parse = (color: string) =>
    color
      .match(/rgba?\(([^)]+)\)/)?.[1]
      .split(",")
      .map(Number) ?? [120, 120, 120, 1];

  const position = t * (slots.length - 1);
  const index = Math.min(Math.floor(position), slots.length - 2);
  const fraction = position - index;
  const [r1, g1, b1, a1 = 1] = parse(slots[index]);
  const [r2, g2, b2, a2 = 1] = parse(slots[index + 1]);
  const lerp = (from: number, to: number) => from + (to - from) * fraction;

  return `rgba(${Math.round(lerp(r1, r2))}, ${Math.round(lerp(g1, g2))}, ${Math.round(lerp(b1, b2))}, ${lerp(a1, a2).toFixed(3)})`;
}

```
        
        
          Finally, create `echarts-brush.tsx` in the same `ui` folder and paste the zoom brush there.
        
        
          ### components/evilcharts/ui/echarts-brush.tsx

```tsx
import { withAlpha, type ResolvedColors } from "@/components/evilcharts/ui/echarts-chart";
import type { DataZoomComponentOption } from "echarts/components";
import type { FC } from "react";
import * as echarts from "echarts/core";

type EChartsInstance = ReturnType<typeof echarts.init>;

const BRUSH_BORDER_OPACITY = 1; // brush frame, × border alpha (evil-brush uses the full token)

export { BRUSH_BORDER_OPACITY };

// ─────────────────────────────────────────────────────────────────────────────
// Brush marker — the declarative `<Chart.Brush/>` child. Rendering nothing, its
// PRESENCE turns the brush on (replacing the old showBrush prop) and its props
// carry the brush's height, handle-label formatter, and range callback. Shared
// so every cartesian chart attaches the SAME component to its root.
// ─────────────────────────────────────────────────────────────────────────────

export interface BrushProps {
  height?: number; // brush preview strip height in px (default 56)
  formatLabel?: (value: string, index: number) => string; // formats the range-handle labels
  onChange?: (range: { startIndex: number; endIndex: number }) => void; // fires as the range moves
}

/** Declares the zoom brush below the chart. Presence renders it; renders nothing itself. */
export const Brush: FC<BrushProps> = () => null;

// ─────────────────────────────────────────────────────────────────────────────
// Brush overlays — the evil-brush look: a rounded border around the SELECTED
// range, dimmed unselected sides, centered grip-dot handle pills, and range
// label pills below the frame. None of that is a dataZoom capability. They are
// raw zrender elements updated imperatively — routing them through setOption
// re-renders the dataZoom component mid-drag, resetting its drag anchor (the
// handle progressively lags the pointer).
// ─────────────────────────────────────────────────────────────────────────────

export type BrushRange = { start: number; end: number };
export type BrushGeometry = { bottom: number; height: number };

export type BrushOverlayParams = {
  range: BrushRange;
  geom: BrushGeometry;
  size: { width: number; height: number };
  tokens: ResolvedColors["tokens"];
  labels: { start: string; end: string } | null;
  showLabels: boolean;
  hover: { left: boolean; right: boolean };
};

type ZrRect = InstanceType<typeof echarts.graphic.Rect>;
type ZrCircle = InstanceType<typeof echarts.graphic.Circle>;
type ZrText = InstanceType<typeof echarts.graphic.Text>;

export type BrushOverlayElements = {
  dimLeft: ZrRect;
  dimRight: ZrRect;
  frame: ZrRect;
  pillLeft: ZrRect;
  pillRight: ZrRect;
  grips: ZrCircle[]; // 3 left + 3 right
  labelStart: ZrText;
  labelEnd: ZrText;
};

export function syncBrushOverlay(
  chart: EChartsInstance,
  store: { brushOverlay: BrushOverlayElements | null },
  params: BrushOverlayParams | null,
) {
  const zr = chart.getZr();
  if (!zr) return;

  if (!params) {
    if (store.brushOverlay) {
      const { grips, ...rest } = store.brushOverlay;
      [...Object.values(rest), ...grips].forEach((el) => zr.remove(el));
      store.brushOverlay = null;
    }
    return;
  }

  if (!store.brushOverlay) {
    const rect = (z: number) => new echarts.graphic.Rect({ silent: true, z, shape: {} });
    const els: BrushOverlayElements = {
      dimLeft: rect(100),
      dimRight: rect(100),
      frame: rect(101),
      pillLeft: rect(102),
      pillRight: rect(102),
      grips: Array.from(
        { length: 6 },
        () => new echarts.graphic.Circle({ silent: true, z: 103, shape: {} }),
      ),
      labelStart: new echarts.graphic.Text({ silent: true, z: 104 }),
      labelEnd: new echarts.graphic.Text({ silent: true, z: 104 }),
    };
    const { grips, ...rest } = els;
    [...Object.values(rest), ...grips].forEach((el) => zr.add(el));
    store.brushOverlay = els;
  }

  const els = store.brushOverlay;
  const { range, geom, size, tokens, labels, showLabels, hover } = params;

  const trackLeft = 8;
  const trackRight = Math.max(size.width - 8, trackLeft);
  const trackWidth = trackRight - trackLeft;
  const top = size.height - geom.bottom - geom.height;
  const centerY = top + geom.height / 2;
  const selectionLeft = trackLeft + (trackWidth * range.start) / 100;
  const selectionRight = trackLeft + (trackWidth * range.end) / 100;

  const dimFill = withAlpha(tokens.background, 0.7);
  els.dimLeft.setShape({
    x: trackLeft,
    y: top,
    width: Math.max(selectionLeft - trackLeft, 0),
    height: geom.height,
  });
  els.dimLeft.setStyle({ fill: dimFill });
  els.dimRight.setShape({
    x: selectionRight,
    y: top,
    width: Math.max(trackRight - selectionRight, 0),
    height: geom.height,
  });
  els.dimRight.setStyle({ fill: dimFill });

  els.frame.setShape({
    x: selectionLeft,
    y: top,
    width: Math.max(selectionRight - selectionLeft, 0),
    height: geom.height,
    r: 6,
  });
  els.frame.setStyle({
    fill: "none",
    stroke: withAlpha(tokens.border, BRUSH_BORDER_OPACITY),
    lineWidth: 1,
  });

  // Handle pills: evil-brush's 6×16 grip pill, centered on the selection edge,
  // brightening to foreground on hover/drag.
  const pill = (el: ZrRect, x: number, hovered: boolean) => {
    el.setShape({ x: x - 3, y: centerY - 8, width: 6, height: 16, r: 3 });
    el.setStyle({ fill: hovered ? tokens.foreground : tokens.mutedForeground });
  };
  pill(els.pillLeft, selectionLeft, hover.left);
  pill(els.pillRight, selectionRight, hover.right);

  const gripFill = withAlpha(tokens.background, 0.7);
  [-4, 0, 4].forEach((offset, i) => {
    els.grips[i].setShape({ cx: selectionLeft, cy: centerY + offset, r: 1 });
    els.grips[i].setStyle({ fill: gripFill });
    els.grips[i + 3].setShape({ cx: selectionRight, cy: centerY + offset, r: 1 });
    els.grips[i + 3].setStyle({ fill: gripFill });
  });

  // Range label pills straddle the frame's bottom line — an overlay, so they
  // occupy no layout space; half the pill sits above the line, half below. Each
  // pill grows INWARD from its handle with a small inset, like the Recharts
  // labels, instead of hanging past the frame edge.
  const label = (el: ZrText, text: string, x: number, align: "left" | "right") => {
    el.setStyle({
      text,
      x: align === "left" ? Math.max(x + 6, trackLeft + 2) : Math.min(x - 6, trackRight - 2),
      y: top + geom.height,
      align,
      verticalAlign: "middle",
      fill: tokens.background,
      backgroundColor: tokens.foreground,
      padding: [2, 5],
      borderRadius: 4,
      font: "500 9px system-ui, sans-serif",
    });
    el.attr("invisible", !showLabels || !text);
  };
  label(els.labelStart, labels?.start ?? "", selectionLeft, "left");
  label(els.labelEnd, labels?.end ?? "", selectionRight, "right");
}

// ─────────────────────────────────────────────────────────────────────────────
// dataZoom slider — the transparent drag layer laid over the mini chart. Fully
// chart-agnostic: the visible frame/handles/labels are the graphic overlays
// above; this provides interaction only. Both zoom entries target only the MAIN
// x-axis (index 0), so the mini chart never filters itself. The per-chart
// mini-series (which differ per chart type) are built by the chart, not here.
// ─────────────────────────────────────────────────────────────────────────────

export function buildBrushDataZoom(params: {
  brushBottom: number;
  brushHeight: number;
  brushRange: BrushRange;
  fillerColor: string;
}): DataZoomComponentOption[] {
  const { brushBottom, brushHeight, brushRange, fillerColor } = params;

  return [
    {
      type: "slider",
      show: true,
      xAxisIndex: [0],
      left: 8,
      right: 8,
      bottom: brushBottom,
      height: brushHeight,
      // Carry the live range through every rebuild — a notMerge push
      // without start/end would reset the zoom to the full extent.
      start: brushRange.start,
      end: brushRange.end,
      brushSelect: false,
      // Range labels are overlay pills below the frame (see
      // syncBrushOverlay) — the native detail text renders INSIDE the
      // track, which is not the evil-brush look.
      showDetail: false,
      backgroundColor: "transparent",
      // The visible frame is the graphic overlay riding the selection —
      // the component's own static border stays hidden.
      borderColor: "transparent",
      fillerColor,
      dataBackground: { lineStyle: { opacity: 0 }, areaStyle: { opacity: 0 } },
      selectedDataBackground: { lineStyle: { opacity: 0 }, areaStyle: { opacity: 0 } },
      // Interaction only — the visible pills are graphic overlays (see
      // syncBrushOverlay). Kept generous for an easy grab target.
      handleIcon: "path://M -3 -5 L -3 5 A 3 3 0 0 0 3 5 L 3 -5 A 3 3 0 0 0 -3 -5 Z",
      handleSize: "35%",
      handleStyle: { opacity: 0 },
      moveHandleSize: 0,
      emphasis: { handleStyle: { opacity: 0 } },
    },
    { type: "inside", xAxisIndex: [0] },
  ];
}

```
        
      
    
  


## Usage

The ECharts line chart is composable, sharing the Recharts twin's API shape. `<EChartsLineChart>` is the container, and every part hangs off it as a compound member — `<EChartsLineChart.Grid>`, `<EChartsLineChart.XAxis>`, `<EChartsLineChart.YAxis>`, `<EChartsLineChart.Legend>`, `<EChartsLineChart.Tooltip>`, and one or more `<EChartsLineChart.Line>` — so a single import gives you the whole chart. Each `<Line>` carries its own `strokeVariant`, `curveType`, `glowing`, `enableBufferLine`, and `isClickable`, so one chart can mix stroke styles and make only some series interactive.

```tsx
import { EChartsLineChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-line-chart";
```

```tsx
<EChartsLineChart data={data} config={chartConfig} curveType="monotone">
  <EChartsLineChart.Grid />
  <EChartsLineChart.XAxis dataKey="month" />
  <EChartsLineChart.YAxis />
  <EChartsLineChart.Legend isClickable />
  <EChartsLineChart.Tooltip />
  <EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable>
    <EChartsLineChart.Dot variant="border" />
    <EChartsLineChart.ActiveDot variant="colored-border" />
  </EChartsLineChart.Line>
  <EChartsLineChart.Line dataKey="mobile" strokeVariant="dashed" glowing>
    <EChartsLineChart.ActiveDot variant="default" />
  </EChartsLineChart.Line>
</EChartsLineChart>
```

The one real difference from the Recharts twin is under the hood: ECharts renders to a `<canvas>`, so these children never mount as DOM. The root reads their props and compiles them into the ECharts option object. Same JSX, same presence semantics (omit a part and it doesn't render), same behavior — the children are declarative config, not live DOM nodes.

The `config` is the same contract as every EvilCharts chart — each key maps a data key to a `label` and a per-theme `colors` array. See [Chart Config](/docs/chart-config) for the full shape. Colors resolve from your CSS variables at runtime, so dark mode just works.

> 
  
    Canvas rendering brings a few small departures from the Recharts twin: multi-color gradients tint each dot with the color at its x-position; the glow is layered gradient strokes stacked under the line, following the series' color in place of an SVG blur filter; and the zoom brush is a themed mini chart driven by ECharts' native `dataZoom` rather than the custom `EvilBrush`.
  


### Interactive Selection

Add `isClickable` to any `<Line>` (and to `<Legend>`) to make those series selectable, then handle events via the `onSelectionChange` callback on `<EChartsLineChart>`:

```tsx
<EChartsLineChart
  data={data}
  config={chartConfig}
  onSelectionChange={(selectedDataKey) => {
    if (selectedDataKey) {
      console.log("Selected:", selectedDataKey);
    } else {
      console.log("Deselected");
    }
  }}
>
  <EChartsLineChart.XAxis dataKey="month" />
  <EChartsLineChart.Legend isClickable />
  <EChartsLineChart.Tooltip />
  <EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable />
  <EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" isClickable />
</EChartsLineChart>
```

### Loading State

### isLoading='true'

```tsx
"use client";

import { EChartsLineChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-line-chart";

const data: { month: string; desktop: number; mobile: number }[] = [];

const chartConfig = {
  desktop: {
    label: "Desktop",
    colors: {
      light: ["#047857"],
      dark: ["#10b981"],
    },
  },
  mobile: {
    label: "Mobile",
    colors: {
      light: ["#be123c"],
      dark: ["#f43f5e"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleLineChart() {
  return (
    <EChartsLineChart
      data={data} // if isLoading is true, pass empty array → i.e isLoading ? [] : data
      config={chartConfig}
      className="h-full w-full p-4"
      isLoading={true} // [!code highlight]
      curveType="bump"
    >
      <EChartsLineChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsLineChart.YAxis dataKey="desktop" />
      <EChartsLineChart.Legend isClickable />
      <EChartsLineChart.Tooltip />
      <EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable>
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
      <EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" isClickable>
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
    </EChartsLineChart>
  );
}

```
> 
  
    Pass `isLoading` to show an animated skeleton, `loadingPoints` to set how many points it draws, and `curveType` to match the real chart's curve.
  


### Buffer Line

### enableBufferLine='true'

```tsx
"use client";

import { EChartsLineChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-line-chart";

const data = [
  { month: "January", desktop: 342, mobile: 184 },
  { month: "February", desktop: 876, mobile: 491 },
  { month: "March", desktop: 512, mobile: 290 },
  { month: "April", desktop: 629, mobile: 391 },
  { month: "May", desktop: 458, mobile: 309 },
  { month: "June", desktop: 781, mobile: 449 },
  { month: "July", desktop: 394, mobile: 234 },
  { month: "August", desktop: 925, mobile: 557 },
  { month: "September", desktop: 647, mobile: 367 },
  { month: "October", desktop: 532, mobile: 357 },
  { month: "November", desktop: 803, mobile: 515 },
  { month: "December", desktop: 271, mobile: 149 },
];

const chartConfig = {
  desktop: {
    label: "Desktop",
    colors: {
      light: ["#047857"],
      dark: ["#10b981"],
    },
  },
  mobile: {
    label: "Mobile",
    colors: {
      light: ["#be123c"],
      dark: ["#f43f5e"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleLineChart() {
  return (
    <EChartsLineChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      xDataKey="month"
    >
      <EChartsLineChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsLineChart.Brush />
      <EChartsLineChart.Legend isClickable />
      <EChartsLineChart.Tooltip />
      <EChartsLineChart.Line
        dataKey="desktop"
        strokeVariant="solid"
        enableBufferLine // [!code highlight]
        isClickable
      >
        <EChartsLineChart.Dot variant="border" />
        <EChartsLineChart.ActiveDot variant="colored-border" />
      </EChartsLineChart.Line>
      <EChartsLineChart.Line
        dataKey="mobile"
        strokeVariant="solid"
        enableBufferLine // [!code highlight]
        isClickable
      >
        <EChartsLineChart.Dot variant="border" />
        <EChartsLineChart.ActiveDot variant="colored-border" />
      </EChartsLineChart.Line>
    </EChartsLineChart>
  );
}

```
> 
  
    With `enableBufferLine`, each line's last segment renders dashed while the rest stays solid — useful for marking projected, estimated, or incomplete data at the end of a series, as in financial charts and forecasting dashboards.
  


### Hover Reveal

### enableHoverReveal='true'

```tsx
"use client";

import { EChartsLineChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-line-chart";

const data = [
  { month: "January", desktop: 342, mobile: 245 },
  { month: "February", desktop: 876, mobile: 654 },
  { month: "March", desktop: 512, mobile: 387 },
  { month: "April", desktop: 629, mobile: 521 },
  { month: "May", desktop: 458, mobile: 412 },
  { month: "June", desktop: 781, mobile: 598 },
  { month: "July", desktop: 394, mobile: 312 },
  { month: "August", desktop: 925, mobile: 743 },
  { month: "September", desktop: 647, mobile: 489 },
  { month: "October", desktop: 532, mobile: 476 },
  { month: "November", desktop: 803, mobile: 687 },
  { month: "December", desktop: 271, mobile: 198 },
];

const chartConfig = {
  desktop: {
    label: "Desktop",
    colors: {
      light: ["#047857"],
      dark: ["#10b981"],
    },
  },
  mobile: {
    label: "Mobile",
    colors: {
      light: ["#be123c"],
      dark: ["#f43f5e"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleLineChart() {
  return (
    <EChartsLineChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      enableHoverReveal // [!code highlight]
    >
      <EChartsLineChart.Grid />
      <EChartsLineChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsLineChart.Legend />
      <EChartsLineChart.Tooltip />
      <EChartsLineChart.Line dataKey="desktop" strokeVariant="solid">
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
      <EChartsLineChart.Line dataKey="mobile" strokeVariant="solid">
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
    </EChartsLineChart>
  );
}

```
> 
  
    With `enableHoverReveal`, hovering colors each line only up to the pointer's position and mutes everything past it to a neutral gray, with the active dot riding the cursor — a scrubbing effect for reading a series left-to-right. When not hovering, the chart looks completely normal.
  


## Examples

Examples with different settings. Change `strokeVariant` on a `<Line>` or `curveType` on the chart to restyle it.

### Gradient Colors

### gradient colors

```tsx
"use client";

import { EChartsLineChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-line-chart";

const data = [
  { month: "January", desktop: 342, mobile: 184 },
  { month: "February", desktop: 876, mobile: 491 },
  { month: "March", desktop: 512, mobile: 290 },
  { month: "April", desktop: 629, mobile: 391 },
  { month: "May", desktop: 458, mobile: 309 },
  { month: "June", desktop: 781, mobile: 449 },
  { month: "July", desktop: 394, mobile: 234 },
  { month: "August", desktop: 925, mobile: 557 },
  { month: "September", desktop: 647, mobile: 367 },
  { month: "October", desktop: 532, mobile: 357 },
  { month: "November", desktop: 803, mobile: 515 },
  { month: "December", desktop: 271, mobile: 149 },
];

const chartConfig = {
  desktop: {
    label: "Desktop",
    colors: {
      light: ["red", "orange", "rosybrown", "purple", "blue"], // [!code highlight]
      dark: ["red", "orange", "rosybrown", "purple", "blue"], // [!code highlight]
    },
  },
  mobile: {
    label: "Mobile",
    colors: {
      light: ["gray"],
      dark: ["gray"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleLineChart() {
  return (
    <EChartsLineChart data={data} config={chartConfig} className="h-full w-full p-4">
      <EChartsLineChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsLineChart.Legend isClickable />
      <EChartsLineChart.Tooltip />
      <EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable>
        <EChartsLineChart.Dot variant="colored-border" />
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
      <EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" isClickable>
        <EChartsLineChart.Dot variant="colored-border" />
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
    </EChartsLineChart>
  );
}

```
### gradient colors - bump

```tsx
"use client";

import { EChartsLineChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-line-chart";

const data = [
  { month: "January", desktop: 342, mobile: 184 },
  { month: "February", desktop: 876, mobile: 491 },
  { month: "March", desktop: 512, mobile: 290 },
  { month: "April", desktop: 629, mobile: 391 },
  { month: "May", desktop: 458, mobile: 309 },
  { month: "June", desktop: 781, mobile: 449 },
  { month: "July", desktop: 394, mobile: 234 },
  { month: "August", desktop: 925, mobile: 557 },
  { month: "September", desktop: 647, mobile: 367 },
  { month: "October", desktop: 532, mobile: 357 },
  { month: "November", desktop: 803, mobile: 515 },
  { month: "December", desktop: 271, mobile: 149 },
];

const chartConfig = {
  desktop: {
    label: "Desktop",
    colors: {
      light: ["red", "orange", "rosybrown", "purple", "blue"],
      dark: ["red", "orange", "rosybrown", "purple", "blue"],
    },
  },
  mobile: {
    label: "Mobile",
    colors: {
      light: ["gray"],
      dark: ["gray"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleLineChart() {
  return (
    <EChartsLineChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      curveType="bump" // [!code highlight]
    >
      <EChartsLineChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsLineChart.Legend isClickable />
      <EChartsLineChart.Tooltip />
      <EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable>
        <EChartsLineChart.Dot variant="colored-border" />
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
      <EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" isClickable>
        <EChartsLineChart.Dot variant="colored-border" />
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
    </EChartsLineChart>
  );
}

```

### Curve Types

### curveType='bump'

```tsx
"use client";

import { EChartsLineChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-line-chart";

const data = [
  { month: "January", desktop: 342, mobile: 184 },
  { month: "February", desktop: 876, mobile: 491 },
  { month: "March", desktop: 512, mobile: 290 },
  { month: "April", desktop: 629, mobile: 391 },
  { month: "May", desktop: 458, mobile: 309 },
  { month: "June", desktop: 781, mobile: 449 },
  { month: "July", desktop: 394, mobile: 234 },
  { month: "August", desktop: 925, mobile: 557 },
  { month: "September", desktop: 647, mobile: 367 },
  { month: "October", desktop: 532, mobile: 357 },
  { month: "November", desktop: 803, mobile: 515 },
  { month: "December", desktop: 271, mobile: 149 },
];

const chartConfig = {
  desktop: {
    label: "Desktop",
    colors: {
      light: ["#047857"],
      dark: ["#10b981"],
    },
  },
  mobile: {
    label: "Mobile",
    colors: {
      light: ["#be123c"],
      dark: ["#f43f5e"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleLineChart() {
  return (
    <EChartsLineChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      curveType="bump" // [!code highlight]
    >
      <EChartsLineChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsLineChart.YAxis dataKey="desktop" />
      <EChartsLineChart.Legend isClickable />
      <EChartsLineChart.Tooltip />
      <EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable>
        <EChartsLineChart.Dot variant="default" />
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
      <EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" isClickable>
        <EChartsLineChart.Dot variant="default" />
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
    </EChartsLineChart>
  );
}

```
### curveType='step'

```tsx
"use client";

import { EChartsLineChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-line-chart";

const data = [
  { month: "January", desktop: 342, mobile: 184 },
  { month: "February", desktop: 876, mobile: 491 },
  { month: "March", desktop: 512, mobile: 290 },
  { month: "April", desktop: 629, mobile: 391 },
  { month: "May", desktop: 458, mobile: 309 },
  { month: "June", desktop: 781, mobile: 449 },
  { month: "July", desktop: 394, mobile: 234 },
  { month: "August", desktop: 925, mobile: 557 },
  { month: "September", desktop: 647, mobile: 367 },
  { month: "October", desktop: 532, mobile: 357 },
  { month: "November", desktop: 803, mobile: 515 },
  { month: "December", desktop: 271, mobile: 149 },
];

const chartConfig = {
  desktop: {
    label: "Desktop",
    colors: {
      light: ["#047857"],
      dark: ["#10b981"],
    },
  },
  mobile: {
    label: "Mobile",
    colors: {
      light: ["#be123c"],
      dark: ["#f43f5e"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleLineChart() {
  return (
    <EChartsLineChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      curveType="step" // [!code highlight]
    >
      <EChartsLineChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsLineChart.YAxis dataKey="desktop" />
      <EChartsLineChart.Legend isClickable />
      <EChartsLineChart.Tooltip />
      <EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable>
        <EChartsLineChart.Dot variant="default" />
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
      <EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" isClickable>
        <EChartsLineChart.Dot variant="default" />
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
    </EChartsLineChart>
  );
}

```
### curveType='monotoneY'

```tsx
"use client";

import { EChartsLineChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-line-chart";

const data = [
  { month: "January", desktop: 342, mobile: 184 },
  { month: "February", desktop: 876, mobile: 491 },
  { month: "March", desktop: 512, mobile: 290 },
  { month: "April", desktop: 629, mobile: 391 },
  { month: "May", desktop: 458, mobile: 309 },
  { month: "June", desktop: 781, mobile: 449 },
  { month: "July", desktop: 394, mobile: 234 },
  { month: "August", desktop: 925, mobile: 557 },
  { month: "September", desktop: 647, mobile: 367 },
  { month: "October", desktop: 532, mobile: 357 },
  { month: "November", desktop: 803, mobile: 515 },
  { month: "December", desktop: 271, mobile: 149 },
];

const chartConfig = {
  desktop: {
    label: "Desktop",
    colors: {
      light: ["#047857"],
      dark: ["#10b981"],
    },
  },
  mobile: {
    label: "Mobile",
    colors: {
      light: ["#be123c"],
      dark: ["#f43f5e"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleLineChart() {
  return (
    <EChartsLineChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      curveType="monotoneY" // [!code highlight]
    >
      <EChartsLineChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsLineChart.YAxis dataKey="desktop" />
      <EChartsLineChart.Legend isClickable />
      <EChartsLineChart.Tooltip />
      <EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable>
        <EChartsLineChart.Dot variant="default" />
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
      <EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" isClickable>
        <EChartsLineChart.Dot variant="default" />
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
    </EChartsLineChart>
  );
}

```

### Stroke Variants

### strokeVariant='solid'

```tsx
"use client";

import { EChartsLineChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-line-chart";

const data = [
  { month: "January", desktop: 342, mobile: 184 },
  { month: "February", desktop: 876, mobile: 491 },
  { month: "March", desktop: 512, mobile: 290 },
  { month: "April", desktop: 629, mobile: 391 },
  { month: "May", desktop: 458, mobile: 309 },
  { month: "June", desktop: 781, mobile: 449 },
  { month: "July", desktop: 394, mobile: 234 },
  { month: "August", desktop: 925, mobile: 557 },
  { month: "September", desktop: 647, mobile: 367 },
  { month: "October", desktop: 532, mobile: 357 },
  { month: "November", desktop: 803, mobile: 515 },
  { month: "December", desktop: 271, mobile: 149 },
];

const chartConfig = {
  desktop: {
    label: "Desktop",
    colors: {
      light: ["#047857"],
      dark: ["#10b981"],
    },
  },
  mobile: {
    label: "Mobile",
    colors: {
      light: ["#be123c"],
      dark: ["#f43f5e"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleLineChart() {
  return (
    <EChartsLineChart data={data} config={chartConfig} className="h-full w-full p-4">
      <EChartsLineChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsLineChart.YAxis dataKey="desktop" />
      <EChartsLineChart.Legend isClickable />
      <EChartsLineChart.Tooltip />
      <EChartsLineChart.Line
        dataKey="desktop"
        strokeVariant="solid" // [!code highlight]
        isClickable
      >
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
      <EChartsLineChart.Line
        dataKey="mobile"
        strokeVariant="solid" // [!code highlight]
        isClickable
      >
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
    </EChartsLineChart>
  );
}

```
### strokeVariant='dashed'

```tsx
"use client";

import { EChartsLineChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-line-chart";

const data = [
  { month: "January", desktop: 342, mobile: 184 },
  { month: "February", desktop: 876, mobile: 491 },
  { month: "March", desktop: 512, mobile: 290 },
  { month: "April", desktop: 629, mobile: 391 },
  { month: "May", desktop: 458, mobile: 309 },
  { month: "June", desktop: 781, mobile: 449 },
  { month: "July", desktop: 394, mobile: 234 },
  { month: "August", desktop: 925, mobile: 557 },
  { month: "September", desktop: 647, mobile: 367 },
  { month: "October", desktop: 532, mobile: 357 },
  { month: "November", desktop: 803, mobile: 515 },
  { month: "December", desktop: 271, mobile: 149 },
];

const chartConfig = {
  desktop: {
    label: "Desktop",
    colors: {
      light: ["#047857"],
      dark: ["#10b981"],
    },
  },
  mobile: {
    label: "Mobile",
    colors: {
      light: ["#be123c"],
      dark: ["#f43f5e"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleLineChart() {
  return (
    <EChartsLineChart data={data} config={chartConfig} className="h-full w-full p-4">
      <EChartsLineChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsLineChart.YAxis dataKey="desktop" />
      <EChartsLineChart.Legend isClickable />
      <EChartsLineChart.Tooltip />
      <EChartsLineChart.Line
        dataKey="desktop"
        strokeVariant="dashed" // [!code highlight]
        isClickable
      >
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
      <EChartsLineChart.Line
        dataKey="mobile"
        strokeVariant="dashed" // [!code highlight]
        isClickable
      >
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
    </EChartsLineChart>
  );
}

```
### strokeVariant='animated-dashed'

```tsx
"use client";

import { EChartsLineChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-line-chart";

const data = [
  { month: "January", desktop: 342, mobile: 184 },
  { month: "February", desktop: 876, mobile: 491 },
  { month: "March", desktop: 512, mobile: 290 },
  { month: "April", desktop: 629, mobile: 391 },
  { month: "May", desktop: 458, mobile: 309 },
  { month: "June", desktop: 781, mobile: 449 },
  { month: "July", desktop: 394, mobile: 234 },
  { month: "August", desktop: 925, mobile: 557 },
  { month: "September", desktop: 647, mobile: 367 },
  { month: "October", desktop: 532, mobile: 357 },
  { month: "November", desktop: 803, mobile: 515 },
  { month: "December", desktop: 271, mobile: 149 },
];

const chartConfig = {
  desktop: {
    label: "Desktop",
    colors: {
      light: ["#047857"],
      dark: ["#10b981"],
    },
  },
  mobile: {
    label: "Mobile",
    colors: {
      light: ["#be123c"],
      dark: ["#f43f5e"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleLineChart() {
  return (
    <EChartsLineChart data={data} config={chartConfig} className="h-full w-full p-4">
      <EChartsLineChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsLineChart.YAxis dataKey="desktop" />
      <EChartsLineChart.Legend isClickable />
      <EChartsLineChart.Tooltip />
      <EChartsLineChart.Line
        dataKey="desktop"
        strokeVariant="animated-dashed" // [!code highlight]
        isClickable
      >
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
      <EChartsLineChart.Line
        dataKey="mobile"
        strokeVariant="animated-dashed" // [!code highlight]
        isClickable
      >
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
    </EChartsLineChart>
  );
}

```

### Glowing Lines

### glowing - gradient colors

```tsx
"use client";

import { EChartsLineChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-line-chart";

const data = [
  { month: "January", desktop: 342, mobile: 184 },
  { month: "February", desktop: 876, mobile: 491 },
  { month: "March", desktop: 512, mobile: 290 },
  { month: "April", desktop: 629, mobile: 391 },
  { month: "May", desktop: 458, mobile: 309 },
  { month: "June", desktop: 781, mobile: 449 },
  { month: "July", desktop: 394, mobile: 234 },
  { month: "August", desktop: 925, mobile: 557 },
  { month: "September", desktop: 647, mobile: 367 },
  { month: "October", desktop: 532, mobile: 357 },
  { month: "November", desktop: 803, mobile: 515 },
  { month: "December", desktop: 271, mobile: 149 },
];

const chartConfig = {
  desktop: {
    label: "Desktop",
    colors: {
      light: ["red", "orange", "rosybrown", "purple", "blue"],
      dark: ["red", "orange", "rosybrown", "purple", "blue"],
    },
  },
  mobile: {
    label: "Mobile",
    colors: {
      light: ["gray"],
      dark: ["gray"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleLineChart() {
  return (
    <EChartsLineChart data={data} config={chartConfig} className="h-full w-full p-4">
      <EChartsLineChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsLineChart.Legend isClickable />
      <EChartsLineChart.Tooltip />
      <EChartsLineChart.Line
        dataKey="desktop"
        strokeVariant="solid"
        glowing // [!code highlight]
        isClickable
      >
        <EChartsLineChart.Dot variant="colored-border" />
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
      <EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" isClickable>
        <EChartsLineChart.Dot variant="colored-border" />
        <EChartsLineChart.ActiveDot variant="default" />
      </EChartsLineChart.Line>
    </EChartsLineChart>
  );
}

```
### glowing - solid colors

```tsx
"use client";

import { EChartsLineChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-line-chart";

const data = [
  { month: "January", desktop: 342, mobile: 184 },
  { month: "February", desktop: 876, mobile: 491 },
  { month: "March", desktop: 512, mobile: 290 },
  { month: "April", desktop: 629, mobile: 391 },
  { month: "May", desktop: 458, mobile: 309 },
  { month: "June", desktop: 781, mobile: 449 },
  { month: "July", desktop: 394, mobile: 234 },
  { month: "August", desktop: 925, mobile: 557 },
  { month: "September", desktop: 647, mobile: 367 },
  { month: "October", desktop: 532, mobile: 357 },
  { month: "November", desktop: 803, mobile: 515 },
  { month: "December", desktop: 271, mobile: 149 },
];

const chartConfig = {
  desktop: {
    label: "Desktop",
    colors: {
      light: ["#047857"],
      dark: ["#10b981"],
    },
  },
  mobile: {
    label: "Mobile",
    colors: {
      light: ["#be123c"],
      dark: ["#f43f5e"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleLineChart() {
  return (
    <EChartsLineChart data={data} config={chartConfig} className="h-full w-full p-4">
      <EChartsLineChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsLineChart.Legend isClickable />
      <EChartsLineChart.Tooltip />
      <EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable>
        <EChartsLineChart.Dot variant="border" />
        <EChartsLineChart.ActiveDot variant="colored-border" />
      </EChartsLineChart.Line>
      <EChartsLineChart.Line
        dataKey="mobile"
        strokeVariant="solid"
        glowing // [!code highlight]
        isClickable
      >
        <EChartsLineChart.Dot variant="border" />
        <EChartsLineChart.ActiveDot variant="colored-border" />
      </EChartsLineChart.Line>
    </EChartsLineChart>
  );
}

```

## API Reference

The chart is composed of several parts; the props below are grouped by part. On canvas each part is declarative config the root compiles, but the API mirrors the Recharts twin one-to-one.

<ApiHeading>EChartsLineChart</ApiHeading>

The root container. It owns the data, shared selection state, loading skeleton, and optional native `dataZoom` brush. Everything visual is composed as its children and compiled into the ECharts option.


  ### `data` (required)

type: `TData[]`

The chart data — an array of objects, one per data point (`TData extends Record<string, unknown>`).
  ### `config` (required)

type: `ChartConfig`

Defines the chart's series — each key matches a data key with a `label` and per-theme `colors` array. Same contract as every EvilCharts chart; see [Chart Config](/docs/chart-config).
  ### `children` (required)

type: `ReactNode`

The composed chart parts — `<Grid />`, `<XAxis />`, `<YAxis />`, `<Legend />`, `<Tooltip />`, and one or more `<Line />`.
  ### `className`

type: `string`

Additional CSS classes for the chart container.
  ### `xDataKey`

type: `keyof TData & string`

The data key for the x-axis categories. Falls back to the `<XAxis dataKey="…" />` value, then to the first data column no `<Line />` claims.
  ### `curveType`

type: `"linear" | "smooth" | "bump" | "monotone" | "monotoneX" | "monotoneY" | "natural" | "step"` · default: `"linear"`

Default curve interpolation inherited by every `<Line />`. Each `<Line />` may override it locally.
  ### `animation`

type: `boolean` · default: `true`

Master switch for the intro draw-in. Pass `false` to render the chart instantly, regardless of `animationType`.
  ### `animationType`

type: `"none" | "left-to-right" | "right-to-left" | "center-out" | "edges-in"` · default: `"left-to-right"`

The intro animation inherited by every `<Line />`. Any value but `"none"` plays ECharts' native progressive draw-in (the line traces in and dots pop up as its front passes; direction values exist for API parity with the Recharts twin). `"none"` disables it; devices set to OS reduce-motion fall back to `"none"` automatically.
  ### `enableHoverHighlight`

type: `boolean` · default: `false`

Highlights the hovered line by dimming the rest — the hover twin of click selection. Dim levels match the selection styling; a glowing line's glow and a buffer line's dashed tail dim and brighten with their parent.
  ### `enableHoverReveal`

type: `boolean` · default: `false`

On hover, colors each line up to the pointer's x-position and mutes the rest to a neutral gray, with the active dot at the cursor. A standalone hover mode that takes visual precedence over `enableHoverHighlight`; idle, the chart renders normally.
  ### `defaultSelectedDataKey`

type: `string | null` · default: `null`

The series selected on first render.
  ### `onSelectionChange`



void">
    Fires when a series is selected or deselected via a clickable `<Line />` or `<Legend />`. Receives the selected data key, or `null` on deselect.
  ### `isLoading`

type: `boolean` · default: `false`

Shows the animated loading skeleton.
  ### `loadingPoints`

type: `number` · default: `14`

Number of points in the loading skeleton.
  ### `chartOptions`



">
    Escape hatch merged over the built ECharts option object. See the [ECharts option documentation](https://echarts.apache.org/en/option.html).


<ApiHeading>Line</ApiHeading>

A single line series. Each `<Line />` is self-contained — its own stroke, glow, and clickability — so a chart can hold any number of independently styled lines.


  ### `dataKey` (required)

type: `string`

The series key. Must exist on both the data rows and the chart `config`.
  ### `strokeVariant`

type: `"solid" | "dashed" | "animated-dashed"` · default: `"solid"`

The stroke style for this line.
  ### `strokeWidth`

type: `number` · default: `0.8`

Stroke thickness for this line, in pixels.
  ### `curveType`

type: `"linear" | "smooth" | "bump" | "monotone" | "monotoneX" | "monotoneY" | "natural" | "step"`

The curve interpolation for this line. Falls back to the chart's `curveType` when omitted.
  ### `animationType`

type: `"none" | "left-to-right" | "right-to-left" | "center-out" | "edges-in"`

The intro draw-in for this line (the first `<Line />`'s value drives the chart). Falls back to the chart's `animationType` when omitted.
  ### `connectNulls`

type: `boolean` · default: `false`

Whether to connect line segments across null or missing values.
  ### `isClickable`

type: `boolean` · default: `false`

Lets this line be selected by clicking it. When any line is selected, the rest become semi-transparent.
  ### `glowing`

type: `boolean` · default: `false`

Applies a soft outer glow to this line, tinted with its series color.
  ### `enableBufferLine`

type: `boolean` · default: `false`

Renders this line's last segment as a dashed buffer while the rest stays solid — useful for projected or incomplete data at the end of a series.
  ### `children`

type: `ReactNode`

Optional `<Dot />` and `<ActiveDot />` config that adds point markers to this line.


<ApiHeading>Dot and ActiveDot</ApiHeading>

Point markers composed inside a `<Line />`. `<Dot />` is the resting marker; `<ActiveDot />` is the hovered marker. They render nothing on their own — the parent `<Line />` reads their `variant`.


  ### `variant`

type: `"default" | "border" | "colored-border"` · default: `"default"`

The visual style of the point marker.


<ApiHeading>XAxis and YAxis</ApiHeading>

The category and value axes. Include `<XAxis />` for x-axis labels and `<YAxis />` for the y-axis; omit either to hide it. Both hide automatically while the chart loads.


  ### `dataKey`

type: `string`

The data key for the axis values.
  ### `tickFormatter`



string">
    Formats the axis tick labels.
  ### `label`

type: `string`

An axis title rendered clear of the tick labels — centered below the x-axis labels, or rotated alongside the y-axis ones.
  ### `hideDots`

type: `boolean` · default: `false`

Hides the small tick dots that sit beside this axis's labels.


<ApiHeading>Grid</ApiHeading>

The background grid lines. Include it to render the dashed horizontal split lines; omit it and they don't draw. Takes no props.

<ApiHeading>Tooltip</ApiHeading>

The hover tooltip. Include it to enable the tooltip; omit it and none shows. It reads the chart's selection state, dimming unselected series in its content.


  ### `variant`

type: `"default" | "frosted-glass"` · default: `"default"`

The visual style of the tooltip surface.
  ### `roundness`

type: `"sm" | "md" | "lg" | "xl"` · default: `"lg"`

Controls the border-radius of the tooltip.
  ### `cursor`

type: `boolean` · default: `true`

Whether the vertical cursor line follows the pointer on hover.
  ### `position`

type: `"fixed" | "variable"` · default: `"variable"`

How the tooltip is anchored. `"variable"` follows both axes (the default). `"fixed"` pins the tooltip near the top of the chart and only tracks the pointer's X.


<ApiHeading>Legend</ApiHeading>

The series legend, rendered as HTML above the canvas. Include it to show the legend; omit it and none shows. With `isClickable`, each entry toggles selection of its series.


  ### `variant`

type: `"square" | "circle" | "circle-outline" | "rounded-square" | "rounded-square-outline" | "vertical-bar" | "horizontal-bar"`

The visual style of the legend indicators.
  ### `align`

type: `"left" | "center" | "right"` · default: `"right"`

Horizontal placement of the legend.
  ### `verticalAlign`

type: `"top" | "middle" | "bottom"` · default: `"top"`

Vertical placement of the legend.
  ### `isClickable`

type: `boolean` · default: `false`

Lets each legend entry toggle selection of its series.


<ApiHeading>Brush</ApiHeading>

An optional zoom brush below the chart — a themed mini chart driven by ECharts' native `dataZoom`. Include `<EChartsLineChart.Brush />` to render it; dragging the range filters the main chart.


  ### `height`

type: `number` · default: `56`

Height of the brush preview strip in pixels.
  ### `formatLabel`



string">
    Formats the range-handle labels below the brush.
  ### `onChange`



void">
    Fires when the brush selection range changes.

