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

### Basic Chart

```tsx
"use client";

import { EChartsBarChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-bar-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 EChartsExampleBarChart() {
  return (
    <EChartsBarChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      xDataKey="month"
    >
      <EChartsBarChart.Grid />
      <EChartsBarChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsBarChart.Brush formatLabel={(value) => String(value).substring(0, 3)} />
      <EChartsBarChart.Legend isClickable />
      <EChartsBarChart.Tooltip />
      <EChartsBarChart.Bar dataKey="desktop" variant="default" isClickable />
      <EChartsBarChart.Bar dataKey="mobile" variant="default" isClickable />
    </EChartsBarChart>
  );
}

```

## Installation


  
  
    ### npm

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

### yarn

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

### bun

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

### pnpm

```bash
pnpm dlx shadcn@latest add @evilcharts/echarts-bar-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 the following code into your project.
        
          In your `components` directory, create the nested folders `evilcharts` → `charts`, then paste the bar-chart code into a new `echarts-bar-chart.tsx` file inside.
        
        
          ### components/evilcharts/charts/echarts-bar-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 {
  Children,
  isValidElement,
  useCallback,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type FC,
  type ReactNode,
} from "react";
import {
  buildChartCss,
  flattenColor,
  getColorsCount,
  resolveColors,
  withAlpha,
  type ChartConfig,
  type ResolvedColors,
} from "@/components/evilcharts/ui/echarts-chart";
import { LegendOverlay, type LegendVariant } from "@/components/evilcharts/ui/echarts-legend";
import type { ComposeOption, ImagePatternObject } from "echarts/core";
import { BarChart, type BarSeriesOption } from "echarts/charts";
import { sampleGradient } from "@/components/evilcharts/ui/echarts-dot";
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, LegendVariant, TooltipPosition, TooltipRoundness, TooltipVariant };

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

type EChartsInstance = ReturnType<typeof echarts.init>;

// The exact option surface this chart uses — bar 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<
  BarSeriesOption | 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 DEFAULT_BAR_RADIUS = 2;
const STROKE_WIDTH = 1; // buffer-bar outline width
const LOADING_ANIMATION_DURATION = 2000; // shimmer loop, in milliseconds
const BAR_GROW_DURATION = 500; // per-bar grow-in length, in milliseconds
const BAR_STAGGER = 50; // delay between consecutive bars in the reveal, in milliseconds
const LOADING_DEFAULT_BARS = 12;
// `revealEndsAt` marks when the intro grow-in finishes. The stripped-cap post-layout
// correction (a notMerge repush) waits for it so it never lands mid-entrance and
// stomps the grow — the same reason the area chart tracks this timestamp.
const SELECTION_DIM = 0.3; // opacity of an unselected series while a selection is active
const HOVER_BLUR = 0.3; // opacity of the non-hovered bars while hover-highlight is on
// Soft outer glow — the canvas analogue of the Recharts feGaussianBlur filter
// (stdDeviation 8, alpha 0.5). A generous shadowBlur keeps the halo soft with no
// hard rim; the shadowColor is sampled PER BAR so a multi-stop gradient series
// glows in its own colors across the plot instead of one flat tint.
const GLOW_BLUR = 18; // shadowBlur radius, in device-independent pixels
const GLOW_OPACITY = 0.65; // per-datum shadowColor alpha, × the sampled series color

// The `blocks` variant renders each bar as a stack of segments instead of a solid
// column: a repeating tile paints a BLOCK_SIZE band then leaves a BLOCK_GAP of
// transparency. The same tile, in a muted tone, fills the column's unused space
// via ECharts' native `showBackground`, so the empty part of every bar reads as a
// dim grid of the same blocks. Both tile from the renderer origin, so the bands
// line up across every column.
// The `expandable` variant draws every bar at full width but fills only a narrow
// centre strip, so it reads as a thin line; hovering one grows its strip out to
// the full width and back on leave. The bar geometry never changes — only the
// horizontal extent of its fill — so nothing re-lays out mid-hover.
const EXPAND_COLLAPSED = 0.12; // resting strip width, as a fraction of the bar
const EXPAND_TAU = 70; // ease time-constant, in milliseconds (exponential approach)

const BLOCK_SIZE = 8; // filled segment height, in pixels
const BLOCK_GAP = 4; // transparent gap between segments, in pixels
const BLOCK_TRACK_OPACITY = 0.22; // unfilled block tone, x the muted-foreground alpha
// Stacked segments would otherwise butt straight into each other and read as one
// solid column. The separation is a REAL gap — transparent spacer series stacked
// between the real ones — not a background-colored border: a border paints on all
// four sides, so it outlines each segment (obvious the moment a bar glows) instead
// of only parting them.
const STACK_SEGMENT_GAP = 4; // separation between stacked segments, in pixels
const MAX_HIGHLIGHT_DIM = 0.16; // non-winning columns under enableMaxValueHighlight, x muted-foreground

// The `stripped` variant caps each bar with a small BRIGHT pill of CONSTANT pixel
// height (Recharts draws a fixed ~2px strip on top of a dimmed body, identical on
// tall and short bars). The cap is expressed PER DATUM as a fraction of that bar's
// own pixel height, so a fixed pixel height maps to a shrinking fraction as the bar
// grows — the fraction is derived at runtime from the measured value-axis
// pixels-per-unit (see measureValuePxPerUnit). A canvas gradient alone can't do
// this: its bright band is a fraction of the bounding box, so it would scale with
// bar length (the bug this replaced).
const STRIPPED_CAP_HEIGHT = 4; // bright cap height, in device-independent pixels
const STRIPPED_BODY_ALPHA = 0.2; // dimmed bar body below the cap, × series color
const STRIPPED_CAP_MAX_FRACTION = 0.85; // cap never swallows a whole (very short) bar
const STRIPPED_FALLBACK_FRACTION = 0.12; // used before the axis geometry is measured

// ─────────────────────────────────────────────────────────────────────────────
// 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 value-axis split lines, × border alpha
// The skeleton is CLIPPED to a small sweeping window — only the bars inside it
// exist, everything outside is fully transparent, like a spotlight sliding across.
const LOADING_SHIMMER_MAX_OPACITY = 0.22; // gray bar fill inside the window, × foreground alpha
const LOADING_SHIMMER_BAND = 0.2; // window half-width, fraction of the 45° sweep axis
const LOADING_SHIMMER_FEATHER = 0.2; // eased edge softening of the clip window
const BRUSH_FILL_OPACITY = 0.5; // mini-chart bar fill
const BRUSH_FILLER_OPACITY = 0; // selected-range wash — evil-brush draws none

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

export type BarVariant =
  | "default"
  | "hatched"
  | "duotone"
  | "duotone-reverse"
  | "gradient"
  | "stripped"
  | "blocks"
  | "expandable";
export type StackType = "default" | "stacked" | "percent";
export type BarLayout = "vertical" | "horizontal";
export type BarAnimationType =
  | "none"
  | "left-to-right"
  | "right-to-left"
  | "center-out"
  | "edges-in";
// 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 EChartsBarChartProps<TData extends Record<string, unknown>> {
  data: TData[]; // rows rendered by the chart
  config: ChartConfig; // series colors + labels
  xDataKey?: keyof TData & string; // category key — falls back to the axis dataKey / first free column
  className?: string; // extra classes for the chart container
  stackType?: StackType; // how multiple bars combine
  layout?: BarLayout; // orientation of the bars
  barRadius?: number; // default corner radius every <Bar> inherits
  animation?: boolean; // master switch for the intro grow-in — false renders instantly
  animationType?: BarAnimationType; // default grow-in order each <Bar> inherits
  barGap?: number; // gap between bars within the same category, in pixels
  barCategoryGap?: number; // gap between categories of bars, in pixels
  defaultSelectedDataKey?: string | null; // series selected on first render
  onSelectionChange?: (key: string | null) => void; // fires when the selected series changes
  // Colors ONLY the tallest column and mutes the rest. With several series the
  // comparison is per COLUMN — the totals across every series at that category —
  // so a whole stack or group lights up together, not one bar inside it.
  enableMaxValueHighlight?: boolean;
  isLoading?: boolean; // shows the animated loading skeleton
  loadingBars?: number; // number of bars in the loading skeleton
  chartOptions?: Record<string, unknown>; // escape hatch merged over the built ECharts option
  children?: ReactNode; // declarative config — <Bar>, <XAxis>, <YAxis>, <Grid>, <Tooltip>, <Legend>, <Brush>
}

// ─────────────────────────────────────────────────────────────────────────────
// Composible parts — DECLARATIVE CONFIG. Every part renders `null`; the root
// walks `children` by reference (child.type === Bar, …) 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 BarProps {
  dataKey: string; // series key — must exist on the data + config
  variant?: BarVariant; // fill style for this bar only
  radius?: number; // corner radius — falls back to the root barRadius
  animationType?: BarAnimationType; // grow-in order — falls back to the root animationType
  isClickable?: boolean; // lets this bar be selected by clicking it
  enableHoverHighlight?: boolean; // dims the other bars while one is hovered
  glowing?: boolean; // applies a soft outer glow to this bar
  bufferBar?: boolean; // renders the last data point as a hatched "buffer" bar
}

/**
 * A single bar series. Declares its own fill variant, radius, glow, buffer, and
 * clickability. Renders nothing — the root reads these props to build the
 * ECharts series.
 */
const Bar: FC<BarProps> = () => null;

export interface XAxisProps {
  dataKey?: string; // category key — overrides the root xDataKey (vertical layout)
  // Category values are stringified, so the formatter always 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 x-position tick labels
  hideDots?: boolean; // hides the tick dots beside this axis's labels
}

/**
 * The x-axis. Category axis in the default (vertical) layout, value axis when
 * `layout="horizontal"`. Presence shows its tick labels. Renders nothing.
 */
const XAxis: FC<XAxisProps> = () => null;

export interface YAxisProps {
  dataKey?: string; // category key — overrides the root xDataKey (horizontal layout)
  tickFormatter?: (value: string, index: number) => string; // formats y tick labels
  label?: string; // axis title, rotated alongside the y-position tick labels
  hideDots?: boolean; // hides the tick dots beside this axis's labels
}

/**
 * The y-axis. Value axis in the default (vertical) layout, category axis when
 * `layout="horizontal"`. Presence shows its tick labels. Renders nothing.
 */
const YAxis: FC<YAxisProps> = () => null;

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

export interface TooltipProps {
  variant?: TooltipVariant; // visual style of the tooltip surface
  roundness?: TooltipRoundness; // border-radius of the tooltip
  defaultIndex?: number; // data index the tooltip shows by default, with no hover
  position?: TooltipPosition; // "variable" follows the pointer (default); "fixed" pins the tooltip near the top and only 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.
// ─────────────────────────────────────────────────────────────────────────────

type BarSeriesConfig = {
  dataKey: string;
  variant: BarVariant;
  radius?: number;
  animationType?: BarAnimationType;
  isClickable: boolean;
  enableHoverHighlight: boolean;
  glowing: boolean;
  bufferBar: boolean;
};

type AxisSlot = {
  present: boolean;
  dataKey?: string;
  tickFormatter?: (value: string, index: number) => string;
  label?: string;
  hideDots: boolean;
};
type TooltipSlot = {
  present: boolean;
  variant: TooltipVariant;
  roundness: TooltipRoundness;
  defaultIndex?: number;
  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 = {
  bars: BarSeriesConfig[];
  xAxis: AxisSlot;
  yAxis: AxisSlot;
  showGrid: boolean;
  tooltip: TooltipSlot;
  legend: LegendSlot;
  brush: BrushSlot;
};

function collectConfig(children: ReactNode): CollectedConfig {
  const bars: BarSeriesConfig[] = [];
  let xAxis: AxisSlot = { present: false, hideDots: false };
  let yAxis: AxisSlot = { present: false, hideDots: false };
  let showGrid = false;
  let tooltip: TooltipSlot = {
    present: false,
    variant: "default",
    roundness: "lg",
    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 === Bar) {
      const props = child.props as BarProps;
      bars.push({
        dataKey: props.dataKey,
        variant: props.variant ?? "default",
        radius: props.radius,
        animationType: props.animationType,
        isClickable: props.isClickable ?? false,
        enableHoverHighlight: props.enableHoverHighlight ?? false,
        glowing: props.glowing ?? false,
        bufferBar: props.bufferBar ?? false,
      });
    } 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",
        defaultIndex: props.defaultIndex,
        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 { bars, xAxis, yAxis, showGrid, tooltip, legend, brush };
}

// Color plumbing (ChartConfig, getColorsCount, distributeColors, buildChartCss,
// normalizeColor, withAlpha, ResolvedColors, resolveColors, flattenColor) plus
// the theme keys now live in @/components/evilcharts/ui/echarts-chart and are imported at the
// top of this file.

// ─────────────────────────────────────────────────────────────────────────────
// Fill paints — the ECharts analogue of the Recharts bar fill variants. Unlike
// the area chart's fills (which run the color gradient HORIZONTALLY), a bar's
// base color gradient runs VERTICALLY top→bottom (Recharts `ColorGradient` uses
// x1=x2=0), so each bar shows the full multi-stop gradient in its own box.
// ─────────────────────────────────────────────────────────────────────────────

const GRAY = "rgba(120, 120, 120, 1)";

// sampleGradient (the color the series gradient shows at t ∈ [0, 1]) now lives in
// @/components/evilcharts/ui/echarts-dot and is imported at the top of this file.

// Solid vertical top→bottom color for a series — a plain string when there is
// one color, else a vertical multi-stop LinearGradient in each bar's own box.
// The `default` variant paints from this at full alpha.
function solidVerticalPaint(
  slots: string[],
  alpha: number,
): string | echarts.graphic.LinearGradient {
  if (slots.length <= 1) {
    const base = slots[0] ?? GRAY;
    return alpha === 1 ? base : withAlpha(base, alpha);
  }
  const stops = slots.map((color, i) => ({
    offset: i / (slots.length - 1),
    color: withAlpha(color, alpha),
  }));
  return new echarts.graphic.LinearGradient(0, 0, 0, 1, stops);
}

// The `gradient` variant: the vertical color gradient faded from solid at the
// top to clear at the bottom. Recharts masks with white@1 at 20% → white@0 at
// 90%, so the alpha holds full through the top fifth and vanishes by 90%.
function verticalFadePaint(slots: string[]): echarts.graphic.LinearGradient {
  const offsets = [0, 0.2, 0.45, 0.7, 0.9, 1];
  const alphaAt = (t: number) => (t <= 0.2 ? 1 : t >= 0.9 ? 0 : 1 - (t - 0.2) / 0.7);
  const stops = offsets.map((t) => ({
    offset: t,
    color: withAlpha(sampleGradient(slots, t), alphaAt(t)),
  }));
  return new echarts.graphic.LinearGradient(0, 0, 0, 1, stops);
}

// The `duotone` family: a hard alpha split across the bar's short axis (its width
// for vertical bars, its height for horizontal). Recharts splits at 50% via an
// objectBoundingBox mask — exact for single-color series; multi-color duotone
// falls back to the base color (an accepted approximation, matching the twin's
// single-color examples).
function duotoneSplitPaint(
  base: string,
  leftAlpha: number,
  rightAlpha: number,
  isHorizontal: boolean,
): echarts.graphic.LinearGradient {
  const stops = [
    { offset: 0, color: withAlpha(base, leftAlpha) },
    { offset: 0.5, color: withAlpha(base, leftAlpha) },
    { offset: 0.5, color: withAlpha(base, rightAlpha) },
    { offset: 1, color: withAlpha(base, rightAlpha) },
  ];
  // Split across the cross-axis: horizontal (0→1 in x) for vertical bars, and
  // vertical (0→1 in y) for horizontal bars, so it always reads across the bar.
  return isHorizontal
    ? new echarts.graphic.LinearGradient(0, 0, 0, 1, stops)
    : new echarts.graphic.LinearGradient(1, 0, 0, 0, stops);
}

// The `stripped` variant: a small BRIGHT cap sitting on top of a dimmed (20%) body
// — the canvas twin of Recharts' fixed strip. The cap is baked into a per-datum
// vertical gradient whose bright band spans exactly `capFraction` of the bar
// (offset 0 = the tip). Because `capFraction` is passed in as
// STRIPPED_CAP_HEIGHT / barPixelHeight (see strippedCapFraction), the cap reads the
// SAME pixel height on every bar — the fraction shrinks as the bar grows. A hard
// two-stop edge (coincident offsets at `capFraction`) keeps the cap a crisp pill
// rather than a fade, and the bar's rounded top corners round the cap's top. The
// cap sits at the tip: the top for vertical bars, the value end (right) for
// horizontal.
function strippedDatumPaint(
  slots: string[],
  isHorizontal: boolean,
  capFraction: number,
): echarts.graphic.LinearGradient {
  const f = Math.min(Math.max(capFraction, 0), 1);
  const cap = withAlpha(sampleGradient(slots, 0), 1);
  const bodyTop = withAlpha(sampleGradient(slots, f), STRIPPED_BODY_ALPHA);
  const bodyEnd = withAlpha(sampleGradient(slots, 1), STRIPPED_BODY_ALPHA);
  const stops = [
    { offset: 0, color: cap },
    { offset: f, color: cap },
    { offset: f, color: bodyTop },
    { offset: 1, color: bodyEnd },
  ];
  // Tip at offset 0: top (y 0→1) for vertical bars, right (x 1→0) for horizontal.
  return isHorizontal
    ? new echarts.graphic.LinearGradient(1, 0, 0, 0, stops)
    : new echarts.graphic.LinearGradient(0, 0, 0, 1, stops);
}

// The gradient fraction that renders a STRIPPED_CAP_HEIGHT-pixel cap on a bar whose
// value-axis magnitude is `value`, given the measured pixels-per-unit. Falls back to
// a small constant before the coordinate system has been measured (the very first
// paint, corrected right after layout).
function strippedCapFraction(value: number, valuePxPerUnit: number | null): number {
  if (valuePxPerUnit == null) return STRIPPED_FALLBACK_FRACTION;
  const barPx = Math.abs(value) * valuePxPerUnit;
  if (!(barPx > 0)) return STRIPPED_FALLBACK_FRACTION;
  return Math.min(STRIPPED_CAP_HEIGHT / barPx, STRIPPED_CAP_MAX_FRACTION);
}

// Pixels per one value-axis unit, read straight off the live coordinate system.
// Returns null before the first layout (no coordinate system yet) — callers fall
// back then. Turns the stripped cap's fixed pixel height into a per-bar gradient
// fraction, so the cap stays constant as the value axis rescales on resize/zoom.
function measureValuePxPerUnit(chart: EChartsInstance, isHorizontal: boolean): number | null {
  const finder = isHorizontal ? { xAxisIndex: 0 } : { yAxisIndex: 0 };
  // convertToPixel throws before the first setOption (no coordinate system yet) and
  // whenever the value axis isn't laid out — treat any failure as "not measurable".
  try {
    const p0 = chart.convertToPixel(finder, 0);
    const p1 = chart.convertToPixel(finder, 1);
    if (typeof p0 !== "number" || typeof p1 !== "number") return null;
    const delta = Math.abs(p1 - p0);
    return Number.isFinite(delta) && delta > 0 ? delta : null;
  } catch {
    return null;
  }
}

// Measures the rendered width of one bar, so the `blocks` variant can make its
// segments square (their width IS the bar width, and only layout knows it). The
// category pitch comes from the axis; the bar occupies that minus the category
// gap — a px number when the consumer set one, else ECharts' own "20%" default.
function measureBarWidthPx(
  chart: EChartsInstance,
  isHorizontal: boolean,
  barCategoryGap: number | undefined,
): number | null {
  const finder = isHorizontal ? { yAxisIndex: 0 } : { xAxisIndex: 0 };
  try {
    const p0 = chart.convertToPixel(finder, 0);
    const p1 = chart.convertToPixel(finder, 1);
    if (typeof p0 !== "number" || typeof p1 !== "number") return null;
    const pitch = Math.abs(p1 - p0);
    if (!Number.isFinite(pitch) || pitch <= 0) return null;
    const width = barCategoryGap != null ? pitch - barCategoryGap : pitch * 0.8;
    return width > 1 ? width : null;
  } catch {
    return null;
  }
}

// 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: "hatched" | "buffer" | "blocks",
  color: string,
  blockSize = BLOCK_SIZE,
): 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 === "blocks") {
    // 1px-wide tile: it repeats horizontally into a full-width band, and
    // vertically into the stack of blocks.
    size(1, blockSize + BLOCK_GAP);
    ctx.fillStyle = withAlpha(color, 1);
    ctx.fillRect(0, 0, 1, blockSize);
    return pattern();
  }

  if (kind === "hatched") {
    // Recharts hatched: the color shown at 0.3 everywhere, punched to full along
    // a 1.5px stripe every 5px, leaning -45°.
    size(5, 5);
    ctx.fillStyle = withAlpha(color, 0.3);
    ctx.fillRect(0, 0, 5, 5);
    ctx.fillStyle = withAlpha(color, 1);
    ctx.fillRect(0, 0, 1.5, 5);
    return pattern(-Math.PI / 4);
  }

  // buffer: bare diagonal lines on a transparent ground (no body fill), for the
  // last "projected" bar.
  size(5, 5);
  ctx.fillStyle = withAlpha(color, 1);
  ctx.fillRect(0, 0, 1, 5);
  return pattern(-Math.PI / 4);
}

// The `expandable` fill at a given openness: a horizontal gradient with HARD
// stops, transparent outside the centre strip and the series paint inside it.
// Animating `fraction` slides those stops outward from the middle, which is the
// expand; a real width change would relayout the bar group instead.
function expandableDatumPaint(slots: string[], fraction: number): echarts.graphic.LinearGradient {
  const base = slots[0] ?? GRAY;
  const half = Math.max(0, Math.min(1, fraction)) / 2;
  const left = 0.5 - half;
  const right = 0.5 + half;
  const clear = withAlpha(base, 0);
  return new echarts.graphic.LinearGradient(0, 0, 1, 0, [
    { offset: 0, color: clear },
    { offset: left, color: clear },
    { offset: left, color: base },
    { offset: right, color: base },
    { offset: right, color: clear },
    { offset: 1, color: clear },
  ]);
}

// Resolves a bar variant into an ECharts fill for its series. `base` is the
// first color slot; `slots` the full vertical color run.
function barFillPaint(
  variant: BarVariant,
  slots: string[],
  isHorizontal: boolean,
  blockSize = BLOCK_SIZE,
): string | echarts.graphic.LinearGradient | ImagePatternObject {
  const base = slots[0] ?? GRAY;
  switch (variant) {
    case "gradient":
      return verticalFadePaint(slots);
    case "duotone":
      return duotoneSplitPaint(base, 0.4, 1, isHorizontal);
    case "duotone-reverse":
      return duotoneSplitPaint(base, 1, 0.4, isHorizontal);
    case "hatched":
      return patternFill("hatched", base) ?? solidVerticalPaint(slots, 1);
    case "blocks":
      return patternFill("blocks", base, blockSize) ?? solidVerticalPaint(slots, 1);
    case "expandable":
      // Series-level fallback only; buildBarSeries gives every datum its own
      // openness so a single hovered bar can expand on its own.
      return expandableDatumPaint(slots, EXPAND_COLLAPSED);
    case "stripped":
      // Series-level fallback only; buildBarSeries overrides every stripped datum
      // with its own fixed-pixel cap fraction.
      return strippedDatumPaint(slots, isHorizontal, STRIPPED_FALLBACK_FRACTION);
    default:
      return solidVerticalPaint(slots, 1);
  }
}

// Border radius per variant/layout. Non-stripped bars round every corner
// (Recharts passes a plain number); stripped rounds only the tip corners — the
// top for vertical bars, the right end for horizontal.
function barBorderRadius(
  radius: number,
  variant: BarVariant,
  isHorizontal: boolean,
): number | number[] {
  // Blocks draw their own square segments; a radius would clip the end one. An
  // expandable bar is a thin line at rest, where a radius would swallow it.
  if (variant === "blocks" || variant === "expandable") return 0;
  if (variant !== "stripped") return radius;
  // ECharts corner order: [top-left, top-right, bottom-right, bottom-left].
  return isHorizontal ? [0, radius, radius, 0] : [radius, radius, 0, 0];
}

// ─────────────────────────────────────────────────────────────────────────────
// Selection + entrance helpers
// ─────────────────────────────────────────────────────────────────────────────

// A bar dims to SELECTION_DIM only when a DIFFERENT series is selected.
function selectionOpacity(selected: string | null, key: string): number {
  return selected === null || selected === key ? 1 : SELECTION_DIM;
}

// How many stagger steps a bar at `index` waits before it grows in — the order
// encoded by `animationType`. Bars are independent rectangles, so unlike the
// area chart's single left-to-right clip, the direction values are honored here
// via a per-datum `animationDelay`.
function barStaggerDelay(type: BarAnimationType, index: number, count: number): number {
  if (type === "none" || count <= 0) return 0;
  const last = count - 1;
  const center = last / 2;
  let step: number;
  switch (type) {
    case "right-to-left":
      step = last - index;
      break;
    case "center-out":
      step = Math.abs(index - center);
      break;
    case "edges-in":
      step = center - Math.abs(index - center);
      break;
    default: // left-to-right
      step = index;
  }
  return step * BAR_STAGGER;
}

// The brush overlay primitives (BrushRange, BrushGeometry, BrushOverlayElements,
// syncBrushOverlay) and the dataZoom builder (buildBrushDataZoom) now live in
// @/components/evilcharts/ui/echarts-brush and are imported at the top of this file. The
// tooltip shell/row/styling (roundnessClass, tooltipVariantClass,
// tooltipShell/tooltipRow/tooltipIndicatorHtml), the legend indicators
// (LegendIndicator/LegendOverlay + fill/outline styles), and indicatorBackground
// likewise live in the shared tooltip/legend/chart modules.

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

type OptionBuildContext = {
  data: Record<string, unknown>[];
  config: ChartConfig;
  bars: BarSeriesConfig[];
  seriesKeys: string[];
  animationType: BarAnimationType;
  barRadius: number;
  isHorizontal: boolean;
  isStacked: boolean;
  isPercent: boolean;
  selectedDataKey: string | null;
  hasSelection: boolean;
  showGrid: boolean;
  // Category axis is x (vertical layout) or y (horizontal); value axis the other.
  categorySlot: AxisSlot;
  valueSlot: AxisSlot;
  tooltipSlot: TooltipSlot;
  legendSlot: LegendSlot;
  isLoading: boolean;
  loadingData: () => number[];
  showBrush: boolean;
  brushHeight: number;
  barGap?: number;
  barCategoryGap?: number;
  resolved: ResolvedColors;
  categories: string[];
  brushRange: BrushRange; // zoom window carried through rebuilds
  valuePxPerUnit: number | null; // measured value-axis pixels-per-unit (null pre-layout)
  barWidthPx: number | null; // measured bar width — sizes the blocks variant's squares (null pre-layout)
  // Openness per bar index for the expandable variant, plus which one the pointer
  // is on — driven by the hover rAF, read at build.
  expand: { key: string | null; hovered: number | null; progress: Map<number, number> };
  maxHighlightIndex: number | null; // column to keep colored under enableMaxValueHighlight
};

// 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,
  showBrush,
  brushHeight,
  isHorizontal,
  categorySlot,
  valueSlot,
}: OptionBuildContext): {
  grid: GridComponentOption;
  brushBottom: number;
} {
  const legendTop = legendSlot.present && legendSlot.verticalAlign === "top";
  const legendBottom = legendSlot.present && legendSlot.verticalAlign === "bottom";
  // Clearance covers the axis labels plus the same breathing room the Recharts
  // twin leaves between them and the brush. A bottom-axis TITLE renders below the
  // labels (nameGap), so it needs its own band above the brush frame. The brush
  // is vertical-layout only, where the bottom (x-position) axis is the category axis.
  const bottomAxisLabel = isHorizontal ? valueSlot.label : categorySlot.label;
  const brushGap = showBrush ? brushHeight + 30 + (bottomAxisLabel ? 22 : 0) : 0;

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

// The category + value axes, laid onto x/y per layout. Vertical bars → x is
// category, y is value; horizontal bars → x is value, y is category.
function buildMainAxes(ctx: OptionBuildContext): { xAxis: XAxisOption; yAxis: YAxisOption } {
  const {
    isHorizontal,
    showGrid,
    isLoading,
    isPercent,
    categories,
    loadingData,
    categorySlot,
    valueSlot,
  } = 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 catData = isLoading ? loadingData().map((_, i) => i) : categories;
  const catFormatter = categorySlot.tickFormatter;
  const valFormatter = valueSlot.tickFormatter;

  // The axis title (name) follows the axis PART, not its category/value role: the
  // category axis wears whichever <XAxis>/<YAxis> child renders it per layout, and
  // its label sits at that child's physical position. nameGap is 30 for the bottom
  // (x-position) axis and 38 for the side (y-position) one, so it swaps with the
  // layout alongside the axisLabel styling.
  const categoryNameGap = isHorizontal ? 38 : 30;
  const valueNameGap = isHorizontal ? 30 : 38;

  // NOTE: these are left un-annotated so their inferred literal type stays free
  // of an axis-specific `position` — the layout swap below assigns the category
  // axis to y (and the value axis to x) for horizontal bars, and XAxisOption vs
  // YAxisOption disagree on `position`, so a fixed annotation would reject one
  // branch. `type` is pinned with `as const` to satisfy the axis-kind union.
  const categoryAxis = {
    type: "category" as const,
    // Bars sit BETWEEN ticks — the opposite of the area chart's boundaryGap:false.
    boundaryGap: true,
    show: true,
    // The Recharts YAxis lists its first category at the TOP; ECharts' y category
    // axis defaults to bottom-up, so flip it when the category axis is on y.
    inverse: isHorizontal,
    data: catData,
    // Axis title — same size/color as the tick labels, pushed clear of them. The
    // category axis carries the label of whichever <XAxis>/<YAxis> child renders it.
    name: isLoading ? undefined : categorySlot.label,
    nameLocation: "middle" as const,
    nameGap: categoryNameGap,
    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 && categorySlot.present && !categorySlot.hideDots,
      length: 0.5,
      // Bars use boundaryGap, so ECharts would drop each tick on the BOUNDARY
      // between two categories — a dot floating between labels rather than under
      // one. Align them to the labels instead.
      alignWithLabel: true,
      lineStyle: { color: tickDotColor, width: 3, cap: "round" as const },
    },
    splitLine: { show: false },
    axisLabel: {
      show: !isLoading && categorySlot.present,
      color: axisLabelColor,
      fontSize: 10,
      margin: 8,
      formatter: catFormatter
        ? (value: string, index: number) => catFormatter(value, index)
        : undefined,
    },
  };

  // An ECharts axis with `show: false` hides its splitLines too, but Recharts'
  // <CartesianGrid> draws with or without a visible value axis. Keep the axis on
  // whenever <Grid/> is present and gate the LABELS on the slot instead.
  const valueAxis = {
    type: "value" as const,
    show: valueSlot.present || showGrid,
    max: isPercent ? 1 : undefined,
    // Axis title — same styling as the category axis; the value axis carries the
    // label of the other of the two <XAxis>/<YAxis> children.
    name: isLoading ? undefined : valueSlot.label,
    nameLocation: "middle" as const,
    nameGap: valueNameGap,
    nameTextStyle: { color: axisLabelColor, fontSize: 10 },
    axisLine: { show: false },
    // Same tick dots as the category axis, beside each value label.
    axisTick: {
      show: valueSlot.present && !isLoading && !valueSlot.hideDots,
      length: 0.5,
      // Inert here — ECharts only honors it for CATEGORY ticks, and this axis is
      // always type:"value". Carried so both axes' tick config stays identical.
      lineStyle: { color: tickDotColor, width: 3, cap: "round" as const },
    },
    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
      // axes unmount during loading too.
      show: valueSlot.present && !isLoading,
      color: axisLabelColor,
      fontSize: 10,
      margin: 8,
      formatter: isPercent
        ? (value: number) => `${Math.round(value * 100)}%`
        : valFormatter
          ? (value: number, index: number) => valFormatter(String(value), index)
          : undefined,
    },
  };

  return isHorizontal
    ? { xAxis: valueAxis, yAxis: categoryAxis }
    : { xAxis: categoryAxis, yAxis: valueAxis };
}

// Tooltip HTML builder, closed over the build context. Dims by the click
// selection only — the Recharts twin passes `cursor={false}`, so there is no
// axis-pointer line and hover-highlight never touches the tooltip.
function createTooltipFormatter(ctx: OptionBuildContext) {
  const { config, selectedDataKey, tooltipSlot } = 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);

    const body = rows
      .map((param) => {
        const p = param as {
          seriesId?: string;
          seriesName?: string;
          value?: number | string;
        };
        // Internal series (the brush's mini chart, the loading skeleton) never
        // surface in the tooltip.
        if (String(p.seriesId ?? "").startsWith("__")) return "";
        const key = p.seriesId ?? p.seriesName ?? "";
        const item = config[key];
        const colorsCount = item ? getColorsCount(item) : 1;
        const labelText = typeof item?.label === "string" ? item.label : (p.seriesName ?? key);
        const dimmed = selectedDataKey != null && selectedDataKey !== 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,
      // The twin disables the cursor (`cursor={false}`) — no shadow, no line —
      // so the axisPointer color/width below go unused; only `position` applies.
      cursor: false,
      tokens,
      position: tooltipSlot.position,
      axisPointerColor: tokens.border,
      strokeWidth: STROKE_WIDTH,
    }),
    formatter: createTooltipFormatter(ctx),
  };
}

// ── Brush — the evil-brush "bar" 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 for the vertical layout, where the category axis is x.
function buildBrushOption(
  ctx: OptionBuildContext,
  brushBottom: number,
): {
  miniGrid: GridComponentOption;
  miniXAxis: XAxisOption;
  miniYAxis: YAxisOption;
  miniSeries: BarSeriesOption[];
  dataZoom: DataZoomComponentOption[];
} {
  const { data, bars, isStacked, selectedDataKey, hasSelection, 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: true,
    show: false,
    data: categories,
    axisPointer: { show: false },
  };

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

  const miniSeries: BarSeriesOption[] = bars.map((bar) => {
    const key = bar.dataKey;
    const base = (ctx.resolved.series[key] ?? [])[0] ?? GRAY;
    // The mini chart mirrors the click selection: unselected series recede.
    const dim = hasSelection && selectedDataKey !== key ? SELECTION_DIM : 1;

    return {
      id: `__mini-${key}`,
      type: "bar",
      xAxisIndex: 1,
      yAxisIndex: 1,
      data: data.map((row) => Number(row[key]) || 0),
      stack: isStacked ? "__mini-total" : undefined,
      silent: true,
      barCategoryGap: "20%",
      emphasis: { disabled: true },
      tooltip: { show: false },
      itemStyle: { color: base, opacity: BRUSH_FILL_OPACITY * dim, borderRadius: 1 },
      z: 0,
      animation: false,
    };
  });

  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 row of bars regardless of declared series (Recharts
// parity: its skeleton is a single LoadingBar), swept by the shimmer rAF.
function buildLoadingOption(
  ctx: OptionBuildContext,
  frame: { grid: GridComponentOption; xAxis: XAxisOption; yAxis: YAxisOption },
): EChartsOption {
  const { tokens } = ctx.resolved;

  return {
    animation: false,
    grid: frame.grid,
    xAxis: frame.xAxis,
    yAxis: frame.yAxis,
    tooltip: { show: false },
    series: [
      {
        id: "__loading",
        type: "bar",
        data: ctx.loadingData(),
        barCategoryGap: "30%",
        silent: true,
        // Invisible until the first shimmer tick positions the clip window.
        itemStyle: {
          color: withAlpha(tokens.foreground, 0),
          borderRadius: barBorderRadius(DEFAULT_BAR_RADIUS, "default", ctx.isHorizontal),
        },
        z: 1,
      },
    ],
  };
}

function buildBarSeries(ctx: OptionBuildContext): BarSeriesOption[] {
  const {
    data,
    config,
    bars,
    seriesKeys,
    animationType,
    isHorizontal,
    isStacked,
    isPercent,
    selectedDataKey,
    hasSelection,
    barGap,
    barCategoryGap,
    resolved,
  } = ctx;

  const lastIndex = data.length - 1;

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

  const series: BarSeriesOption[] = bars.map((bar) => {
    const key = bar.dataKey;
    const slots = resolved.series[key] ?? [GRAY];
    const base = slots[0] ?? GRAY;
    const isSelected = selectedDataKey === key;
    const dim = selectionOpacity(selectedDataKey, key);
    const resolvedRadius = bar.radius ?? ctx.barRadius;
    const borderRadius = barBorderRadius(resolvedRadius, bar.variant, isHorizontal);
    // Square segments: the tile's height matches the measured bar width, so each
    // block is 1:1. Falls back to BLOCK_SIZE on the first push, before layout.
    const blockSize = ctx.barWidthPx ?? BLOCK_SIZE;
    const fill = barFillPaint(bar.variant, slots, isHorizontal, blockSize);
    const barAnim = bar.animationType ?? animationType;
    const isStripped = bar.variant === "stripped";
    const isExpandable = bar.variant === "expandable";
    // Under enableMaxValueHighlight every column except the tallest is muted, so a
    // single flat tone replaces whatever fill the variant would have painted.
    const mutedFill = withAlpha(resolved.tokens.mutedForeground, MAX_HIGHLIGHT_DIM);
    const isMuted = (i: number) => ctx.maxHighlightIndex != null && i !== ctx.maxHighlightIndex;

    // Openness per datum, driven by the hover rAF. Bars not in the map are shut.
    const expandOf = (i: number) =>
      ctx.expand.key === key ? (ctx.expand.progress.get(i) ?? EXPAND_COLLAPSED) : EXPAND_COLLAPSED;
    const expandHovered = ctx.expand.key === key ? ctx.expand.hovered : null;
    // The unfilled part of a blocks bar: the same tile in a muted tone, drawn by
    // ECharts' own bar background so it spans the column's full height.
    const isBlocks = bar.variant === "blocks";
    const blockTrack = isBlocks
      ? patternFill(
          "blocks",
          withAlpha(resolved.tokens.mutedForeground, BLOCK_TRACK_OPACITY),
          blockSize,
        )
      : null;

    const values = data.map((row, i) => {
      const value = Number(row[key]) || 0;
      if (!isPercent) return value;
      const total = rowTotals[i];
      return total ? value / total : 0;
    });

    // Buffer bar: the last datum becomes a bare hatched rectangle with a
    // series-colored outline, marking projected/incomplete data.
    const bufferStyle = bar.bufferBar
      ? {
          color: patternFill("buffer", base) ?? "transparent",
          borderColor: base,
          borderWidth: STROKE_WIDTH,
          borderRadius,
        }
      : null;

    // Per-bar glow shadow — the shadowColor is sampled from the series gradient
    // at each bar's horizontal position, so a multi-stop series glows in its own
    // colors across the plot instead of one flat tint (a single shadowColor was
    // the bug). A canvas shape carries only one shadow, so the sample is per bar,
    // not within a bar; the wide, soft shadowBlur reads as the Recharts blur's
    // colored halo with no hard rim.
    // `glowing` haloes every bar in the series; enableMaxValueHighlight haloes only
    // the winning column — the muted ones must stay flat or the "one bar stands
    // out" reading collapses. Same shadow either way, so the two share a builder.
    const glowAt = (i: number) => ({
      shadowBlur: GLOW_BLUR,
      shadowColor: withAlpha(
        sampleGradient(slots, values.length > 1 ? i / (values.length - 1) : 0),
        GLOW_OPACITY,
      ),
    });
    const glowFor = bar.glowing
      ? glowAt
      : ctx.maxHighlightIndex != null
        ? (i: number) => (i === ctx.maxHighlightIndex ? glowAt(i) : {})
        : null;

    // Only wrap a datum in an object when it needs per-point overrides (stripped
    // cap, buffer tip, or glow); otherwise keep the bare number so the series
    // itemStyle applies untouched. Stripped and glow touch every datum; buffer only
    // the last one.
    const dataPoints =
      isStripped ||
      isExpandable ||
      glowFor ||
      ctx.maxHighlightIndex != null ||
      (bufferStyle && lastIndex >= 0)
        ? values.map((value, i) => {
            const isBuffer = !!bufferStyle && i === lastIndex;
            if (!isBuffer && !glowFor && !isStripped && !isExpandable && !isMuted(i)) return value;
            return {
              value,
              ...(isExpandable ? { label: { show: i === expandHovered } } : {}),
              itemStyle: {
                // The stripped cap is per datum: its fixed pixel height becomes a
                // fraction of THIS bar's own height, so the cap is a constant pixel
                // height across bars. The buffer tip (bare hatched) still wins on
                // the last datum.
                ...(isStripped && !isBuffer
                  ? {
                      color: strippedDatumPaint(
                        slots,
                        isHorizontal,
                        strippedCapFraction(value, ctx.valuePxPerUnit),
                      ),
                    }
                  : {}),
                ...(isExpandable && !isBuffer
                  ? { color: expandableDatumPaint(slots, expandOf(i)) }
                  : {}),
                ...(isBuffer && bufferStyle ? bufferStyle : {}),
                ...(glowFor ? glowFor(i) : {}),
                // Last so it overrides the variant's own paint.
                ...(isMuted(i) ? { color: mutedFill } : {}),
              },
            };
          })
        : values;

    return {
      id: key,
      name: typeof config[key]?.label === "string" ? config[key]?.label : key,
      type: "bar",
      data: dataPoints,
      stack: isStacked ? "total" : undefined,
      barGap,
      barCategoryGap,
      cursor: bar.isClickable ? "pointer" : "default",
      // Selected series ride on top; when a selection is active the rest sink below.
      z: isSelected ? 3 : hasSelection ? 1 : 2,
      // The hovered bar names its value above itself (Recharts twin parity).
      label: isExpandable
        ? {
            show: false,
            position: "top",
            color: resolved.tokens.foreground,
            fontFamily: "var(--font-mono, monospace)",
            fontSize: 11,
          }
        : undefined,
      showBackground: isBlocks,
      backgroundStyle: blockTrack ? { color: blockTrack, borderRadius } : undefined,
      itemStyle: {
        color: fill,
        borderRadius,
        opacity: dim,
        // The glow lives on each datum's itemStyle (per-bar sampled shadowColor),
        // not here — a single series-level shadowColor can't follow a gradient.
      },
      // Hover-highlight uses ECharts-native focus/blur: `self` keeps only the
      // hovered bar lit and dims every other, matching the twin's per-bar dim.
      // A click-selection OWNS the dim while it is active, so hover highlighting
      // switches off entirely whenever a selection exists (this option rebuilds on
      // every selection change, and the notMerge push clears any live blur) and
      // resumes once the selection clears. Otherwise emphasis is disabled so
      // hovering leaves the bar untouched.
      emphasis:
        bar.enableHoverHighlight && !hasSelection
          ? { focus: "self" as const, blurScope: "coordinateSystem" as const }
          : { disabled: true },
      blur:
        bar.enableHoverHighlight && !hasSelection
          ? { itemStyle: { opacity: HOVER_BLUR } }
          : undefined,
      // The grow-in envelope. Only takes effect on the reveal push (top-level
      // `animation: true`); every later push sends `animation: false`, so the
      // per-datum stagger is dormant then.
      animationDuration: BAR_GROW_DURATION,
      animationEasing: "cubicOut",
      animationDelay: (idx: number) => barStaggerDelay(barAnim, idx, data.length),
    };
  });

  // Stacked segments butt together into one solid column, so part them with a REAL
  // gap: a transparent series stacked between each adjacent pair. A background
  // -colored border can't do this — a border paints all four sides, outlining every
  // segment (glaring the moment a bar glows) instead of only separating them.
  //
  // The spacer's value is in DATA units, so it is derived from the measured
  // pixels-per-unit to keep the gap a constant pixel height whatever the scale.
  // Before the first layout that measurement is null and the gap is simply skipped;
  // the push re-applies once it exists, in the same frame (see the sync effect).
  const gapUnits =
    (isStacked || isPercent) && series.length > 1 && ctx.valuePxPerUnit
      ? STACK_SEGMENT_GAP / ctx.valuePxPerUnit
      : 0;
  if (!gapUnits) return series;

  const spaced: BarSeriesOption[] = [];
  series.forEach((entry, i) => {
    spaced.push(entry);
    if (i === series.length - 1) return;
    spaced.push({
      id: `__stackgap-${i}`,
      type: "bar",
      stack: isStacked ? "total" : undefined,
      data: data.map(() => gapUnits),
      itemStyle: { color: "transparent" },
      silent: true,
      tooltip: { show: false },
      legendHoverLink: false,
      emphasis: { disabled: true },
      animation: false,
      z: 1,
    });
  });
  return spaced;
}

// ─────────────────────────────────────────────────────────────────────────────
// 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. 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
  hasRevealed: boolean; // the intro grow-in already played on this chart instance
  revealEndsAt: number; // performance.now() the entrance settles — gates the stripped-cap correction
  valuePxPerUnit: number | null; // measured value-axis pixels-per-unit — sizes the stripped cap
  barWidthPx: number | null; // measured bar width — sizes the blocks variant's squares
  // Openness per bar index for the expandable variant, plus which one the pointer
  // is on. Per-index so a bar being left keeps easing shut while the next one
  // opens — a single shared value made the outgoing bar snap.
  expand: { key: string | null; hovered: number | null; progress: Map<number, number> };
  expandRaf: number; // in-flight expand animation frame
  animateExpand: (key: string | null, index: number | null) => void;
  loadingRows: number[] | null; // skeleton data, lazily rolled and re-rolled per shimmer sweep
  categories: string[]; // x labels of the last build, for the brush label pills
  dataLength: number; // row count, for the datazoom index math
  brushRange: BrushRange; // live zoom window — carried through every rebuild
  brushGeom: BrushGeometry | null; // brush footer layout of the last build
  brushOverlay: BrushOverlayElements | null; // zrender elements, owned by syncBrushOverlay
  brushHover: { inside: boolean; left: boolean; right: boolean };
  // Latest callbacks/flags for the imperative ECharts event handlers.
  handlers: {
    onBrushChange?: (range: { startIndex: number; endIndex: number }) => void;
    clickableKeys: Set<string>;
    brushFormatLabel?: (value: string, index: number) => string;
    seriesKeys: string[];
    hasStripped: boolean; // any visible stripped bar → run the post-layout cap correction
    hasBlocks: boolean; // any blocks bar → re-push once the bar width is measurable
    hasStackGap: boolean; // stacked with >1 series → the segment gap needs the axis scale
    expandableKey: string | null; // the expandable series, if any — drives the column hover
    barCategoryGap?: number; // consumer's category gap, needed to derive the bar width
    isHorizontal: boolean; // layout, for measuring the value axis in the finished handler
  };
  // Update-style re-push for paths that bypass React entirely (theme flips,
  // resizes) — set by the sync effect.
  repush: () => void;
  // Rebuilds ONLY the stripped bar series (fresh per-datum cap fractions) and
  // merges them with a silent lazyUpdate — never notMerge, so it leaves the
  // dataZoom drag anchor and the running entrance untouched. Set by the sync effect.
  patchStrippedCaps: () => void;
};

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

/**
 * Apache ECharts port of the EvilCharts bar 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 grow-in, and optional zoom
 * brush; every visual part — `<Bar>`, `<XAxis>`, `<YAxis>`, `<Grid>`,
 * `<Tooltip>`, `<Legend>` — is composed as a declarative child that renders
 * nothing. The root walks those children by reference and drives a single
 * imperative ECharts instance. Fully self-contained: its only dependencies are
 * `react`, `echarts`, and `motion`.
 */
export function EChartsBarChart<TData extends Record<string, unknown>>({
  data,
  config,
  xDataKey,
  className,
  stackType = "default",
  layout = "vertical",
  barRadius = DEFAULT_BAR_RADIUS,
  animation = true,
  animationType = "left-to-right",
  barGap,
  barCategoryGap,
  defaultSelectedDataKey = null,
  onSelectionChange,
  enableMaxValueHighlight = false,
  isLoading = false,
  loadingBars = LOADING_DEFAULT_BARS,
  chartOptions,
  children,
}: EChartsBarChartProps<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 object identity is stable for
  // the component's lifetime.
  const live = useRef<LiveState>({
    resolved: null,
    hasRevealed: false,
    revealEndsAt: 0,
    valuePxPerUnit: null,
    barWidthPx: null,
    expand: { key: null, hovered: null, progress: new Map<number, number>() },
    expandRaf: 0,
    animateExpand: () => {},
    loadingRows: null,
    categories: [],
    dataLength: 0,
    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
      clickableKeys: new Set<string>(),
      brushFormatLabel: undefined, // set per-render from the <Brush> child's formatLabel
      seriesKeys: [],
      hasStripped: false,
      hasBlocks: false,
      hasStackGap: false,
      expandableKey: null,
      isHorizontal: false,
    },
    repush: () => {},
    patchStrippedCaps: () => {},
  }).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 ??= getLoadingBarData(loadingBars)),
    [live, loadingBars],
  );
  const shouldReduceMotion = useReducedMotion();

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

  // ── Declarative config, collected from children by reference ─────────────────
  const collected = useMemo(() => collectConfig(children), [children]);
  const {
    bars,
    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 isHorizontal = layout === "horizontal";
  const isPercent = stackType === "percent";
  const isStacked = stackType === "stacked" || isPercent;

  // Category axis is x when vertical, y when horizontal; value axis the other.
  const categorySlot = isHorizontal ? yAxisSlot : xAxisSlot;
  const valueSlot = isHorizontal ? xAxisSlot : yAxisSlot;

  const seriesKeys = useMemo(() => bars.map((bar) => bar.dataKey), [bars]);

  // category key: category axis dataKey → root xDataKey → first data column no <Bar> claims.
  const categoryKey = useMemo(() => {
    if (categorySlot.dataKey) return categorySlot.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 "";
  }, [categorySlot.dataKey, xDataKey, data, seriesKeys]);

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

  // The tallest COLUMN, comparing totals across every series so a stack or group
  // wins together rather than one bar inside it. Null when the flag is off.
  const maxHighlightIndex = useMemo(() => {
    if (!enableMaxValueHighlight || !data.length || !seriesKeys.length) return null;
    let best = 0;
    let bestTotal = -Infinity;
    data.forEach((row, i) => {
      const total = seriesKeys.reduce((sum, key) => sum + (Number(row[key]) || 0), 0);
      if (total > bestTotal) {
        bestTotal = total;
        best = i;
      }
    });
    return best;
  }, [enableMaxValueHighlight, data, seriesKeys]);

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

  const hasSelection = selectedDataKey !== null;

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

  // Any visible stripped bar? (never while loading — the skeleton has no stripped
  // series.) Gates the post-layout cap correction in the `finished` handler.
  const hasStrippedBars = !isLoading && bars.some((bar) => bar.variant === "stripped");

  // Refresh the handlers' snapshot of the latest callbacks/flags every render.
  live.handlers = {
    onBrushChange: brushSlot.onChange,
    clickableKeys,
    brushFormatLabel: brushSlot.formatLabel,
    seriesKeys,
    hasStripped: hasStrippedBars,
    hasBlocks: bars.some((bar) => bar.variant === "blocks"),
    hasStackGap: (stackType === "stacked" || stackType === "percent") && bars.length > 1,
    expandableKey: bars.find((bar) => bar.variant === "expandable")?.dataKey ?? null,
    barCategoryGap,
    isHorizontal,
  };
  live.dataLength = data.length;

  const toggleSelection = useCallback(
    (key: string) => {
      setSelectedDataKey((prev) => {
        const next = prev === key ? null : key;
        onSelectionChange?.(next);
        return next;
      });
    },
    [onSelectionChange],
  );

  // The brush is meaningful only when the category axis is on x (vertical layout).
  const brushEnabled = showBrush && !isHorizontal;

  // 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 ─────────────────────────────────────────────────────────
  const buildOption = useCallback((): EChartsOption => {
    const resolved = live.resolved;
    if (!resolved) return {};

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

    const ctx: OptionBuildContext = {
      data,
      config,
      bars,
      seriesKeys,
      animationType,
      barRadius,
      isHorizontal,
      isStacked,
      isPercent,
      selectedDataKey,
      hasSelection,
      showGrid,
      categorySlot,
      valueSlot,
      tooltipSlot,
      legendSlot,
      isLoading,
      loadingData,
      showBrush: brushEnabled,
      brushHeight,
      barGap,
      barCategoryGap,
      resolved,
      categories,
      brushRange: live.brushRange,
      valuePxPerUnit: live.valuePxPerUnit,
      barWidthPx: live.barWidthPx,
      expand: live.expand,
      maxHighlightIndex,
    };

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

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

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

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

    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: [...buildBarSeries(ctx), ...(brush?.miniSeries ?? [])],
    };
  }, [
    live,
    data,
    config,
    bars,
    seriesKeys,
    categoryKey,
    animationType,
    barRadius,
    isHorizontal,
    isStacked,
    isPercent,
    selectedDataKey,
    hasSelection,
    showGrid,
    categorySlot,
    valueSlot,
    tooltipSlot,
    legendSlot,
    isLoading,
    loadingData,
    brushEnabled,
    brushHeight,
    barGap,
    barCategoryGap,
    maxHighlightIndex,
  ]);

  // ── 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 grow-in —
      // only react when the renderer size actually changed.
      if (mount.clientWidth === chart.getWidth() && mount.clientHeight === chart.getHeight()) {
        return;
      }
      chart.resize();
      live.repush();
    });
    resizeObserver.observe(mount);

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

    // Expandable hover, driven by the pointer's COLUMN rather than the bar element.
    // An expandable bar is a hairline at rest, so element hover would only catch a
    // couple of pixels — and hovering the empty space above a bar (where the axis
    // tooltip still responds) would highlight it without expanding it. Converting
    // the pointer's x back to a category index makes the whole column the target,
    // matching what the tooltip already does. Registered ONCE here rather than in
    // the sync effect, which re-runs on every prop/theme change and would stack
    // duplicate listeners; it calls through live.animateExpand, always the current one.
    chart.getZr().on("mousemove", (event: { offsetX: number; offsetY: number }) => {
      const { expandableKey } = live.handlers;
      if (!expandableKey) return;
      const point = [event.offsetX, event.offsetY];
      if (!chart.containPixel({ gridIndex: 0 }, point)) {
        live.animateExpand(expandableKey, null);
        return;
      }
      // A grid finder returns [xValue, yValue]; on a category axis the x value IS
      // the index. An xAxisIndex finder returns null for a 2D point.
      const converted = chart.convertFromPixel({ gridIndex: 0 }, point);
      const index = Array.isArray(converted) ? converted[0] : converted;
      live.animateExpand(expandableKey, typeof index === "number" ? Math.round(index) : null);
    });
    chart.getZr().on("globalout", () => {
      const { expandableKey } = live.handlers;
      if (expandableKey) live.animateExpand(expandableKey, null);
    });

    chart.on("click", (params) => {
      const { clickableKeys: clickable, seriesKeys: keys } = live.handlers;
      const p = params as { seriesId?: string; seriesIndex?: number };
      // Bar clicks carry seriesId; keep the seriesIndex fallback for safety. Main
      // series come first in the series array, so the index maps directly.
      const id =
        p.seriesId ?? (typeof p.seriesIndex === "number" ? keys[p.seriesIndex] : undefined);
      if (typeof id === "string" && clickable.has(id)) toggleSelection(id);
    });

    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 });
    });

    // The stripped cap needs the value-axis pixel scale, which only exists AFTER a
    // layout. Once rendering settles — the first paint, a zoom that rescaled the
    // axis — measure it and, if it moved, correct the caps. The correction is a
    // SILENT series-only merge (patchStrippedCaps), so it can't reset a dataZoom
    // drag. Held off until the entrance finishes (revealEndsAt) so it never lands
    // mid-grow; guarded by an epsilon so a stable measurement doesn't loop.
    chart.on("finished", () => {
      const { hasStripped, isHorizontal: horiz } = live.handlers;
      if (!hasStripped || performance.now() < live.revealEndsAt) return;
      const measured = measureValuePxPerUnit(chart, horiz);
      if (measured == null) return;
      if (live.valuePxPerUnit != null && Math.abs(measured - live.valuePxPerUnit) < 0.5) return;
      live.valuePxPerUnit = measured;
      live.patchStrippedCaps();
    });

    // 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 () => {
      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) => {
      // Refresh the value-axis pixel scale before building, so stripped caps get the
      // right per-bar fraction on this same push (after a resize the coordinate
      // system is already updated here). Null before the very first push — the
      // `finished` handler corrects that first paint once the axis exists.
      const measured = measureValuePxPerUnit(chart, isHorizontal);
      if (measured != null) live.valuePxPerUnit = measured;

      const apply = () => {
        const option = buildOption();
        const merged = chartOptions ? { ...option, ...chartOptions } : option;
        Object.assign(merged, {
          animation: withEntrance,
          animationDuration: BAR_GROW_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 });
      };

      apply();

      // Two things can only be sized once a coordinate system has been laid out, so
      // the first build uses fallbacks: the `blocks` variant's square segments (bar
      // width) and the stacked-segment gap (value-axis pixels-per-unit). Measure
      // both now and, if either moved, rebuild IMMEDIATELY — still inside this task,
      // before the browser paints, so the corrected chart is the only thing ever
      // shown. Doing this from the async `finished` handler instead made the bars
      // visibly re-align a frame later.
      let needsRebuild = false;
      if (live.handlers.hasBlocks) {
        const width = measureBarWidthPx(chart, isHorizontal, barCategoryGap);
        if (width != null && (live.barWidthPx == null || Math.abs(width - live.barWidthPx) > 0.5)) {
          live.barWidthPx = width;
          needsRebuild = true;
        }
      }
      if (live.handlers.hasStackGap) {
        const scale = measureValuePxPerUnit(chart, isHorizontal);
        if (scale != null && (live.valuePxPerUnit == null || live.valuePxPerUnit !== scale)) {
          live.valuePxPerUnit = scale;
          needsRebuild = true;
        }
      }
      if (needsRebuild) apply();
      // Mark when the entrance settles, so the stripped-cap correction holds off
      // until the grow finishes (0 = nothing animating, correct immediately).
      const maxStagger = data.length > 1 ? (data.length - 1) * BAR_STAGGER : 0;
      live.revealEndsAt = withEntrance ? performance.now() + BAR_GROW_DURATION + maxStagger : 0;
      // Overlays live outside the option — reposition them after every push.
      syncBrushOverlayNow();
    };

    // A stripped-cap correction that never disturbs the entrance or a brush drag:
    // rebuild only the stripped series with fresh per-datum cap fractions (the just
    // -measured live.valuePxPerUnit) and merge them silently.
    // Drives the `expandable` hover: eases live.expand.progress toward its target
    // and re-merges ONLY the expandable series each frame, so the strip grows out
    // of the bar's middle. A series-scoped silent merge (same shape as
    // patchStrippedCaps) — never a full notMerge push, which would fight the
    // hover state it is animating.
    live.animateExpand = (key: string | null, index: number | null) => {
      const expandKeys = new Set(
        bars.filter((bar) => bar.variant === "expandable").map((bar) => bar.dataKey),
      );
      if (!expandKeys.size) return;

      const next = index != null && key != null ? index : null;
      if (live.expand.hovered === next && (key == null || live.expand.key === key)) return;
      if (key != null) live.expand.key = key;
      live.expand.hovered = next;
      // Seed the newly hovered bar so it has something to ease from.
      if (next != null && !live.expand.progress.has(next)) {
        live.expand.progress.set(next, EXPAND_COLLAPSED);
      }
      if (live.expandRaf) return; // a loop is already running; it picks up the new target

      const patchOnce = () => {
        const option = buildOption();
        const series = Array.isArray(option.series)
          ? option.series
          : option.series
            ? [option.series]
            : [];
        const patch = series.filter(
          (s): s is BarSeriesOption => typeof s?.id === "string" && expandKeys.has(s.id),
        );
        if (patch.length) chart.setOption({ series: patch }, { silent: true, lazyUpdate: true });
      };

      let last = performance.now();
      const step = () => {
        const now = performance.now();
        const dt = Math.min(64, now - last);
        last = now;
        // Exponential approach — every bar eases toward its own target, so the one
        // being left keeps animating shut while the next one opens.
        const k = 1 - Math.exp(-dt / EXPAND_TAU);
        let moving = false;
        for (const [i, value] of live.expand.progress) {
          const target = i === live.expand.hovered ? 1 : EXPAND_COLLAPSED;
          const eased = value + (target - value) * k;
          if (Math.abs(target - eased) < 0.004) {
            if (target === EXPAND_COLLAPSED) live.expand.progress.delete(i);
            else live.expand.progress.set(i, target);
          } else {
            live.expand.progress.set(i, eased);
            moving = true;
          }
        }
        patchOnce();
        live.expandRaf = moving ? requestAnimationFrame(step) : 0;
      };
      live.expandRaf = requestAnimationFrame(step);
    };

    live.patchStrippedCaps = () => {
      const option = buildOption();
      const series = Array.isArray(option.series)
        ? option.series
        : option.series
          ? [option.series]
          : [];
      const strippedKeys = new Set(
        bars.filter((bar) => bar.variant === "stripped").map((bar) => bar.dataKey),
      );
      const patch = series.filter(
        (s): s is BarSeriesOption => typeof s?.id === "string" && strippedKeys.has(s.id),
      );
      if (patch.length) chart.setOption({ series: patch }, { silent: true, lazyUpdate: true });
    };

    // Intro grow-in — ECharts' native bar entrance (bars rise from the baseline),
    // staggered per-datum by animationType, enabled only for the first real
    // render: every later push (selection, theme, zoom) applies instantly, since
    // notMerge would otherwise replay the entrance on each. A loading cycle
    // re-arms it: the Recharts twin remounts its <Bar>s while loading and replays
    // the intro, so data → loading → data grows 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;
    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,
    data.length,
    bars,
    isHorizontal,
    barCategoryGap,
    syncBrushOverlayNow,
  ]);

  // ── Default tooltip — show the tooltip at `defaultIndex` with no hover ────────
  // Recharts' `defaultIndex` keeps a tooltip open on load; ECharts has no static
  // equivalent, so dispatch `showTip` once the layout has settled.
  useEffect(() => {
    const chart = echartsRef.current;
    const index = tooltipSlot.defaultIndex;
    if (!chart || isLoading || !tooltipSlot.present || index == null) return;
    const timer = setTimeout(() => {
      chart.dispatchAction({ type: "showTip", seriesIndex: 0, dataIndex: index });
    }, 300);
    return () => clearTimeout(timer);
  }, [tooltipSlot.present, tooltipSlot.defaultIndex, isLoading, data.length, seriesKeys.length]);

  // ── Loading shimmer — rAF sweeps a bright band across the gray bars ──────────
  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 heights.
      if (phase < lastPhase) live.loadingRows = getLoadingBarData(loadingBars);
      lastPhase = phase;

      // Read tokens per frame, so a theme flip mid-loading retints the shimmer.
      const foreground = live.resolved?.tokens.foreground ?? GRAY;
      const w = chart.getWidth();
      const h = chart.getHeight();
      if (!w || !h) {
        raf = requestAnimationFrame(tick);
        return;
      }
      // Sweep the clip window from fully off-screen to fully off-screen, leaned
      // 45°. The gradient runs on ABSOLUTE pixel coordinates (0,0)→(w,w) shared
      // by every bar — the whole skeleton lives in one coordinate frame, so each
      // bar brightens as the diagonal band passes diagonally over it, the same
      // sweep language as the area chart's loading shimmer. `maxT` is the farthest
      // plot corner projected onto the 45° axis, keeping 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 fill = new echarts.graphic.LinearGradient(
        0,
        0,
        w,
        w,
        shimmerWindowStops(center, foreground, LOADING_SHIMMER_MAX_OPACITY),
        true,
      );
      chart.setOption(
        { series: [{ id: "__loading", data: loadingData(), itemStyle: { color: fill } }] },
        { silent: true, lazyUpdate: true },
      );
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [live, isLoading, loadingBars, 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: brushEnabled ? 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={null}
          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>
  );
}

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

// Skeleton bar heights as a smooth random walk in a comfortable band — reads
// like a resting chart instead of raw noise spikes.
function getLoadingBarData(bars: number): number[] {
  const rows: number[] = [];
  let value = 40 + Math.random() * 25;
  for (let i = 0; i < bars; i++) {
    value = Math.min(85, Math.max(20, value + (Math.random() - 0.5) * 30));
    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;
}

// Compound API: every part hangs off the root as a static member, so a consumer
// writes <EChartsBarChart.Bar/>, <EChartsBarChart.Tooltip/>, … from a single
// import — no colliding named marker exports when several charts share one file.
EChartsBarChart.Bar = Bar;
EChartsBarChart.XAxis = XAxis;
EChartsBarChart.YAxis = YAxis;
EChartsBarChart.Grid = Grid;
EChartsBarChart.Tooltip = Tooltip;
EChartsBarChart.Legend = Legend;
EChartsBarChart.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 bar chart is composible, sharing the Recharts twin's API shape. `<EChartsBarChart>` is the container, and every part hangs off it as a compound member — `<EChartsBarChart.Grid>`, `<EChartsBarChart.XAxis>`, `<EChartsBarChart.YAxis>`, `<EChartsBarChart.Legend>`, `<EChartsBarChart.Tooltip>`, and one or more `<EChartsBarChart.Bar>` — so a single import gives you the whole chart. Each `<Bar>` carries its own `variant`, `radius`, `glowing`, `bufferBar`, and `isClickable`, so one chart can mix fill styles and make only some series interactive.

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

```tsx
<EChartsBarChart data={data} config={chartConfig} stackType="default">
  <EChartsBarChart.Grid />
  <EChartsBarChart.XAxis dataKey="month" />
  <EChartsBarChart.Legend isClickable />
  <EChartsBarChart.Tooltip />
  <EChartsBarChart.Bar dataKey="desktop" variant="default" isClickable />
  <EChartsBarChart.Bar dataKey="mobile" variant="hatched" isClickable />
