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

### Basic Chart

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      stackType="stacked"
      xDataKey="month"
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.Brush formatLabel={(value) => String(value).substring(0, 3)} />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area dataKey="desktop" variant="gradient" isClickable>
        <EChartsAreaChart.Dot variant="border" />
        <EChartsAreaChart.ActiveDot variant="colored-border" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area dataKey="mobile" variant="gradient" isClickable>
        <EChartsAreaChart.Dot variant="border" />
        <EChartsAreaChart.ActiveDot variant="colored-border" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```

## Installation


  
  
    ### npm

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

### yarn

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

### bun

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

### pnpm

```bash
pnpm dlx shadcn@latest add @evilcharts/echarts-area-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.
        
          In your `components` directory, create `evilcharts`, then `charts` nested inside it, and paste the area-chart code into a new `echarts-area-chart.tsx` file there.
        
        
          ### components/evilcharts/charts/echarts-area-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 DotVariant } from "@/components/evilcharts/ui/echarts-dot";
import { LegendOverlay, type LegendVariant } from "@/components/evilcharts/ui/echarts-legend";
import type { ComposeOption, ImagePatternObject } from "echarts/core";
import { LineChart, type LineSeriesOption } from "echarts/charts";
import { motion, useReducedMotion } from "motion/react";
import { CanvasRenderer } from "echarts/renderers";
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.
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"]>>;

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

const STROKE_WIDTH = 0.8; // default series stroke — <Area 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 — ECharts hardcodes the line-entrance clip to
// linear and ignores animationEasing at every level (verified empirically).
const LOADING_DEFAULT_POINTS = 14;
// Buffer line: the last segment's stroke renders as this dash while the rest of
// the area stays solid, echoing the Recharts twin's 4px dash / 3px gap forecast
// tail. Ported from the line-chart twin.
const BUFFER_DASH: [number, number] = [4, 3];

// ─────────────────────────────────────────────────────────────────────────────
// 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 wave section
// inside it exists (stroke + fill), 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_MAX_OPACITY = 0.03; // fill 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
const BRUSH_FILL_OPACITY = 0.15; // mini-chart series fade, at the top stop
const BRUSH_FILLER_OPACITY = 0; // selected-range wash — evil-brush draws none

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

export type AreaVariant =
  | "gradient"
  | "gradient-reverse"
  | "solid"
  | "dotted"
  | "lines"
  | "hatched"
  | "none"; // stroke only — no fill at all
export type StrokeVariant = "solid" | "dashed" | "animated-dashed";
export type StackType = "default" | "stacked" | "expanded";
export type AreaAnimationType =
  | "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 EChartsAreaChartProps<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 <Area> inherits
  stackType?: StackType; // how multiple areas combine
  animation?: boolean; // master switch for the intro draw-in — false renders instantly
  animationType?: AreaAnimationType; // default intro reveal (first <Area> overrides)
  enableHoverHighlight?: boolean; // hovering a series dims the others, like a temporary selection
  enableHoverReveal?: boolean; // hovering colors each area up to the pointer's x and mutes the rest
  defaultSelectedDataKey?: string | null; // series selected on first render
  selectedDataKey?: string | null; // controlled selection — overrides internal state when set
  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 — <Area>, <XAxis>, <Grid>, <Tooltip>, <Legend>, <Brush>, …
}

// ─────────────────────────────────────────────────────────────────────────────
// Composible parts — DECLARATIVE CONFIG. Every part renders `null`; the root
// walks `children` by reference (child.type === Area, …) 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 AreaProps {
  dataKey: string; // series key — must exist on the data + config
  variant?: AreaVariant; // fill style for this area only
  strokeVariant?: StrokeVariant; // stroke style for this area
  strokeWidth?: number; // stroke thickness in pixels for this area
  curveType?: CurveType; // curve interpolation — falls back to the root curveType
  animationType?: AreaAnimationType; // intro reveal — first area drives the wrapper wipe
  connectNulls?: boolean; // join segments across null/missing values
  isClickable?: boolean; // lets this area be selected by clicking it
  enableBufferLine?: boolean; // renders this area's last segment as a dashed, fill-less buffer
  children?: ReactNode; // optional <Dot> and <ActiveDot> config
}

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

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

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

