Evil Charts
componentecharts-bar-chartsource: ok · hit
Echarts Bar Chart
Bar chart component rendered with Apache ECharts
npx shadcn add "https://evilcharts.com/r/echarts-bar-chart.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Registry dependencies
@evilcharts/echarts-chart@evilcharts/echarts-tooltip@evilcharts/echarts-dot@evilcharts/echarts-legend@evilcharts/echarts-brush
Files1 · source cached 10/9/2026, 10:17:33 PM
src/registry/charts/echarts-bar-chart.tsx
"use client";
import {
DEFAULT_ECHARTS_RENDERER,
buildChartCss,
flattenColor,
getColorsCount,
resolveColors,
withAlpha,
type ChartConfig,
type EChartsRenderer,
type ResolvedColors,
} from "@/registry/ui/echarts-chart";
import {
tooltipBaseOption,
tooltipIndicatorHtml,
tooltipRow,
tooltipShell,
type TooltipPosition,
type TooltipRoundness,
type TooltipVariant,
} from "@/registry/ui/echarts-tooltip";
import {
Brush,
buildBrushDataZoom,
syncBrushOverlay,
type BrushGeometry,
type BrushOverlayElements,
type BrushProps,
type BrushRange,
} from "@/registry/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 { LegendOverlay, type LegendVariant } from "@/registry/ui/echarts-legend";
import type { ComposeOption, ImagePatternObject } from "echarts/core";
import { BarChart, type BarSeriesOption } from "echarts/charts";
import { sampleGradient } from "@/registry/ui/echarts-dot";
import { motion, useReducedMotion } from "motion/react";
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,
EChartsRenderer,
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]);
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 @/registry/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
renderer?: EChartsRenderer; // rendering engine — defaults to canvas
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 @/registry/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
// @/registry/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
// @/registry/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,
renderer = DEFAULT_ECHARTS_RENDERER,
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 (per renderer instance) ──────────────────
useEffect(() => {
const mount = mountRef.current;
const container = containerRef.current;
if (!mount || !container) return;
const chart = echarts.init(mount, null, { renderer });
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 });
});
// Every push measures the axis scale and corrects stripped caps before it
// paints, so this only catches rescales that BYPASS push — a dataZoom drag
// narrowing the window until the value axis re-ranges. 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();
if (live.expandRaf) {
cancelAnimationFrame(live.expandRaf);
live.expandRaf = 0;
}
chart.dispose();
echartsRef.current = null;
// The overlay elements died with the zrender instance.
live.brushOverlay = null;
live.brushHover = { inside: false, left: false, right: false };
// Expand progress and layout measurements belong to the disposed painter.
// Recompute them for the replacement renderer instead of briefly painting
// its first frame with geometry captured from the old surface.
live.expand = { key: null, hovered: null, progress: new Map<number, number>() };
live.animateExpand = () => {};
live.valuePxPerUnit = null;
live.barWidthPx = 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
}, [renderer]);
// ── 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, and stale
// when this push rescales the axis (loading skeleton → real data) — the
// post-apply re-measure below corrects both before anything paints.
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();
// Some 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), the stacked-segment gap, and the stripped variant's constant-pixel
// cap (both value-axis pixels-per-unit). Measure now and, if anything 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 —
// exactly the stripped-cap flicker this replaces.
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 || live.handlers.hasStripped) {
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);
};
}, [
renderer,
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);
}, [
renderer,
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);
}, [renderer, 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;