</EChartsBarChart>
```

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 object — same JSX, same presence semantics (omit a part and it doesn't render), but the children are declarative config, not live DOM nodes.

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

> 
  
    Canvas rendering brings a few small departures from the Recharts twin: the `duotone` split is baked as a single ECharts gradient (visually equivalent for single-color series), the `stripped` cap is a fixed-height band derived from the measured axis scale, the `hatched` fill is a tiling canvas texture, and the zoom brush is a themed mini chart driven by ECharts' native `dataZoom` rather than the custom `EvilBrush`.
  


### Interactive Selection

Add `isClickable` to any `<Bar>` (and to `<Legend>`) to make those series selectable. Use the `onSelectionChange` callback on `<EChartsBarChart>` to handle selection events:

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

### Loading State

### isLoading='true'

```tsx
"use client";

import { EChartsBarChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-bar-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 EChartsExampleBarChart() {
  return (
    <EChartsBarChart
      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]
    >
      <EChartsBarChart.Grid />
      <EChartsBarChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsBarChart.Legend />
      <EChartsBarChart.Tooltip />
      <EChartsBarChart.Bar dataKey="desktop" variant="default" />
      <EChartsBarChart.Bar dataKey="mobile" variant="default" />
    </EChartsBarChart>
  );
}