/** Declares the hovered/active point marker for the enclosing <Area>. 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 <Area>'s own
// children; a missing dot child means that marker does not render.
// ─────────────────────────────────────────────────────────────────────────────

type AreaSeriesConfig = {
  dataKey: string;
  variant: AreaVariant;
  strokeVariant: StrokeVariant;
  strokeWidth: number;
  curveType?: CurveType;
  animationType?: AreaAnimationType;
  connectNulls: boolean;
  isClickable: 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 = {
  areas: AreaSeriesConfig[];
  xAxis: XAxisSlot;
  yAxis: YAxisSlot;
  showGrid: boolean;
  tooltip: TooltipSlot;
  legend: LegendSlot;
  brush: BrushSlot;
};

function collectConfig(children: ReactNode): CollectedConfig {
  const areas: AreaSeriesConfig[] = [];
  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 === Area) {
      const props = child.props as AreaProps;
      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";
        }
      });
      areas.push({
        dataKey: props.dataKey,
        variant: props.variant ?? "gradient",
        strokeVariant: props.strokeVariant ?? "dashed",
        strokeWidth: props.strokeWidth ?? STROKE_WIDTH,
        curveType: props.curveType,
        animationType: props.animationType,
        connectNulls: props.connectNulls ?? false,
        isClickable: props.isClickable ?? 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 { areas, 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.

// ─────────────────────────────────────────────────────────────────────────────
// Fill paints — the ECharts analogue of the Recharts fill variants (§1.1).
// The first three are alpha fades; the last three are tiling canvas patterns.
// ─────────────────────────────────────────────────────────────────────────────

// Tiling texture fills, tinted with the series' first color. Stripes are drawn
// STRAIGHT (trivially seamless) and the pattern itself is rotated — zrender
// applies pattern transforms the same way ECharts decals do. Baking a diagonal
// into a square tile clips the stroke at the corners, which reads as periodic
// gaps once tiled. Tiles render at devicePixelRatio and scale back down so the
// texture stays crisp on retina canvases.
function patternFill(
  kind: "dotted" | "lines" | "hatched" | "stripe",
  color: string,
): ImagePatternObject | null {
  if (typeof document === "undefined") return null;
  const dpr = Math.max(window.devicePixelRatio || 1, 1);
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d");
  if (!ctx) return null;

  const size = (width: number, height: number) => {
    canvas.width = width * dpr;
    canvas.height = height * dpr;
    ctx.scale(dpr, dpr);
  };
  const pattern = (rotation = 0): ImagePatternObject => ({
    image: canvas,
    repeat: "repeat",
    rotation,
    scaleX: 1 / dpr,
    scaleY: 1 / dpr,
  });

  if (kind === "dotted") {
    size(6, 6);
    // Slightly larger dots at 0.7 — the vertical fade + areaStyle opacity temper
    // them, so at the old 0.5/r0.6 they washed out (especially on shorter areas).
    ctx.fillStyle = withAlpha(color, 0.7);
    ctx.beginPath();
    ctx.arc(3, 3, 0.85, 0, Math.PI * 2);
    ctx.fill();
    return pattern();
  }

  if (kind === "lines" || kind === "stripe") {
    // Vertical 1px line every 5px, rotated 45° by the pattern transform.
    size(5, 5);
    ctx.strokeStyle = withAlpha(color, 0.3);
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(2.5, -1);
    ctx.lineTo(2.5, 6);
    ctx.stroke();
    return pattern(-Math.PI / 4);
  }

  // hatched: bold two-tone stripes leaning ~20°, echoing the Recharts
  // gradient-edged stripe fill.
  size(20, 20);
  ctx.fillStyle = withAlpha(color, 0.06);
  ctx.fillRect(0, 0, 10, 20);
  ctx.fillStyle = withAlpha(color, 0.22);
  ctx.fillRect(10, 0, 10, 20);
  return pattern((20 * Math.PI) / 180);
}

// Canvas can't express "multi-stop color horizontally × alpha fade vertically"
// as one gradient, so multi-color fills composite the two on an offscreen canvas
// sized to the chart: paint the horizontal color run, then mask it with a
// vertical alpha ramp via destination-in. Regenerated on resize (patterns anchor
// to the renderer's origin at natural pixel size).
function gradientFillTexture(
  slots: string[],
  width: number,
  height: number,
  reverse: boolean,
): HTMLCanvasElement | null {
  if (typeof document === "undefined" || width < 1 || height < 1) return null;

  const canvas = document.createElement("canvas");
  canvas.width = Math.ceil(width);
  canvas.height = Math.ceil(height);
  const ctx = canvas.getContext("2d");
  if (!ctx) return null;

  const colors = ctx.createLinearGradient(0, 0, canvas.width, 0);
  slots.forEach((color, i) => colors.addColorStop(i / (slots.length - 1), color));
  ctx.fillStyle = colors;
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  const fade = ctx.createLinearGradient(0, 0, 0, canvas.height);
  fade.addColorStop(0, `rgba(0, 0, 0, ${reverse ? 0 : 0.1})`);
  fade.addColorStop(1, `rgba(0, 0, 0, ${reverse ? 0.1 : 0})`);
  ctx.globalCompositeOperation = "destination-in";
  ctx.fillStyle = fade;
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  return canvas;
}

// A pattern fill (dotted/lines/hatched) faded vertically — opaque at the top
// (near the line), transparent toward the baseline — so it reads like the
// gradient variant instead of a flat wall of pattern. The tiling pattern can't
// itself carry an alpha ramp, so bake it into a plot-sized texture: tile the
// pattern (reusing patternFill's tile + rotation), then mask it with a vertical
// alpha ramp via destination-in.
function patternFadeTexture(
  kind: "dotted" | "lines" | "hatched",
  color: string,
  width: number,
  height: number,
): HTMLCanvasElement | null {
  const patternObj = patternFill(kind, color);
  if (!patternObj || typeof document === "undefined" || width < 1 || height < 1) return null;
  const tile = patternObj.image;
  if (!(tile instanceof HTMLCanvasElement)) return null;
  const rotation = patternObj.rotation ?? 0;
  const tileScale = patternObj.scaleX ?? 1; // patternFill draws the tile at dpr; 1/dpr scales it back

  const w = Math.ceil(width);
  const h = Math.ceil(height);
  const canvas = document.createElement("canvas");
  canvas.width = w;
  canvas.height = h;
  const ctx = canvas.getContext("2d");
  if (!ctx) return null;

  const pat = ctx.createPattern(tile, "repeat");
  if (!pat) return null;
  // Replicate the ECharts ImagePattern transform (rotate the tiling, scale the
  // dpr tile back to CSS size) so the baked texture matches the plain pattern.
  if (typeof pat.setTransform === "function") {
    const m = new DOMMatrix();
    m.rotateSelf((rotation * 180) / Math.PI);
    m.scaleSelf(tileScale, tileScale);
    pat.setTransform(m);
  }
  ctx.fillStyle = pat;
  ctx.fillRect(0, 0, w, h);

  const fade = ctx.createLinearGradient(0, 0, 0, h);
  fade.addColorStop(0, "rgba(0, 0, 0, 1)"); // top: keep the pattern
  fade.addColorStop(1, "rgba(0, 0, 0, 0)"); // baseline: fade it out
  ctx.globalCompositeOperation = "destination-in";
  ctx.fillStyle = fade;
  ctx.fillRect(0, 0, w, h);

  return canvas;
}

// Resolves the area fill for a variant into an ECharts color value. `size` is the
// full renderer size, used to bake 2D gradients for multi-color series.
function fillPaint(
  variant: AreaVariant,
  showUnselected: boolean,
  slots: string[],
  size: { width: number; height: number },
): string | echarts.graphic.LinearGradient | ImagePatternObject {
  const base = slots[0] ?? "rgba(120, 120, 120, 1)";
  const multi = slots.length > 1;

  // "none" — stroke only, no fill (even when unselected).
  if (variant === "none") return "transparent";

  // A non-selected area in a clickable chart recedes as a 45° stripe texture.
  if (showUnselected) {
    return patternFill("stripe", base) ?? withAlpha(base, 0.1);
  }

  switch (variant) {
    case "gradient":
    case "gradient-reverse": {
      const reverse = variant === "gradient-reverse";
      if (multi) {
        const texture = gradientFillTexture(slots, size.width, size.height, reverse);
        if (texture) return { image: texture, repeat: "no-repeat" };
      }
      return new echarts.graphic.LinearGradient(0, 0, 0, 1, [
        { offset: 0, color: withAlpha(base, reverse ? 0 : 0.1) },
        { offset: 1, color: withAlpha(base, reverse ? 0.1 : 0) },
      ]);
    }
    case "solid": {
      // Uniform alpha, so the horizontal color run survives as one gradient.
      if (multi) {
        return new echarts.graphic.LinearGradient(
          0,
          0,
          1,
          0,
          slots.map((color, i) => ({
            offset: i / (slots.length - 1),
            color: withAlpha(color, 0.1),
          })),
        );
      }
      return withAlpha(base, 0.1);
    }
    case "dotted":
    case "lines":
    case "hatched": {
      // Faded by default (opaque near the line, transparent at the baseline).
      const texture = patternFadeTexture(variant, base, size.width, size.height);
      if (texture) return { image: texture, repeat: "no-repeat" };
      return patternFill(variant, base) ?? withAlpha(base, 0.1);
    }
    default:
      return withAlpha(base, 0.1);
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Curve mapping — linear → straight, step → step:"end", 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 (§4.3) — dims a series only when another one is selected.
// ─────────────────────────────────────────────────────────────────────────────

function getOpacity(selected: string | null, key: string) {
  if (selected === null || selected === key) return { fill: 0.8, stroke: 1, dot: 1 };
  return { fill: 0.1, 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;
}

// The `__buffer-` prefix marks the dashed forecast overlay of a buffer area; 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 fill-only patch under a buffer area's dashed tail (the main area's fill
// stops one point early). Internal, so the tooltip drops it.
const BUFFERFILL_PREFIX = "__bufferfill-";
// The `__reveal-` prefix marks the muted base layer of a hover-reveal area — see
// buildAreaSeries. Internal, so the tooltip drops it like the mini/loading rows.
const REVEAL_PREFIX = "__reveal-";

// ─────────────────────────────────────────────────────────────────────────────
// 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;
  areas: AreaSeriesConfig[];
  seriesKeys: string[];
  curveType: CurveType;
  isStacked: boolean;
  isExpanded: boolean;
  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 area 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[]>; // buildAreaSeries writes each area's full per-datum points here for the hover handler
  resolved: ResolvedColors;
  rendererSize: { width: number; height: number }; // 2D gradient textures bake at renderer size
  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, isExpanded, 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,
      // 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), kept for parity with the bar/composed charts.
      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,
    max: isExpanded ? 1 : undefined,
    // 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: isExpanded
        ? (value: number) => `${Math.round(value * 100)}%`
        : yTickFormatter
          ? (value: number, index: number) => yTickFormatter(value, index)
          : undefined,
    },
  };

  return { xAxis, yAxis };
}

// Tooltip HTML builder, closed over the build context. `getHoveredKey` is read
// per invocation — ECharts calls the formatter at hover time, and syncing hover
// through an option push instead would reset the native blur state mid-hover.
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 area 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, hover-reveal base).
        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
        // area's solid part stops before the last point, a revealed series stops
        // at the cursor) — skip it, letting another row for the 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 look, canvas-style: a real mini chart of the full
// data 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, areas, curveType, isStacked, 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[] = areas.map((area) => {
    const key = area.dataKey;
    const base = (ctx.resolved.series[key] ?? [])[0] ?? "rgba(120, 120, 120, 1)";
    const curve = curveConfig(area.curveType ?? curveType);

    // The mini chart mirrors the click selection: unselected series recede
    // by the same ratios as the main plot.
    // Dim ratios normalize against the base opacities (stroke 1, fill 0.8).
    const opacity = getOpacity(selectedDataKey, key);
    const strokeDim = opacity.stroke;
    const fillDim = opacity.fill / 0.8;

    return {
      id: `__mini-${key}`,
      type: "line",
      xAxisIndex: 1,
      yAxisIndex: 1,
      data: data.map((row) => Number(row[key]) || 0),
      stack: isStacked ? "__mini-total" : undefined,
      smooth: curve.smooth,
      step: curve.step,
      connectNulls: area.connectNulls,
      silent: true,
      showSymbol: false,
      emphasis: { disabled: true },
      tooltip: { show: false },
      lineStyle: { color: base, width: 1, opacity: BRUSH_STROKE_OPACITY * strokeDim },
      areaStyle: {
        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
          { offset: 0, color: withAlpha(base, BRUSH_FILL_OPACITY * fillDim) },
          { offset: 1, color: withAlpha(base, 0) },
        ]),
      },
      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 areas (Recharts
// parity: its skeleton is a single LoadingArea), swept by the shimmer rAF.
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 },
        areaStyle: { color: withAlpha(tokens.foreground, 0) },
        z: 1,
      },
    ],
  };
}

function buildAreaSeries(ctx: OptionBuildContext): LineSeriesOption[] {
  const {
    data,
    config,
    areas,
    seriesKeys,
    curveType,
    isStacked,
    isExpanded,
    selectedDataKey,
    hasSelection,
    enableHoverHighlight,
    enableHoverReveal,
    revealIndex,
    revealSink,
    resolved,
    rendererSize,
  } = ctx;

  // Optional per-row normalization for the expanded (100%) stack.
  const rowTotals = isExpanded
    ? data.map((row) => seriesKeys.reduce((sum, key) => sum + (Number(row[key]) || 0), 0))
    : [];

  return areas.flatMap((area): LineSeriesOption[] => {
    const key = area.dataKey;
    const slots = resolved.series[key] ?? ["rgba(120, 120, 120, 1)"];
    const paint = seriesPaint(slots);
    const isSelected = selectedDataKey === key;
    const showUnselected = hasSelection && !isSelected;
    const opacity = getOpacity(selectedDataKey, key);
    const curve = curveConfig(area.curveType ?? curveType);

    const values = data.map((row, i) => {
      const value = Number(row[key]) || 0;
      if (!isExpanded) return value;
      const total = rowTotals[i];
      return total ? value / total : 0;
    });
    const n = values.length;
    // Hover-reveal is a root-level mode and owns the whole area rendering, so it
    // takes precedence over a per-area buffer tail when both are set.
    const reveal = enableHoverReveal;
    const buffer = !reveal && area.enableBufferLine && n >= 2;
    const revealActive = reveal && revealIndex !== null;

    const restingDot = dotStyle(area.dotVariant, paint, resolved.tokens.background);
    const activeDot = dotStyle(area.activeDotVariant, paint, resolved.tokens.background);
    const restingVisible = area.dotVariant !== "none";
    const dotOpacity = opacity.dot;
    const multiColor = slots.length > 1;

    // The reveal truncates the line to the cursor, which would COMPRESS a
    // bbox-relative stroke gradient into the shorter span — misaligning it from
    // the plot-anchored fill texture and the index-sampled dots. Anchor the
    // stroke to the plot in absolute pixels so every x keeps its own color.
    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 areas (like the Recharts dots; the line/fill keep the full
    // gradient), and passing `null` gaps through so a buffer area's two parts
    // each draw only their own segment.
    type AreaPoint =
      | number
      | null
      | {
          value: number;
          itemStyle: Record<string, unknown>;
          emphasis: { itemStyle: Record<string, unknown> };
        };
    const toPoints = (vals: (number | null)[]): AreaPoint[] =>
      !multiColor
        ? vals
        : vals.map((value, i): AreaPoint => {
            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 ? area.dotVariant : area.activeDotVariant,
                  pointColor,
                  resolved.tokens.background,
                ),
                opacity: dotOpacity,
              },
              emphasis: {
                itemStyle: {
                  ...dotItemStyle(
                    area.activeDotVariant === "none" ? "default" : area.activeDotVariant,
                    pointColor,
                    resolved.tokens.background,
                  ),
                  opacity: 1,
                },
              },
            };
          });

    // Buffer area: the solid MAIN part drops the last point (its final segment —
    // both fill and stroke — becomes the dashed, fill-less overlay); the overlay
    // carries only the last two points. Reveal instead TRUNCATES the real series
    // at the cursor's x-index (points beyond it null'd), so its line + fill stop
    // 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.
    // 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);

    const mainValues: (number | null)[] = buffer
      ? values.map((v, i) => (i === n - 1 ? null : v))
      : revealActive
        ? sliceToNull(values, revealIndex as number)
        : values;

    // A buffer area keeps its body solid and dashes only the tail overlay, so
    // the main stroke is always solid regardless of strokeVariant (matches the
    // Recharts twin, which suppresses the base dasharray while buffering).
    const mainDash: "solid" | [number, number] =
      buffer || area.strokeVariant === "solid" ? "solid" : ([3, 3] as [number, number]);

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

    const mainSeries: LineSeriesOption = {
      id: key,
      name: typeof config[key]?.label === "string" ? config[key]?.label : key,
      type: "line",
      data: toPoints(mainValues),
      stack: isStacked ? "total" : undefined,
      smooth: curve.smooth,
      step: curve.step,
      connectNulls: area.connectNulls,
      cursor: area.isClickable ? "pointer" : "default",
      // By default ECharts only fires mouse events on the symbols — this makes
      // the line AND the filled area clickable, like the Recharts <Area>.
      // (`true` covers both; the deprecated `triggerLineEvent` did the same.)
      triggerEvent: area.isClickable,
      showSymbol: restingVisible,
      symbol: "circle",
      symbolSize: restingVisible ? restingDot.size : activeDot.size,
      z,
      lineStyle: {
        color: strokePaint,
        width: area.strokeWidth,
        opacity: opacity.stroke,
        type: mainDash,
        dashOffset: 0,
      },
      itemStyle: multiColor
        ? { opacity: dotOpacity }
        : {
            ...(restingVisible ? restingDot.itemStyle : activeDot.itemStyle),
            opacity: dotOpacity,
          },
      areaStyle: {
        color: fillPaint(area.variant, showUnselected, slots, rendererSize),
        opacity: opacity.fill,
      },
      emphasis: {
        // focus "series" blurs every other series in this grid while one is
        // hovered — the hover twin of the click selection. 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).
        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 (fill 0.1 / stroke 0.3 / dot 0.3).
      blur: {
        lineStyle: { opacity: 0.3 },
        areaStyle: { opacity: 0.1 },
        itemStyle: { opacity: 0.3 },
      },
    };

    // Hover-reveal: a muted gray BASE layer 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 series has stopped — shows as neutral gray.
    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,
        // Its OWN stack, not "total" — a second series in the real stack would
        // double every key's contribution (broken geometry). This mirror stack
        // reproduces the same cumulative shape in a separate layer.
        stack: isStacked ? "__reveal-total" : undefined,
        smooth: curve.smooth,
        step: curve.step,
        connectNulls: false,
        silent: true,
        showSymbol: false,
        symbol: "circle",
        z: z - 1,
        // Neutral gray, NO fill, SAME dash pattern as the colored line.
        lineStyle: {
          color: muted,
          width: area.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 [mainSeries];

    // Dashed forecast overlay — draws ONLY the last segment's stroke, with NO
    // fill (matching the line twin's fill-less buffer). 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),
      // Own mirror stack — a second series in "total" would double the last
      // points' stacked height (buffer drawn too high). Same values in the same
      // order give the identical cumulative height, so the dash lines up.
      stack: isStacked ? "__buffer-total" : undefined,
      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: area.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 area it belongs to. The root
      // dispatch-links this id (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 } },
    };

    // Fill patch — the main area drops its last point (so the tail stroke can be
    // the dashed overlay), which also removes the FILL under that last segment.
    // This fill-only layer (no stroke, no dots) fills just that segment so the
    // area reads as full under the dashed tail. Its OWN mirror stack keeps the
    // stacked geometry right — a second series in "total" would double the last
    // points' cumulative height (same reason as the reveal base + mini chart).
    const bufferFillSeries: LineSeriesOption = {
      id: `${BUFFERFILL_PREFIX}${key}`,
      type: "line",
      data: toPoints(bufferValues),
      stack: isStacked ? "__bufferfill-total" : undefined,
      smooth: curve.smooth,
      step: curve.step,
      connectNulls: true,
      silent: true,
      showSymbol: false,
      z: z - 1,
      lineStyle: { opacity: 0 },
      areaStyle: {
        color: fillPaint(area.variant, showUnselected, slots, rendererSize),
        opacity: opacity.fill,
      },
      emphasis: { disabled: true },
      blur: { areaStyle: { opacity: 0.1 } },
      tooltip: { show: false },
    };

    return [mainSeries, bufferSeries, bufferFillSeries];
  });
}

// Copy a value list with everything AFTER `idx` nulled — the hover-reveal cut:
// the colored real series keeps its data up to the cursor and drops the rest, so
// (with connectNulls false) its line and fill stop 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));
}

// Per-series PLOTTED top value per category index — expanded normalization and
// stack accumulation applied — so pointer hit-testing can reason in data space.
function computePlottedTops(ctx: OptionBuildContext): Record<string, number[]> {
  const { data, areas, seriesKeys, isStacked, isExpanded } = ctx;
  const rowTotals = isExpanded
    ? data.map((row) => seriesKeys.reduce((sum, key) => sum + (Number(row[key]) || 0), 0))
    : [];
  const running = new Array(data.length).fill(0);
  const tops: Record<string, number[]> = {};
  for (const area of areas) {
    const key = area.dataKey;
    tops[key] = data.map((row, i) => {
      let value = Number(row[key]) || 0;
      if (isExpanded) value = rowTotals[i] ? value / rowTotals[i] : 0;
      return isStacked ? (running[i] += value) : value;
    });
  }
  return tops;
}

// Overlapping area polygons all contain the same pixel, so ECharts' native hit
// test lands on whichever series drew topmost — not the band the user SEES.
// Resolve the intended series geometrically: a plotted line within grab
// distance of the pointer wins outright; otherwise the point belongs to the
// nearest line ABOVE it (the boundary of the band the pointer is inside).
// Returns null when the pointer is outside the grid or above every line.
function resolveAreaAtPixel(
  chart: EChartsInstance,
  tops: Record<string, number[]>,
  keys: string[],
  x: number,
  y: number,
): string | null {
  if (keys.length < 2) return null;
  if (!chart.containPixel({ gridIndex: 0 }, [x, y])) return null;
  const [rawIndex] = chart.convertFromPixel({ gridIndex: 0 }, [x, y]);
  const index = Math.round(rawIndex);

  let nearest: string | null = null;
  let nearestDist = Infinity;
  let above: string | null = null;
  let abovePixelY = -Infinity;
  for (const key of keys) {
    const value = tops[key]?.[index];
    if (value === undefined) continue;
    const pixelY = chart.convertToPixel({ gridIndex: 0 }, [index, value])[1];
    const dist = Math.abs(pixelY - y);
    if (dist < nearestDist) {
      nearestDist = dist;
      nearest = key;
    }
    // Pixel y grows downward: a line above the pointer has the larger pixelY
    // among those ≤ the pointer's.
    if (pixelY <= y && pixelY > abovePixelY) {
      abovePixelY = pixelY;
      above = key;
    }
  }
  return nearestDist <= 10 ? nearest : above;
}

// ─────────────────────────────────────────────────────────────────────────────
// 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
  plottedTops: Record<string, number[]>; // per-series plotted line value per index, for pointer hit-testing
  seriesKeyByIndex: (string | undefined)[]; // built series order → key, so a polygon click's seriesIndex recovers its key past interleaved buffer/reveal/mini series
  companionIdsByKey: Map<string, string[]>; // per-key silent companion series ids (buffer tail, reveal base) — highlighted/downplayed with their parent
  revealIndex: number | null; // hover-reveal pointer x-index (null = idle); read by builds and the reveal hover handler
  revealValues: Record<string, unknown[]>; // per-area FULL per-datum points (with dot itemStyle), sliced to the cursor on hover without a rebuild
  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 };
  // 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 area 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 — `<Area>`, `<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` and `echarts`.
 */