```
> 
  
    Pass `isLoading` to show an animated skeleton of gray bars with a shimmer while data loads, and `loadingBars` to set how many bars the skeleton draws.
  


### Buffer Bar

<ComponentPreview className="mb-0" title="<Bar bufferBar />" name="ex-buffer-echarts-bar-chart"  />
> 
  
    With `bufferBar` set, a `<Bar>`'s last data point renders with a hatched (diagonal lines) pattern and a series-colored outline while the rest stay solid — handy for flagging projected, estimated, or incomplete data at the end of a series.
  


## Examples

Examples of the bar chart with different `variants`. Each `<Bar>` sets its own `variant`; the chart-wide `stackType` and `layout` shape the rest.

### Hover Highlight

<ComponentPreview className="mb-0" title="<Bar enableHoverHighlight />" name="ex-hover-highlight-echarts-bar-chart"  />
> 
  
    Set `enableHoverHighlight` on a `<Bar>` to dim every other bar on hover, keeping focus on one series. It uses ECharts' native emphasis/blur, so nothing re-renders mid-hover.
  


### Max Value Highlight

<ComponentPreview className="mb-0" title="<EChartsBarChart enableMaxValueHighlight />" name="ex-max-highlight-echarts-bar-chart"  />
> 
  
    Set `enableMaxValueHighlight` on the chart to color only its tallest column and mute the rest. With several series the comparison is per column — the totals across every series at that category — so a whole stack or group lights up together rather than one bar inside it.
  


### Gradient Colors

### gradient colors

```tsx
"use client";

import { EChartsBarChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-bar-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: ["#a855f7", "#6366f1", "#3b82f6"], // [!code highlight]
      dark: ["#f43f5e", "#ec4899", "#a855f7", "#6366f1", "#3b82f6"], // [!code highlight]
    },
  },
  mobile: {
    label: "Mobile",
    colors: {
      light: ["#10b981", "#34d399", "#6ee7b7"], // [!code highlight]
      dark: ["#10b981", "#14b8a6", "#06b6d4"], // [!code highlight]
    },
  },
} satisfies ChartConfig;

export function EChartsExampleBarChart() {
  return (
    <EChartsBarChart data={data} config={chartConfig} className="h-full w-full p-4">
      <EChartsBarChart.Grid />
      <EChartsBarChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsBarChart.Legend isClickable />
      <EChartsBarChart.Tooltip />
      <EChartsBarChart.Bar dataKey="desktop" variant="default" isClickable />
      <EChartsBarChart.Bar dataKey="mobile" variant="default" isClickable />
    </EChartsBarChart>
  );
}

```

### Bar Variants

### variant='default'

```tsx
"use client";

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

```
### variant='hatched'

```tsx
"use client";

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

```
### variant='duotone'

```tsx
"use client";

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

```
### variant='duotone-reverse'

```tsx
"use client";

import { EChartsBarChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-bar-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 EChartsExampleBarChart() {
  return (
    <EChartsBarChart data={data} config={chartConfig} className="h-full w-full p-4">
      <EChartsBarChart.Grid />
      <EChartsBarChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsBarChart.Legend isClickable />
      <EChartsBarChart.Tooltip />
      <EChartsBarChart.Bar
        dataKey="desktop"
        variant="duotone-reverse" // [!code highlight]
        isClickable
      />
      <EChartsBarChart.Bar
        dataKey="mobile"
        variant="duotone-reverse" // [!code highlight]
        isClickable
      />
    </EChartsBarChart>
  );
}

```
### variant='gradient'

```tsx
"use client";

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

```
### variant='stripped'

```tsx
"use client";

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

```
### variant='blocks'

```tsx
"use client";

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

const data = [
  { month: "January", desktop: 342 },
  { month: "February", desktop: 876 },
  { month: "March", desktop: 512 },
  { month: "April", desktop: 629 },
  { month: "May", desktop: 458 },
  { month: "June", desktop: 781 },
  { month: "July", desktop: 394 },
  { month: "August", desktop: 925 },
  { month: "September", desktop: 647 },
  { month: "October", desktop: 532 },
  { month: "November", desktop: 803 },
  { month: "December", desktop: 271 },
];

const chartConfig = {
  desktop: {
    label: "Desktop",
    colors: {
      light: ["#0a0a0a"],
      dark: ["#fafafa"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleBarChart() {
  return (
    <EChartsBarChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      barCategoryGap={32}
    >
      <EChartsBarChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsBarChart.Tooltip />
      <EChartsBarChart.Bar
        dataKey="desktop"
        variant="blocks" // [!code highlight]
      />
    </EChartsBarChart>
  );
}

```
### variant='expandable'

```tsx
"use client";

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

const data = [
  { month: "January", desktop: 342 },
  { month: "February", desktop: 876 },
  { month: "March", desktop: 512 },
  { month: "April", desktop: 629 },
  { month: "May", desktop: 458 },
  { month: "June", desktop: 781 },
  { month: "July", desktop: 394 },
  { month: "August", desktop: 925 },
  { month: "September", desktop: 647 },
  { month: "October", desktop: 532 },
  { month: "November", desktop: 803 },
  { month: "December", desktop: 271 },
];

const chartConfig = {
  desktop: {
    label: "Desktop",
    colors: {
      light: ["#0a0a0a"],
      dark: ["#fafafa"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleBarChart() {
  return (
    <EChartsBarChart data={data} config={chartConfig} className="h-full w-full p-4">
      <EChartsBarChart.XAxis dataKey="month" tickFormatter={(value) => value.substring(0, 3)} />
      <EChartsBarChart.Tooltip />
      <EChartsBarChart.Bar
        dataKey="desktop"
        variant="expandable" // [!code highlight]
      />
    </EChartsBarChart>
  );
}

```
> 
  
    `variant="blocks"` renders each bar as a stack of segments rather than a solid column, and fills the rest of the column with the same segments in a muted tone — so every bar reads against a dim grid of its own blocks. `variant="expandable"` rests as a thin line and grows out from its own middle to the full bar width on hover, naming its value above itself.
  


### Stack Types

### stackType='stacked'

```tsx
"use client";

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

```
### stackType='percent'

```tsx
"use client";

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

```

### Horizontal Layout

### layout='horizontal'

```tsx
"use client";

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

const data = [
  { month: "January", desktop: 186 },
  { month: "February", desktop: 305 },
  { month: "March", desktop: 237 },
  { month: "April", desktop: 173 },
  { month: "May", desktop: 209 },
  { month: "June", desktop: 214 },
];

const chartConfig = {
  desktop: {
    label: "Desktop",
    colors: {
      light: ["#2563eb"],
      dark: ["#3b82f6"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleBarChart() {
  return (
    <EChartsBarChart
      data={data}
      config={chartConfig}
      className="h-full w-full p-4"
      layout="horizontal" // [!code highlight]
    >
      <EChartsBarChart.Grid />
      <EChartsBarChart.YAxis
        dataKey="month"
        tickFormatter={(value) => value.substring(0, 3)} // [!code highlight]
      />
      <EChartsBarChart.Legend />
      <EChartsBarChart.Tooltip />
      <EChartsBarChart.Bar dataKey="desktop" variant="default" />
    </EChartsBarChart>
  );
}

```
> 
  
    Set `layout="horizontal"` on `<EChartsBarChart>` to render bars horizontally. The `<YAxis>` then shows categories and the `<XAxis>` shows values — pass a `tickFormatter` to `<YAxis>` for category formatting.
  


### Glowing Bars

<ComponentPreview className="mb-0" title="<Bar glowing /> - desktop" name="ex-glowing-desktop-echarts-bar-chart"  />
<ComponentPreview title="<Bar glowing /> - mobile" name="ex-glowing-mobile-echarts-bar-chart"  />

## API Reference

The props below 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>EChartsBarChart</ApiHeading>

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


  ### `data` (required)

type: `TData[]`

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

type: `ChartConfig`

Defines the chart's series. Each key matches a data key, with a `label` and 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 `<Bar />`.
  ### `className`

type: `string`

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

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

How multiple bars combine. `"default"` renders them side by side, `"stacked"` stacks them, and `"percent"` normalizes them to a percentage distribution.
  ### `layout`

type: `"vertical" | "horizontal"` · default: `"vertical"`

Bar orientation. With `"horizontal"`, bars lay sideways and the axes swap — the `<YAxis />` shows categories and the `<XAxis />` shows values.
  ### `barRadius`

type: `number` · default: `2`

Default corner radius for every `<Bar />`, in pixels. Each `<Bar />` can override it with its own `radius` prop.
  ### `animation`

type: `boolean` · default: `true`

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

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

Order in which bars grow into view, inherited by every `<Bar />`. Bars rise from their baseline with a per-datum stagger. `"none"` disables it — devices set to OS reduce-motion fall back to `"none"` automatically.
  ### `barGap`

type: `number`

Gap between bars in the same category (with multiple series), in pixels.
  ### `barCategoryGap`

type: `number`

Gap between bar categories, in pixels.
  ### `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 `<Bar />` or `<Legend />`. Receives the selected data key, or `null` when deselected.
  ### `enableMaxValueHighlight`

type: `boolean` · default: `false`

Colors only the tallest column and mutes every other. With several series the comparison is per column (totals across all series), so a whole stack or group highlights together.
  ### `isLoading`

type: `boolean` · default: `false`

Shows the animated shimmer skeleton while data loads.
  ### `loadingBars`

type: `number` · default: `12`

Number of bars in the loading skeleton.
  ### `xDataKey`

type: `keyof TData & string`

The data key used for the category axis. Falls back to the axis `dataKey`; also read by the brush footer.
  ### `chartOptions`



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


<ApiHeading>Bar</ApiHeading>

A single bar series. Each `<Bar />` carries its own fill variant, radius, glow, buffer, and clickability, so a chart can hold any number of bars with mixed styles.


  ### `dataKey` (required)

type: `string`

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

type: `"default" | "hatched" | "duotone" | "duotone-reverse" | "gradient" | "stripped" | "blocks" | "expandable"` · default: `"default"`

The bar's fill style, applied to this bar only. The `default` variant renders the full vertical color gradient for multi-color configs; `blocks` renders the bar as a stack of segments over a muted grid of the same segments.
  ### `radius`

type: `number`

The corner radius of this bar, in pixels. Falls back to the chart's `barRadius` when omitted.
  ### `animationType`

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

The grow-in order for this bar series. Falls back to the chart's `animationType` when omitted.
  ### `isClickable`

type: `boolean` · default: `false`

Lets this bar be selected by clicking it. When any bar is selected, unselected bars become semi-transparent.
  ### `enableHoverHighlight`

type: `boolean` · default: `false`

Hovering over a bar dims every other bar, keeping focus on one series.
  ### `glowing`

type: `boolean` · default: `false`

Applies a soft outer glow to this bar series.
  ### `bufferBar`

type: `boolean` · default: `false`

Renders this series' last data point with a hatched (diagonal lines) pattern and a series-colored outline while the rest stay solid. Useful for flagging projected or incomplete data at the end of a series.


<ApiHeading>XAxis and YAxis</ApiHeading>

The two axes. In the default (vertical) layout `<XAxis />` is the category axis and `<YAxis />` the value axis; `layout="horizontal"` swaps the roles. Include an axis to show its tick labels, omit it to hide them. Both hide automatically while loading, and the value axis formats ticks as percentages when `stackType="percent"`.


  ### `dataKey`

type: `string`

The category key for the axis. Overrides the root `xDataKey`.
  ### `tickFormatter`



string">
    Formats the axis tick labels. Category values arrive as strings.
  ### `label`

type: `string`

An axis title centered outside the tick labels — below the `<XAxis />`, alongside the `<YAxis />`. Hidden while loading.
  ### `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 split lines on the value axis; omit it and they don't render. Takes no props.

<ApiHeading>Tooltip</ApiHeading>

The hover tooltip. Include it to enable the tooltip; omit it and none shows. It reads selection state, so its content dims unselected series.


  ### `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.
  ### `defaultIndex`

type: `number`

Shows the tooltip by default at the given data point index, with no hover.
  ### `position`

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

How the tooltip is anchored. `"variable"` lets it follow the pointer, and `"fixed"` pins it near the top of the chart while only tracking the pointer's X.


<ApiHeading>Legend</ApiHeading>

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


  ### `variant`

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

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

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

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

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

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

type: `boolean` · default: `false`

Lets each legend entry toggle selection of its series.


<ApiHeading>Brush</ApiHeading>

An optional zoom brush below the chart — a themed mini chart driven by ECharts' native `dataZoom`. Include `<EChartsBarChart.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.