export function EChartsAreaChart<TData extends Record<string, unknown>>({
  data,
  config,
  xDataKey,
  className,
  curveType = "linear",
  stackType = "default",
  animation = true,
  animationType = "left-to-right",
  enableHoverHighlight = false,
  enableHoverReveal = false,
  defaultSelectedDataKey = null,
  selectedDataKey: selectedDataKeyProp,
  onSelectionChange,
  isLoading = false,
  loadingPoints = LOADING_DEFAULT_POINTS,
  chartOptions,
  children,
}: EChartsAreaChartProps<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,
    plottedTops: {},
    seriesKeyByIndex: [],
    companionIdsByKey: new Map<string, string[]>(),
    revealIndex: null,
    revealValues: {},
    brushRange: { start: 0, end: 100 },
    brushGeom: null,
    brushOverlay: null,
    brushHover: { inside: false, left: false, right: false },
    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();

  // Selection is controlled when the `selectedDataKey` prop is provided;
  // otherwise the internal state (seeded by defaultSelectedDataKey) drives it.
  const [internalSelectedKey, setSelectedDataKey] = useState<string | null>(defaultSelectedDataKey);
  const selectedDataKey =
    selectedDataKeyProp !== undefined ? selectedDataKeyProp : internalSelectedKey;

  // 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' 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 {
    areas,
    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(() => areas.map((area) => area.dataKey), [areas]);

  // x category key: <XAxis dataKey> → root xDataKey → first data column no <Area> 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 area's setting, falling back to the root default.
  const effectiveAnimation = areas[0]?.animationType ?? animationType;

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

  const hasSelection = selectedDataKey !== null;
  const isExpanded = stackType === "expanded";
  const isStacked = stackType === "stacked" || isExpanded;

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

  // 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;

  // Reads the CURRENT selection through live.handlers so the identity stays
  // stable for the init effect's click closure, and stays correct when the
  // selection is controlled from outside.
  const toggleSelection = useCallback(
    (key: string) => {
      const next = live.handlers.selectedDataKey === key ? null : key;
      // Making a selection hands the canvas to the selection dim — clear any
      // active hover highlight at that moment so it doesn't linger in the
      // legend/tooltip or fight the notMerge rebuild that follows.
      if (next !== null && live.hoveredKey !== null) {
        const previous = live.hoveredKey;
        live.hoveredKey = null;
        setHoveredDataKey(null);
        echartsRef.current?.dispatchAction({
          type: "downplay",
          seriesIndex: live.handlers.seriesKeys.indexOf(previous),
        });
      }
      setSelectedDataKey(next);
      live.handlers.onSelectionChange?.(next);
    },
    [live],
  );

  // 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;

    // buildAreaSeries fills this with each area'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,
      areas,
      seriesKeys,
      curveType,
      isStacked,
      isExpanded,
      selectedDataKey,
      hasSelection,
      showGrid,
      xAxisSlot,
      yAxisSlot,
      tooltipSlot,
      legendSlot,
      isLoading,
      loadingData,
      showBrush,
      brushHeight,
      enableHoverHighlight,
      enableHoverReveal,
      revealIndex: live.revealIndex,
      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,
      revealSink,
    };

    live.plottedTops = computePlottedTops(ctx);

    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 = [...buildAreaSeries(ctx), ...(brush?.miniSeries ?? [])];
    // buildAreaSeries has now filled revealSink with each area's full per-datum
    // points — hand them to the hover handler for slicing.
    if (enableHoverReveal) live.revealValues = revealSink;
    // Record the exact series order so an area-polygon 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.
    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 (buffer tail, hover-reveal
    // base), mirroring exactly what buildAreaSeries emits — the hover handlers
    // highlight/downplay these with the parent so focus:"series" never strands
    // an area's own forecast tail or muted reveal base apart from it.
    const companionIdsByKey = new Map<string, string[]>();
    for (const area of areas) {
      const ids: string[] = [];
      if (area.enableBufferLine && data.length >= 2) {
        ids.push(`${BUFFER_PREFIX}${area.dataKey}`, `${BUFFERFILL_PREFIX}${area.dataKey}`);
      }
      if (enableHoverReveal) ids.push(`${REVEAL_PREFIX}${area.dataKey}`);
      if (ids.length) companionIdsByKey.set(area.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,
    areas,
    seriesKeys,
    xCategoryKey,
    curveType,
    isStacked,
    isExpanded,
    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();
      // 2D gradient textures are baked at renderer size — rebuild them to fit.
      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, seriesKeys: keys } = live.handlers;
      const p = params as {
        seriesId?: string;
        seriesIndex?: number;
        event?: { offsetX?: number; offsetY?: number };
      };
      // Symbol clicks carry seriesId; area-polygon clicks (triggerEvent)
      // only carry seriesIndex — recover the key from the last build's index map,
      // which accounts for the extra `__buffer-`/`__reveal-`/`__mini-` series
      // interleaved between the main ones (a raw seriesKeys lookup would miss).
      let id =
        p.seriesId ??
        (typeof p.seriesIndex === "number" ? live.seriesKeyByIndex[p.seriesIndex] : undefined);
      // Overlapping polygons: the native hit is the topmost series, not the
      // band the pointer is visually inside — resolve geometrically.
      if (typeof p.event?.offsetX === "number" && typeof p.event?.offsetY === "number") {
        const resolved = resolveAreaAtPixel(
          chart,
          live.plottedTops,
          keys,
          p.event.offsetX,
          p.event.offsetY,
        );
        if (resolved) id = resolved;
      }
      if (typeof id === "string" && clickable.has(id)) toggleSelection(id);
    });

    // Hover-highlight is POINTER-driven, not series-mouseover-driven:
    // overlapping polygons would pin the native hover on the topmost series and
    // never re-fire while the pointer moves within it. A zrender mousemove
    // tracker resolves the visually-hovered band, drives the canvas emphasis
    // via dispatchAction, and mirrors into the HTML legend (React state) and
    // tooltip (live.hoveredKey — its formatter runs per pointer move).
    const applyHoverKey = (key: string | null) => {
      if (live.hoveredKey === key) return;
      const previous = live.hoveredKey;
      live.hoveredKey = key;
      setHoveredDataKey(key);
      // Dispatch by seriesId (buffer/reveal series shift the numeric indices), and
      // link each key's silent companions (buffer tail, reveal base) so
      // focus:"series" never strands them apart from their parent.
      if (previous) {
        chart.dispatchAction({ type: "downplay", seriesId: previous });
        for (const id of live.companionIdsByKey.get(previous) ?? [])
          chart.dispatchAction({ type: "downplay", seriesId: id });
      }
      if (key) {
        chart.dispatchAction({ type: "highlight", seriesId: key });
        for (const id of live.companionIdsByKey.get(key) ?? [])
          chart.dispatchAction({ type: "highlight", seriesId: id });
      }
    };

    // Hover-reveal: color each area 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 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 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 clearReveal = () => {
      if (live.revealIndex === null) return;
      live.revealIndex = null;
      pushReveal(null);
    };

    const zrHover = chart.getZr();
    const onZrHoverMove = (event: { offsetX?: number; offsetY?: number }) => {
      // Reveal is a standalone hover mode and takes precedence over highlight.
      if (live.handlers.enableHoverReveal) {
        applyReveal(event);
        return;
      }
      if (!live.handlers.enableHoverHighlight) return;
      // A click selection owns the canvas dim — hover highlighting stops
      // entirely while one exists and resumes once it clears.
      if (live.handlers.selectedDataKey !== null) return;
      applyHoverKey(
        resolveAreaAtPixel(
          chart,
          live.plottedTops,
          live.handlers.seriesKeys,
          event.offsetX ?? -1,
          event.offsetY ?? -1,
        ),
      );
    };
    const onZrHoverOut = () => {
      if (live.handlers.enableHoverReveal) clearReveal();
      else if (live.handlers.enableHoverHighlight) applyHoverKey(null);
    };
    zrHover.on("mousemove", onZrHoverMove);
    zrHover.on("globalout", onZrHoverOut);

    // The native hover still emphasizes whichever element the pointer entered —
    // cancel it whenever it disagrees with the tracker's resolved key.
    chart.on("mouseover", (params) => {
      const { enableHoverHighlight: hoverOn, enableHoverReveal: revealOn } = live.handlers;
      if (!hoverOn || revealOn) return;
      // While a selection is active, hover highlighting is disabled — never
      // dispatch emphasis/downplay so the selection dim is the only dimming.
      if (live.handlers.selectedDataKey !== null) return;
      const p = params as { seriesIndex?: number; componentType?: string };
      if (p.componentType !== "series" || typeof p.seriesIndex !== "number") return;
      const key = live.seriesKeyByIndex[p.seriesIndex];
      if (!key || key.startsWith("__")) return;
      if (key !== live.hoveredKey) {
        chart.dispatchAction({ type: "downplay", seriesIndex: p.seriesIndex });
        if (live.hoveredKey) {
          chart.dispatchAction({ type: "highlight", seriesId: live.hoveredKey });
        }
      }
    });

    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 () => {
      zrHover.off("mousemove", onZrHoverMove);
      zrHover.off("globalout", onZrHoverOut);
      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 <Area>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 textures need renderer-sized rebakes)
    // 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 = areas
      .filter((area) => area.strokeVariant === "animated-dashed" && !area.enableBufferLine)
      .map((area) => area.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, areas, 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 shared
      // by stroke and fill — bbox-relative coords put the window at different
      // positions for the line vs the area polygon (their bounding boxes
      // differ), which made the line trail the fill near the sweep's end.
      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 },
              areaStyle: { color: clip(LOADING_SHIMMER_MAX_OPACITY) },
            },
          ],
        },
        { 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 <EChartsAreaChart.Area/>, <EChartsAreaChart.Tooltip/>, … from a single
// import — no colliding named marker exports when several charts share one file.
EChartsAreaChart.Area = Area;
EChartsAreaChart.Dot = Dot;
EChartsAreaChart.ActiveDot = ActiveDot;
EChartsAreaChart.XAxis = XAxis;
EChartsAreaChart.YAxis = YAxis;
EChartsAreaChart.Grid = Grid;
EChartsAreaChart.Tooltip = Tooltip;
EChartsAreaChart.Legend = Legend;
EChartsAreaChart.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 area chart is composable. `<EChartsAreaChart>` is the container, and every part hangs off it as a compound member — `<EChartsAreaChart.Grid>`, `<EChartsAreaChart.XAxis>`, `<EChartsAreaChart.YAxis>`, `<EChartsAreaChart.Legend>`, `<EChartsAreaChart.Tooltip>`, `<EChartsAreaChart.Brush>`, and one or more `<EChartsAreaChart.Area>` — so a single import gives you the whole chart. Each `<Area>` sets its own `variant`, `strokeVariant`, and `isClickable`, so one chart can mix fills, strokes, and selective interactivity.

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

```tsx
<EChartsAreaChart data={data} config={chartConfig} stackType="stacked">
  <EChartsAreaChart.Grid />
  <EChartsAreaChart.XAxis dataKey="month" />
  <EChartsAreaChart.Brush />
  <EChartsAreaChart.Legend isClickable />
  <EChartsAreaChart.Tooltip />
  <EChartsAreaChart.Area dataKey="desktop" variant="gradient" strokeVariant="dashed" isClickable />
  <EChartsAreaChart.Area dataKey="mobile" variant="hatched" strokeVariant="solid" isClickable />
</EChartsAreaChart>
```

The one real difference 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 — same JSX, same presence semantics (omit a part and it won't render), same behavior, just declarative config instead of 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 CSS variables at runtime, so dark mode just works.

> 
  
    Canvas rendering brings two small departures from the Recharts twin: multi-color gradients bake into a texture at render size (visually equivalent, rebuilt on resize), and the zoom brush is a themed mini chart driven by ECharts' native `dataZoom` instead of the custom `EvilBrush`.
  


### Loading State

### isLoading='true'

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      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]
      stackType="stacked"
      curveType="bump"
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.YAxis dataKey="desktop" />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area dataKey="desktop" variant="gradient" isClickable>
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area dataKey="mobile" variant="gradient" isClickable>
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```
> 
  
    Pass `isLoading` to show an animated skeleton; use `loadingPoints` to set how many points it draws.
  


## Examples

Change `variant` and `strokeVariant` on an `<Area>`, or `curveType` and `stackType` on the chart, to restyle it.

### Hover Highlight

### enableHoverHighlight='true'

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      stackType="stacked"
      enableHoverHighlight // [!code highlight]
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.YAxis dataKey="desktop" />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area dataKey="desktop" variant="gradient" isClickable>
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area dataKey="mobile" variant="gradient" isClickable>
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```

### Buffer Line

### enableBufferLine='true'

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      xDataKey="month"
      stackType="stacked"
    >
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.Brush />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area
        dataKey="desktop"
        variant="gradient"
        strokeVariant="solid"
        enableBufferLine // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.Dot variant="border" />
        <EChartsAreaChart.ActiveDot variant="colored-border" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area
        dataKey="mobile"
        variant="gradient"
        strokeVariant="solid"
        enableBufferLine // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.Dot variant="border" />
        <EChartsAreaChart.ActiveDot variant="colored-border" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```
> 
  
    With `enableBufferLine`, each area's last segment renders as a dashed tail 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 { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      enableHoverReveal // [!code highlight]
      stackType="stacked"
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.Legend />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area dataKey="desktop" variant="gradient">
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area dataKey="mobile" variant="gradient">
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```
> 
  
    With `enableHoverReveal`, hovering colors each area's line and fill 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.
  


### Gradient Colors

### gradient colors

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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: ["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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      stackType="stacked"
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area dataKey="desktop" variant="gradient" isClickable>
        <EChartsAreaChart.Dot variant="colored-border" />
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area dataKey="mobile" variant="gradient" isClickable>
        <EChartsAreaChart.Dot variant="colored-border" />
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```
### gradient colors - bump

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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: ["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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      stackType="stacked"
      curveType="bump"
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area dataKey="desktop" variant="gradient" isClickable>
        <EChartsAreaChart.Dot variant="colored-border" />
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area dataKey="mobile" variant="gradient" isClickable>
        <EChartsAreaChart.Dot variant="colored-border" />
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```

### Curve Types

### curveType='bump'

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      stackType="stacked"
      curveType="bump" // [!code highlight]
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.YAxis dataKey="desktop" />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area dataKey="desktop" variant="gradient" isClickable>
        <EChartsAreaChart.Dot variant="default" />
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area dataKey="mobile" variant="gradient" isClickable>
        <EChartsAreaChart.Dot variant="default" />
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```
### curveType='step'

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      stackType="stacked"
      curveType="step" // [!code highlight]
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.YAxis dataKey="desktop" />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area dataKey="desktop" variant="gradient" isClickable>
        <EChartsAreaChart.Dot variant="default" />
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area dataKey="mobile" variant="gradient" isClickable>
        <EChartsAreaChart.Dot variant="default" />
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```
### curveType='monotoneY'

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      stackType="stacked"
      curveType="monotoneY" // [!code highlight]
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.YAxis dataKey="desktop" />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area dataKey="desktop" variant="gradient" isClickable>
        <EChartsAreaChart.Dot variant="default" />
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area dataKey="mobile" variant="gradient" isClickable>
        <EChartsAreaChart.Dot variant="default" />
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```

### Stack Types

### stackType='default'

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      stackType="default" // [!code highlight]
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.YAxis dataKey="desktop" />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area dataKey="desktop" variant="gradient" isClickable>
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area dataKey="mobile" variant="gradient" isClickable>
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```
### stackType='stacked'

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      stackType="stacked" // [!code highlight]
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.YAxis dataKey="desktop" />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area dataKey="desktop" variant="gradient" isClickable>
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area dataKey="mobile" variant="gradient" isClickable>
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```
### stackType='expanded'

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      stackType="expanded" // [!code highlight]
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.YAxis dataKey="desktop" />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area dataKey="desktop" variant="gradient" isClickable>
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area dataKey="mobile" variant="gradient" isClickable>
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```

### Stroke Variants

### strokeVariant='solid'

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      stackType="stacked"
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.YAxis dataKey="desktop" />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area
        dataKey="desktop"
        variant="gradient"
        strokeVariant="solid" // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area
        dataKey="mobile"
        variant="gradient"
        strokeVariant="solid" // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```
### strokeVariant='dashed'

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      stackType="stacked"
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.YAxis dataKey="desktop" />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area
        dataKey="desktop"
        variant="gradient"
        strokeVariant="dashed" // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area
        dataKey="mobile"
        variant="gradient"
        strokeVariant="dashed" // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

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

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      stackType="stacked"
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.YAxis dataKey="desktop" />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area
        dataKey="desktop"
        variant="gradient"
        strokeVariant="animated-dashed" // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area
        dataKey="mobile"
        variant="gradient"
        strokeVariant="animated-dashed" // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```

### Area Variants

### variant='gradient'

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      stackType="stacked"
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.YAxis dataKey="desktop" />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area
        dataKey="desktop"
        variant="gradient" // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area
        dataKey="mobile"
        variant="gradient" // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```
### variant='gradient-reverse'

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      stackType="stacked"
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.YAxis dataKey="desktop" />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area
        dataKey="desktop"
        variant="gradient-reverse" // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area
        dataKey="mobile"
        variant="gradient-reverse" // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```
### variant='solid'

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      stackType="stacked"
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.YAxis dataKey="desktop" />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area
        dataKey="desktop"
        variant="solid" // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area
        dataKey="mobile"
        variant="solid" // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```
### variant='dotted'

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      stackType="stacked"
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.YAxis dataKey="desktop" />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area
        dataKey="desktop"
        variant="dotted" // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area
        dataKey="mobile"
        variant="dotted" // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```
### variant='lines'

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      stackType="stacked"
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.YAxis dataKey="desktop" />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area
        dataKey="desktop"
        variant="lines" // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area
        dataKey="mobile"
        variant="lines" // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```
### variant='hatched'

```tsx
"use client";

import { EChartsAreaChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-area-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 EChartsExampleAreaChart() {
  return (
    <EChartsAreaChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      stackType="stacked"
    >
      <EChartsAreaChart.Grid />
      <EChartsAreaChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsAreaChart.YAxis dataKey="desktop" />
      <EChartsAreaChart.Legend isClickable />
      <EChartsAreaChart.Tooltip />
      <EChartsAreaChart.Area
        dataKey="desktop"
        variant="hatched" // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
      <EChartsAreaChart.Area
        dataKey="mobile"
        variant="hatched" // [!code highlight]
        isClickable
      >
        <EChartsAreaChart.ActiveDot variant="default" />
      </EChartsAreaChart.Area>
    </EChartsAreaChart>
  );
}

```

## API Reference

Props are grouped by the part they belong to. On canvas each part is declarative config the root compiles, but the API mirrors the Recharts twin one-to-one.

<ApiHeading>EChartsAreaChart</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[]`

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 a 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 `<Area />`.
  ### `className`

type: `string`

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

type: `keyof TData & string`

Data key for the x-axis categories.
  ### `curveType`

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

Default curve interpolation inherited by every `<Area />`.
  ### `animation`

type: `boolean` · default: `true`

Master switch for the intro draw-in. Pass `false` to render 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 `<Area />`. Any value but `"none"` plays ECharts' native progressive draw-in — the line traces in and dots pop as its front passes (direction values exist for parity with the Recharts twin). `"none"` disables it; OS reduce-motion falls back to `"none"` automatically.
  ### `enableHoverHighlight`

type: `boolean` · default: `false`

Highlights the hovered series by dimming the others — the hover twin of click selection, using the same dim levels.
  ### `enableHoverReveal`

type: `boolean` · default: `false`

On hover, colors each area 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.
  ### `stackType`

type: `"default" | "stacked" | "expanded"` · default: `"default"`

How multiple areas combine — independent, stacked, or normalized to 100%.
  ### `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 `<Area />` or `<Legend />`.
  ### `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 deep-merged into the underlying ECharts option object. See the [ECharts option documentation](https://echarts.apache.org/en/option.html).


<ApiHeading>Area</ApiHeading>

A single area series. Each `<Area />` carries its own fill and stroke config, so a chart can hold any number — each with its own variant, stroke, and clickability.


  ### `dataKey` (required)

type: `string`

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

type: `"gradient" | "gradient-reverse" | "solid" | "dotted" | "lines" | "hatched"` · default: `"gradient"`

Visual style of the area fill, for this area only. Multi-color configs render the full horizontal gradient faded vertically, matching the Recharts twin.
  ### `strokeVariant`

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

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

type: `number` · default: `0.8`

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

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

The curve interpolation for this area. 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 area (the first `<Area />`'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`

Makes this area selectable on click. When any area is selected, the rest turn semi-transparent.
  ### `enableBufferLine`

type: `boolean` · default: `false`

Renders this area's last segment as a dashed buffer tail 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 area.


<ApiHeading>Dot and ActiveDot</ApiHeading>

Point markers composed inside an `<Area />`. `<Dot />` is the resting marker; `<ActiveDot />` is the hovered marker. They render nothing on their own — the parent `<Area />` 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 />` or `<YAxis />` to show each; omit either to hide it. Both hide automatically while loading, and `<YAxis />` formats ticks as percentages when `stackType="expanded"`.


  ### `dataKey`

type: `string`

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



string">
    Formats the axis tick labels.
  ### `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 draw the dashed horizontal split lines; omit it and they don't. Takes no props.

<ApiHeading>Tooltip</ApiHeading>

The hover tooltip. Include it to enable the tooltip; omit it and none shows. It reads 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.


<ApiHeading>Legend</ApiHeading>

The series legend, rendered as HTML above the canvas. Include it to show the legend; omit it and none shows. When `isClickable` is set, 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 `<EChartsAreaChart.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.

