{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "echarts-bar-chart",
  "description": "Bar chart component rendered with Apache ECharts",
  "dependencies": [
    "echarts",
    "motion"
  ],
  "registryDependencies": [
    "@evilcharts/echarts-chart",
    "@evilcharts/echarts-tooltip",
    "@evilcharts/echarts-dot",
    "@evilcharts/echarts-legend",
    "@evilcharts/echarts-brush"
  ],
  "files": [
    {
      "path": "src/registry/charts/echarts-bar-chart.tsx",
      "content": "\"use client\";\n\nimport {\n  tooltipBaseOption,\n  tooltipIndicatorHtml,\n  tooltipRow,\n  tooltipShell,\n  type TooltipPosition,\n  type TooltipRoundness,\n  type TooltipVariant,\n} from \"@/registry/ui/echarts-tooltip\";\nimport {\n  Brush,\n  buildBrushDataZoom,\n  syncBrushOverlay,\n  type BrushGeometry,\n  type BrushOverlayElements,\n  type BrushProps,\n  type BrushRange,\n} from \"@/registry/ui/echarts-brush\";\nimport {\n  DataZoomComponent,\n  GridComponent,\n  TooltipComponent,\n  type DataZoomComponentOption,\n  type GridComponentOption,\n  type TooltipComponentOption,\n} from \"echarts/components\";\nimport {\n  Children,\n  isValidElement,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type CSSProperties,\n  type FC,\n  type ReactNode,\n} from \"react\";\nimport {\n  buildChartCss,\n  flattenColor,\n  getColorsCount,\n  resolveColors,\n  withAlpha,\n  type ChartConfig,\n  type ResolvedColors,\n} from \"@/registry/ui/echarts-chart\";\nimport { LegendOverlay, type LegendVariant } from \"@/registry/ui/echarts-legend\";\nimport type { ComposeOption, ImagePatternObject } from \"echarts/core\";\nimport { BarChart, type BarSeriesOption } from \"echarts/charts\";\nimport { sampleGradient } from \"@/registry/ui/echarts-dot\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { CanvasRenderer } from \"echarts/renderers\";\nimport * as echarts from \"echarts/core\";\n\n// Re-export the shared types that were previously declared inline here, so\n// existing consumers/examples keep importing them from the chart module.\nexport type { ChartConfig, LegendVariant, TooltipPosition, TooltipRoundness, TooltipVariant };\n\n// Modular registration keeps the bundle lean — only the pieces this chart needs.\n// `DataZoomComponent` bundles both the slider (brush footer) and inside (wheel/drag)\n// zoom. The brush's frame/handles/labels are raw zrender elements, not the\n// graphic component — see syncBrushOverlay. No LineChart: the main plot, the\n// loading skeleton, and the brush mini chart are ALL bar series.\necharts.use([BarChart, GridComponent, TooltipComponent, DataZoomComponent, CanvasRenderer]);\n\ntype EChartsInstance = ReturnType<typeof echarts.init>;\n\n// The exact option surface this chart uses — bar series, grid, tooltip, and\n// dataZoom, plus the axis options they pull in as dependencies. Narrower than\n// echarts' full EChartsOption, so a misspelled key fails the compile instead of\n// silently reaching setOption.\ntype EChartsOption = ComposeOption<\n  BarSeriesOption | GridComponentOption | TooltipComponentOption | DataZoomComponentOption\n>;\n\n// Single-entry views of the composed option's array-or-single fields — the\n// modular entry points don't export the axis option types directly.\ntype ArrayItem<T> = T extends readonly (infer U)[] ? U : T;\ntype XAxisOption = ArrayItem<NonNullable<EChartsOption[\"xAxis\"]>>;\ntype YAxisOption = ArrayItem<NonNullable<EChartsOption[\"yAxis\"]>>;\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Constants\n// ─────────────────────────────────────────────────────────────────────────────\n\nconst DEFAULT_BAR_RADIUS = 2;\nconst STROKE_WIDTH = 1; // buffer-bar outline width\nconst LOADING_ANIMATION_DURATION = 2000; // shimmer loop, in milliseconds\nconst BAR_GROW_DURATION = 500; // per-bar grow-in length, in milliseconds\nconst BAR_STAGGER = 50; // delay between consecutive bars in the reveal, in milliseconds\nconst LOADING_DEFAULT_BARS = 12;\n// `revealEndsAt` marks when the intro grow-in finishes. The stripped-cap post-layout\n// correction (a notMerge repush) waits for it so it never lands mid-entrance and\n// stomps the grow — the same reason the area chart tracks this timestamp.\nconst SELECTION_DIM = 0.3; // opacity of an unselected series while a selection is active\nconst HOVER_BLUR = 0.3; // opacity of the non-hovered bars while hover-highlight is on\n// Soft outer glow — the canvas analogue of the Recharts feGaussianBlur filter\n// (stdDeviation 8, alpha 0.5). A generous shadowBlur keeps the halo soft with no\n// hard rim; the shadowColor is sampled PER BAR so a multi-stop gradient series\n// glows in its own colors across the plot instead of one flat tint.\nconst GLOW_BLUR = 18; // shadowBlur radius, in device-independent pixels\nconst GLOW_OPACITY = 0.65; // per-datum shadowColor alpha, × the sampled series color\n\n// The `blocks` variant renders each bar as a stack of segments instead of a solid\n// column: a repeating tile paints a BLOCK_SIZE band then leaves a BLOCK_GAP of\n// transparency. The same tile, in a muted tone, fills the column's unused space\n// via ECharts' native `showBackground`, so the empty part of every bar reads as a\n// dim grid of the same blocks. Both tile from the renderer origin, so the bands\n// line up across every column.\n// The `expandable` variant draws every bar at full width but fills only a narrow\n// centre strip, so it reads as a thin line; hovering one grows its strip out to\n// the full width and back on leave. The bar geometry never changes — only the\n// horizontal extent of its fill — so nothing re-lays out mid-hover.\nconst EXPAND_COLLAPSED = 0.12; // resting strip width, as a fraction of the bar\nconst EXPAND_TAU = 70; // ease time-constant, in milliseconds (exponential approach)\n\nconst BLOCK_SIZE = 8; // filled segment height, in pixels\nconst BLOCK_GAP = 4; // transparent gap between segments, in pixels\nconst BLOCK_TRACK_OPACITY = 0.22; // unfilled block tone, x the muted-foreground alpha\n// Stacked segments would otherwise butt straight into each other and read as one\n// solid column. The separation is a REAL gap — transparent spacer series stacked\n// between the real ones — not a background-colored border: a border paints on all\n// four sides, so it outlines each segment (obvious the moment a bar glows) instead\n// of only parting them.\nconst STACK_SEGMENT_GAP = 4; // separation between stacked segments, in pixels\nconst MAX_HIGHLIGHT_DIM = 0.16; // non-winning columns under enableMaxValueHighlight, x muted-foreground\n\n// The `stripped` variant caps each bar with a small BRIGHT pill of CONSTANT pixel\n// height (Recharts draws a fixed ~2px strip on top of a dimmed body, identical on\n// tall and short bars). The cap is expressed PER DATUM as a fraction of that bar's\n// own pixel height, so a fixed pixel height maps to a shrinking fraction as the bar\n// grows — the fraction is derived at runtime from the measured value-axis\n// pixels-per-unit (see measureValuePxPerUnit). A canvas gradient alone can't do\n// this: its bright band is a fraction of the bounding box, so it would scale with\n// bar length (the bug this replaced).\nconst STRIPPED_CAP_HEIGHT = 4; // bright cap height, in device-independent pixels\nconst STRIPPED_BODY_ALPHA = 0.2; // dimmed bar body below the cap, × series color\nconst STRIPPED_CAP_MAX_FRACTION = 0.85; // cap never swallows a whole (very short) bar\nconst STRIPPED_FALLBACK_FRACTION = 0.12; // used before the axis geometry is measured\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Theme knobs — every neutral line in the chart draws from these. Base colors\n// come from the consumer's CSS tokens (resolved from the live DOM), so only the\n// opacity factors live here. Factors MULTIPLY the token's own alpha — a border\n// token that is already 10%-white stays subtle. Tune here, not in the builder.\n// ─────────────────────────────────────────────────────────────────────────────\n// Recharts draws its grid at border/50, but SVG dashes render pixel-crisp while\n// canvas at 2× DPR spreads a 1px line across device pixels — roughly halving\n// perceived intensity. Using the border token's full alpha lands both engines at\n// the same apparent brightness.\nconst GRID_LINE_OPACITY = 1; // dashed value-axis split lines, × border alpha\n// The skeleton is CLIPPED to a small sweeping window — only the bars inside it\n// exist, everything outside is fully transparent, like a spotlight sliding across.\nconst LOADING_SHIMMER_MAX_OPACITY = 0.22; // gray bar fill inside the window, × foreground alpha\nconst LOADING_SHIMMER_BAND = 0.2; // window half-width, fraction of the 45° sweep axis\nconst LOADING_SHIMMER_FEATHER = 0.2; // eased edge softening of the clip window\nconst BRUSH_FILL_OPACITY = 0.5; // mini-chart bar fill\nconst BRUSH_FILLER_OPACITY = 0; // selected-range wash — evil-brush draws none\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Public types\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport type BarVariant =\n  | \"default\"\n  | \"hatched\"\n  | \"duotone\"\n  | \"duotone-reverse\"\n  | \"gradient\"\n  | \"stripped\"\n  | \"blocks\"\n  | \"expandable\";\nexport type StackType = \"default\" | \"stacked\" | \"percent\";\nexport type BarLayout = \"vertical\" | \"horizontal\";\nexport type BarAnimationType =\n  | \"none\"\n  | \"left-to-right\"\n  | \"right-to-left\"\n  | \"center-out\"\n  | \"edges-in\";\n// TooltipVariant, TooltipRoundness, LegendVariant, and ChartConfig now live in\n// the shared @/registry/ui/echarts/* modules and are imported + re-exported at\n// the top of this file.\n\nexport interface EChartsBarChartProps<TData extends Record<string, unknown>> {\n  data: TData[]; // rows rendered by the chart\n  config: ChartConfig; // series colors + labels\n  xDataKey?: keyof TData & string; // category key — falls back to the axis dataKey / first free column\n  className?: string; // extra classes for the chart container\n  stackType?: StackType; // how multiple bars combine\n  layout?: BarLayout; // orientation of the bars\n  barRadius?: number; // default corner radius every <Bar> inherits\n  animation?: boolean; // master switch for the intro grow-in — false renders instantly\n  animationType?: BarAnimationType; // default grow-in order each <Bar> inherits\n  barGap?: number; // gap between bars within the same category, in pixels\n  barCategoryGap?: number; // gap between categories of bars, in pixels\n  defaultSelectedDataKey?: string | null; // series selected on first render\n  onSelectionChange?: (key: string | null) => void; // fires when the selected series changes\n  // Colors ONLY the tallest column and mutes the rest. With several series the\n  // comparison is per COLUMN — the totals across every series at that category —\n  // so a whole stack or group lights up together, not one bar inside it.\n  enableMaxValueHighlight?: boolean;\n  isLoading?: boolean; // shows the animated loading skeleton\n  loadingBars?: number; // number of bars in the loading skeleton\n  chartOptions?: Record<string, unknown>; // escape hatch merged over the built ECharts option\n  children?: ReactNode; // declarative config — <Bar>, <XAxis>, <YAxis>, <Grid>, <Tooltip>, <Legend>, <Brush>\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Composible parts — DECLARATIVE CONFIG. Every part renders `null`; the root\n// walks `children` by reference (child.type === Bar, …) to collect its props.\n// Presence semantics mirror the Recharts twin: omit a child and that part does\n// not render. These are never mounted into the tree — they only carry props.\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface BarProps {\n  dataKey: string; // series key — must exist on the data + config\n  variant?: BarVariant; // fill style for this bar only\n  radius?: number; // corner radius — falls back to the root barRadius\n  animationType?: BarAnimationType; // grow-in order — falls back to the root animationType\n  isClickable?: boolean; // lets this bar be selected by clicking it\n  enableHoverHighlight?: boolean; // dims the other bars while one is hovered\n  glowing?: boolean; // applies a soft outer glow to this bar\n  bufferBar?: boolean; // renders the last data point as a hatched \"buffer\" bar\n}\n\n/**\n * A single bar series. Declares its own fill variant, radius, glow, buffer, and\n * clickability. Renders nothing — the root reads these props to build the\n * ECharts series.\n */\nconst Bar: FC<BarProps> = () => null;\n\nexport interface XAxisProps {\n  dataKey?: string; // category key — overrides the root xDataKey (vertical layout)\n  // Category values are stringified, so the formatter always sees a string —\n  // letting examples share `(value) => value.substring(0, 3)` with the Recharts twin.\n  tickFormatter?: (value: string, index: number) => string; // formats x tick labels\n  label?: string; // axis title, centered below the x-position tick labels\n  hideDots?: boolean; // hides the tick dots beside this axis's labels\n}\n\n/**\n * The x-axis. Category axis in the default (vertical) layout, value axis when\n * `layout=\"horizontal\"`. Presence shows its tick labels. Renders nothing.\n */\nconst XAxis: FC<XAxisProps> = () => null;\n\nexport interface YAxisProps {\n  dataKey?: string; // category key — overrides the root xDataKey (horizontal layout)\n  tickFormatter?: (value: string, index: number) => string; // formats y tick labels\n  label?: string; // axis title, rotated alongside the y-position tick labels\n  hideDots?: boolean; // hides the tick dots beside this axis's labels\n}\n\n/**\n * The y-axis. Value axis in the default (vertical) layout, category axis when\n * `layout=\"horizontal\"`. Presence shows its tick labels. Renders nothing.\n */\nconst YAxis: FC<YAxisProps> = () => null;\n\n/** Presence shows the dashed split lines on the value axis. Renders nothing. */\nconst Grid: FC = () => null;\n\nexport interface TooltipProps {\n  variant?: TooltipVariant; // visual style of the tooltip surface\n  roundness?: TooltipRoundness; // border-radius of the tooltip\n  defaultIndex?: number; // data index the tooltip shows by default, with no hover\n  position?: TooltipPosition; // \"variable\" follows the pointer (default); \"fixed\" pins the tooltip near the top and only tracks the pointer's X\n}\n\n/** Presence enables the hover tooltip. Renders nothing. */\nconst Tooltip: FC<TooltipProps> = () => null;\n\nexport interface LegendProps {\n  variant?: LegendVariant; // visual style of the legend indicators\n  align?: \"left\" | \"center\" | \"right\"; // horizontal placement\n  verticalAlign?: \"top\" | \"middle\" | \"bottom\"; // vertical placement\n  isClickable?: boolean; // lets each entry toggle selection of its series\n}\n\n/** Presence enables the HTML legend overlay. Renders nothing. */\nconst Legend: FC<LegendProps> = () => null;\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Children collection — walk the declarative config into plain objects the\n// option builder consumes.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype BarSeriesConfig = {\n  dataKey: string;\n  variant: BarVariant;\n  radius?: number;\n  animationType?: BarAnimationType;\n  isClickable: boolean;\n  enableHoverHighlight: boolean;\n  glowing: boolean;\n  bufferBar: boolean;\n};\n\ntype AxisSlot = {\n  present: boolean;\n  dataKey?: string;\n  tickFormatter?: (value: string, index: number) => string;\n  label?: string;\n  hideDots: boolean;\n};\ntype TooltipSlot = {\n  present: boolean;\n  variant: TooltipVariant;\n  roundness: TooltipRoundness;\n  defaultIndex?: number;\n  position: TooltipPosition;\n};\ntype LegendSlot = {\n  present: boolean;\n  variant: LegendVariant;\n  align: \"left\" | \"center\" | \"right\";\n  verticalAlign: \"top\" | \"middle\" | \"bottom\";\n  isClickable: boolean;\n};\ntype BrushSlot = {\n  present: boolean; // a <Brush> child was passed — replaces the old showBrush prop\n  height?: number;\n  formatLabel?: (value: string, index: number) => string;\n  onChange?: (range: { startIndex: number; endIndex: number }) => void;\n};\n\ntype CollectedConfig = {\n  bars: BarSeriesConfig[];\n  xAxis: AxisSlot;\n  yAxis: AxisSlot;\n  showGrid: boolean;\n  tooltip: TooltipSlot;\n  legend: LegendSlot;\n  brush: BrushSlot;\n};\n\nfunction collectConfig(children: ReactNode): CollectedConfig {\n  const bars: BarSeriesConfig[] = [];\n  let xAxis: AxisSlot = { present: false, hideDots: false };\n  let yAxis: AxisSlot = { present: false, hideDots: false };\n  let showGrid = false;\n  let tooltip: TooltipSlot = {\n    present: false,\n    variant: \"default\",\n    roundness: \"lg\",\n    position: \"variable\",\n  };\n  let legend: LegendSlot = {\n    present: false,\n    variant: \"rounded-square\",\n    align: \"right\",\n    verticalAlign: \"top\",\n    isClickable: false,\n  };\n  let brush: BrushSlot = { present: false };\n\n  Children.forEach(children, (child) => {\n    if (!isValidElement(child)) return;\n    const type = child.type;\n\n    if (type === Bar) {\n      const props = child.props as BarProps;\n      bars.push({\n        dataKey: props.dataKey,\n        variant: props.variant ?? \"default\",\n        radius: props.radius,\n        animationType: props.animationType,\n        isClickable: props.isClickable ?? false,\n        enableHoverHighlight: props.enableHoverHighlight ?? false,\n        glowing: props.glowing ?? false,\n        bufferBar: props.bufferBar ?? false,\n      });\n    } else if (type === XAxis) {\n      const props = child.props as XAxisProps;\n      xAxis = {\n        present: true,\n        dataKey: props.dataKey,\n        tickFormatter: props.tickFormatter,\n        label: props.label,\n        hideDots: props.hideDots ?? false,\n      };\n    } else if (type === YAxis) {\n      const props = child.props as YAxisProps;\n      yAxis = {\n        present: true,\n        dataKey: props.dataKey,\n        tickFormatter: props.tickFormatter,\n        label: props.label,\n        hideDots: props.hideDots ?? false,\n      };\n    } else if (type === Grid) {\n      showGrid = true;\n    } else if (type === Tooltip) {\n      const props = child.props as TooltipProps;\n      tooltip = {\n        present: true,\n        variant: props.variant ?? \"default\",\n        roundness: props.roundness ?? \"lg\",\n        defaultIndex: props.defaultIndex,\n        position: props.position ?? \"variable\",\n      };\n    } else if (type === Legend) {\n      const props = child.props as LegendProps;\n      legend = {\n        present: true,\n        variant: props.variant ?? \"rounded-square\",\n        align: props.align ?? \"right\",\n        verticalAlign: props.verticalAlign ?? \"top\",\n        isClickable: props.isClickable ?? false,\n      };\n    } else if (type === Brush) {\n      const props = child.props as BrushProps;\n      brush = {\n        present: true,\n        height: props.height,\n        formatLabel: props.formatLabel,\n        onChange: props.onChange,\n      };\n    }\n  });\n\n  return { bars, xAxis, yAxis, showGrid, tooltip, legend, brush };\n}\n\n// Color plumbing (ChartConfig, getColorsCount, distributeColors, buildChartCss,\n// normalizeColor, withAlpha, ResolvedColors, resolveColors, flattenColor) plus\n// the theme keys now live in @/registry/ui/echarts-chart and are imported at the\n// top of this file.\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Fill paints — the ECharts analogue of the Recharts bar fill variants. Unlike\n// the area chart's fills (which run the color gradient HORIZONTALLY), a bar's\n// base color gradient runs VERTICALLY top→bottom (Recharts `ColorGradient` uses\n// x1=x2=0), so each bar shows the full multi-stop gradient in its own box.\n// ─────────────────────────────────────────────────────────────────────────────\n\nconst GRAY = \"rgba(120, 120, 120, 1)\";\n\n// sampleGradient (the color the series gradient shows at t ∈ [0, 1]) now lives in\n// @/registry/ui/echarts-dot and is imported at the top of this file.\n\n// Solid vertical top→bottom color for a series — a plain string when there is\n// one color, else a vertical multi-stop LinearGradient in each bar's own box.\n// The `default` variant paints from this at full alpha.\nfunction solidVerticalPaint(\n  slots: string[],\n  alpha: number,\n): string | echarts.graphic.LinearGradient {\n  if (slots.length <= 1) {\n    const base = slots[0] ?? GRAY;\n    return alpha === 1 ? base : withAlpha(base, alpha);\n  }\n  const stops = slots.map((color, i) => ({\n    offset: i / (slots.length - 1),\n    color: withAlpha(color, alpha),\n  }));\n  return new echarts.graphic.LinearGradient(0, 0, 0, 1, stops);\n}\n\n// The `gradient` variant: the vertical color gradient faded from solid at the\n// top to clear at the bottom. Recharts masks with white@1 at 20% → white@0 at\n// 90%, so the alpha holds full through the top fifth and vanishes by 90%.\nfunction verticalFadePaint(slots: string[]): echarts.graphic.LinearGradient {\n  const offsets = [0, 0.2, 0.45, 0.7, 0.9, 1];\n  const alphaAt = (t: number) => (t <= 0.2 ? 1 : t >= 0.9 ? 0 : 1 - (t - 0.2) / 0.7);\n  const stops = offsets.map((t) => ({\n    offset: t,\n    color: withAlpha(sampleGradient(slots, t), alphaAt(t)),\n  }));\n  return new echarts.graphic.LinearGradient(0, 0, 0, 1, stops);\n}\n\n// The `duotone` family: a hard alpha split across the bar's short axis (its width\n// for vertical bars, its height for horizontal). Recharts splits at 50% via an\n// objectBoundingBox mask — exact for single-color series; multi-color duotone\n// falls back to the base color (an accepted approximation, matching the twin's\n// single-color examples).\nfunction duotoneSplitPaint(\n  base: string,\n  leftAlpha: number,\n  rightAlpha: number,\n  isHorizontal: boolean,\n): echarts.graphic.LinearGradient {\n  const stops = [\n    { offset: 0, color: withAlpha(base, leftAlpha) },\n    { offset: 0.5, color: withAlpha(base, leftAlpha) },\n    { offset: 0.5, color: withAlpha(base, rightAlpha) },\n    { offset: 1, color: withAlpha(base, rightAlpha) },\n  ];\n  // Split across the cross-axis: horizontal (0→1 in x) for vertical bars, and\n  // vertical (0→1 in y) for horizontal bars, so it always reads across the bar.\n  return isHorizontal\n    ? new echarts.graphic.LinearGradient(0, 0, 0, 1, stops)\n    : new echarts.graphic.LinearGradient(1, 0, 0, 0, stops);\n}\n\n// The `stripped` variant: a small BRIGHT cap sitting on top of a dimmed (20%) body\n// — the canvas twin of Recharts' fixed strip. The cap is baked into a per-datum\n// vertical gradient whose bright band spans exactly `capFraction` of the bar\n// (offset 0 = the tip). Because `capFraction` is passed in as\n// STRIPPED_CAP_HEIGHT / barPixelHeight (see strippedCapFraction), the cap reads the\n// SAME pixel height on every bar — the fraction shrinks as the bar grows. A hard\n// two-stop edge (coincident offsets at `capFraction`) keeps the cap a crisp pill\n// rather than a fade, and the bar's rounded top corners round the cap's top. The\n// cap sits at the tip: the top for vertical bars, the value end (right) for\n// horizontal.\nfunction strippedDatumPaint(\n  slots: string[],\n  isHorizontal: boolean,\n  capFraction: number,\n): echarts.graphic.LinearGradient {\n  const f = Math.min(Math.max(capFraction, 0), 1);\n  const cap = withAlpha(sampleGradient(slots, 0), 1);\n  const bodyTop = withAlpha(sampleGradient(slots, f), STRIPPED_BODY_ALPHA);\n  const bodyEnd = withAlpha(sampleGradient(slots, 1), STRIPPED_BODY_ALPHA);\n  const stops = [\n    { offset: 0, color: cap },\n    { offset: f, color: cap },\n    { offset: f, color: bodyTop },\n    { offset: 1, color: bodyEnd },\n  ];\n  // Tip at offset 0: top (y 0→1) for vertical bars, right (x 1→0) for horizontal.\n  return isHorizontal\n    ? new echarts.graphic.LinearGradient(1, 0, 0, 0, stops)\n    : new echarts.graphic.LinearGradient(0, 0, 0, 1, stops);\n}\n\n// The gradient fraction that renders a STRIPPED_CAP_HEIGHT-pixel cap on a bar whose\n// value-axis magnitude is `value`, given the measured pixels-per-unit. Falls back to\n// a small constant before the coordinate system has been measured (the very first\n// paint, corrected right after layout).\nfunction strippedCapFraction(value: number, valuePxPerUnit: number | null): number {\n  if (valuePxPerUnit == null) return STRIPPED_FALLBACK_FRACTION;\n  const barPx = Math.abs(value) * valuePxPerUnit;\n  if (!(barPx > 0)) return STRIPPED_FALLBACK_FRACTION;\n  return Math.min(STRIPPED_CAP_HEIGHT / barPx, STRIPPED_CAP_MAX_FRACTION);\n}\n\n// Pixels per one value-axis unit, read straight off the live coordinate system.\n// Returns null before the first layout (no coordinate system yet) — callers fall\n// back then. Turns the stripped cap's fixed pixel height into a per-bar gradient\n// fraction, so the cap stays constant as the value axis rescales on resize/zoom.\nfunction measureValuePxPerUnit(chart: EChartsInstance, isHorizontal: boolean): number | null {\n  const finder = isHorizontal ? { xAxisIndex: 0 } : { yAxisIndex: 0 };\n  // convertToPixel throws before the first setOption (no coordinate system yet) and\n  // whenever the value axis isn't laid out — treat any failure as \"not measurable\".\n  try {\n    const p0 = chart.convertToPixel(finder, 0);\n    const p1 = chart.convertToPixel(finder, 1);\n    if (typeof p0 !== \"number\" || typeof p1 !== \"number\") return null;\n    const delta = Math.abs(p1 - p0);\n    return Number.isFinite(delta) && delta > 0 ? delta : null;\n  } catch {\n    return null;\n  }\n}\n\n// Measures the rendered width of one bar, so the `blocks` variant can make its\n// segments square (their width IS the bar width, and only layout knows it). The\n// category pitch comes from the axis; the bar occupies that minus the category\n// gap — a px number when the consumer set one, else ECharts' own \"20%\" default.\nfunction measureBarWidthPx(\n  chart: EChartsInstance,\n  isHorizontal: boolean,\n  barCategoryGap: number | undefined,\n): number | null {\n  const finder = isHorizontal ? { yAxisIndex: 0 } : { xAxisIndex: 0 };\n  try {\n    const p0 = chart.convertToPixel(finder, 0);\n    const p1 = chart.convertToPixel(finder, 1);\n    if (typeof p0 !== \"number\" || typeof p1 !== \"number\") return null;\n    const pitch = Math.abs(p1 - p0);\n    if (!Number.isFinite(pitch) || pitch <= 0) return null;\n    const width = barCategoryGap != null ? pitch - barCategoryGap : pitch * 0.8;\n    return width > 1 ? width : null;\n  } catch {\n    return null;\n  }\n}\n\n// Tiling texture fills tinted with the series' first color. Stripes are drawn\n// STRAIGHT (trivially seamless) and the pattern itself is rotated — zrender\n// applies pattern transforms the same way ECharts decals do. Baking a diagonal\n// into a square tile clips the stroke at the corners, which reads as periodic\n// gaps once tiled. Tiles render at devicePixelRatio and scale back down so the\n// texture stays crisp on retina canvases.\nfunction patternFill(\n  kind: \"hatched\" | \"buffer\" | \"blocks\",\n  color: string,\n  blockSize = BLOCK_SIZE,\n): ImagePatternObject | null {\n  if (typeof document === \"undefined\") return null;\n  const dpr = Math.max(window.devicePixelRatio || 1, 1);\n  const canvas = document.createElement(\"canvas\");\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) return null;\n\n  const size = (width: number, height: number) => {\n    canvas.width = width * dpr;\n    canvas.height = height * dpr;\n    ctx.scale(dpr, dpr);\n  };\n  const pattern = (rotation = 0): ImagePatternObject => ({\n    image: canvas,\n    repeat: \"repeat\",\n    rotation,\n    scaleX: 1 / dpr,\n    scaleY: 1 / dpr,\n  });\n\n  if (kind === \"blocks\") {\n    // 1px-wide tile: it repeats horizontally into a full-width band, and\n    // vertically into the stack of blocks.\n    size(1, blockSize + BLOCK_GAP);\n    ctx.fillStyle = withAlpha(color, 1);\n    ctx.fillRect(0, 0, 1, blockSize);\n    return pattern();\n  }\n\n  if (kind === \"hatched\") {\n    // Recharts hatched: the color shown at 0.3 everywhere, punched to full along\n    // a 1.5px stripe every 5px, leaning -45°.\n    size(5, 5);\n    ctx.fillStyle = withAlpha(color, 0.3);\n    ctx.fillRect(0, 0, 5, 5);\n    ctx.fillStyle = withAlpha(color, 1);\n    ctx.fillRect(0, 0, 1.5, 5);\n    return pattern(-Math.PI / 4);\n  }\n\n  // buffer: bare diagonal lines on a transparent ground (no body fill), for the\n  // last \"projected\" bar.\n  size(5, 5);\n  ctx.fillStyle = withAlpha(color, 1);\n  ctx.fillRect(0, 0, 1, 5);\n  return pattern(-Math.PI / 4);\n}\n\n// The `expandable` fill at a given openness: a horizontal gradient with HARD\n// stops, transparent outside the centre strip and the series paint inside it.\n// Animating `fraction` slides those stops outward from the middle, which is the\n// expand; a real width change would relayout the bar group instead.\nfunction expandableDatumPaint(slots: string[], fraction: number): echarts.graphic.LinearGradient {\n  const base = slots[0] ?? GRAY;\n  const half = Math.max(0, Math.min(1, fraction)) / 2;\n  const left = 0.5 - half;\n  const right = 0.5 + half;\n  const clear = withAlpha(base, 0);\n  return new echarts.graphic.LinearGradient(0, 0, 1, 0, [\n    { offset: 0, color: clear },\n    { offset: left, color: clear },\n    { offset: left, color: base },\n    { offset: right, color: base },\n    { offset: right, color: clear },\n    { offset: 1, color: clear },\n  ]);\n}\n\n// Resolves a bar variant into an ECharts fill for its series. `base` is the\n// first color slot; `slots` the full vertical color run.\nfunction barFillPaint(\n  variant: BarVariant,\n  slots: string[],\n  isHorizontal: boolean,\n  blockSize = BLOCK_SIZE,\n): string | echarts.graphic.LinearGradient | ImagePatternObject {\n  const base = slots[0] ?? GRAY;\n  switch (variant) {\n    case \"gradient\":\n      return verticalFadePaint(slots);\n    case \"duotone\":\n      return duotoneSplitPaint(base, 0.4, 1, isHorizontal);\n    case \"duotone-reverse\":\n      return duotoneSplitPaint(base, 1, 0.4, isHorizontal);\n    case \"hatched\":\n      return patternFill(\"hatched\", base) ?? solidVerticalPaint(slots, 1);\n    case \"blocks\":\n      return patternFill(\"blocks\", base, blockSize) ?? solidVerticalPaint(slots, 1);\n    case \"expandable\":\n      // Series-level fallback only; buildBarSeries gives every datum its own\n      // openness so a single hovered bar can expand on its own.\n      return expandableDatumPaint(slots, EXPAND_COLLAPSED);\n    case \"stripped\":\n      // Series-level fallback only; buildBarSeries overrides every stripped datum\n      // with its own fixed-pixel cap fraction.\n      return strippedDatumPaint(slots, isHorizontal, STRIPPED_FALLBACK_FRACTION);\n    default:\n      return solidVerticalPaint(slots, 1);\n  }\n}\n\n// Border radius per variant/layout. Non-stripped bars round every corner\n// (Recharts passes a plain number); stripped rounds only the tip corners — the\n// top for vertical bars, the right end for horizontal.\nfunction barBorderRadius(\n  radius: number,\n  variant: BarVariant,\n  isHorizontal: boolean,\n): number | number[] {\n  // Blocks draw their own square segments; a radius would clip the end one. An\n  // expandable bar is a thin line at rest, where a radius would swallow it.\n  if (variant === \"blocks\" || variant === \"expandable\") return 0;\n  if (variant !== \"stripped\") return radius;\n  // ECharts corner order: [top-left, top-right, bottom-right, bottom-left].\n  return isHorizontal ? [0, radius, radius, 0] : [radius, radius, 0, 0];\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Selection + entrance helpers\n// ─────────────────────────────────────────────────────────────────────────────\n\n// A bar dims to SELECTION_DIM only when a DIFFERENT series is selected.\nfunction selectionOpacity(selected: string | null, key: string): number {\n  return selected === null || selected === key ? 1 : SELECTION_DIM;\n}\n\n// How many stagger steps a bar at `index` waits before it grows in — the order\n// encoded by `animationType`. Bars are independent rectangles, so unlike the\n// area chart's single left-to-right clip, the direction values are honored here\n// via a per-datum `animationDelay`.\nfunction barStaggerDelay(type: BarAnimationType, index: number, count: number): number {\n  if (type === \"none\" || count <= 0) return 0;\n  const last = count - 1;\n  const center = last / 2;\n  let step: number;\n  switch (type) {\n    case \"right-to-left\":\n      step = last - index;\n      break;\n    case \"center-out\":\n      step = Math.abs(index - center);\n      break;\n    case \"edges-in\":\n      step = center - Math.abs(index - center);\n      break;\n    default: // left-to-right\n      step = index;\n  }\n  return step * BAR_STAGGER;\n}\n\n// The brush overlay primitives (BrushRange, BrushGeometry, BrushOverlayElements,\n// syncBrushOverlay) and the dataZoom builder (buildBrushDataZoom) now live in\n// @/registry/ui/echarts-brush and are imported at the top of this file. The\n// tooltip shell/row/styling (roundnessClass, tooltipVariantClass,\n// tooltipShell/tooltipRow/tooltipIndicatorHtml), the legend indicators\n// (LegendIndicator/LegendOverlay + fill/outline styles), and indicatorBackground\n// likewise live in the shared tooltip/legend/chart modules.\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Option builders — pure functions from a snapshot context to ECharts option\n// fragments. The component reads its refs ONCE per build into this context;\n// nothing below touches React state or the chart instance, so each fragment can\n// be reasoned about in isolation.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype OptionBuildContext = {\n  data: Record<string, unknown>[];\n  config: ChartConfig;\n  bars: BarSeriesConfig[];\n  seriesKeys: string[];\n  animationType: BarAnimationType;\n  barRadius: number;\n  isHorizontal: boolean;\n  isStacked: boolean;\n  isPercent: boolean;\n  selectedDataKey: string | null;\n  hasSelection: boolean;\n  showGrid: boolean;\n  // Category axis is x (vertical layout) or y (horizontal); value axis the other.\n  categorySlot: AxisSlot;\n  valueSlot: AxisSlot;\n  tooltipSlot: TooltipSlot;\n  legendSlot: LegendSlot;\n  isLoading: boolean;\n  loadingData: () => number[];\n  showBrush: boolean;\n  brushHeight: number;\n  barGap?: number;\n  barCategoryGap?: number;\n  resolved: ResolvedColors;\n  categories: string[];\n  brushRange: BrushRange; // zoom window carried through rebuilds\n  valuePxPerUnit: number | null; // measured value-axis pixels-per-unit (null pre-layout)\n  barWidthPx: number | null; // measured bar width — sizes the blocks variant's squares (null pre-layout)\n  // Openness per bar index for the expandable variant, plus which one the pointer\n  // is on — driven by the hover rAF, read at build.\n  expand: { key: string | null; hovered: number | null; progress: Map<number, number> };\n  maxHighlightIndex: number | null; // column to keep colored under enableMaxValueHighlight\n};\n\n// Grid insets plus the footer band reserved for the brush. ECharts 6 contains\n// axis labels automatically (the legacy `containLabel` flag now only triggers a\n// deprecation warning).\nfunction buildChartLayout({\n  legendSlot,\n  showBrush,\n  brushHeight,\n  isHorizontal,\n  categorySlot,\n  valueSlot,\n}: OptionBuildContext): {\n  grid: GridComponentOption;\n  brushBottom: number;\n} {\n  const legendTop = legendSlot.present && legendSlot.verticalAlign === \"top\";\n  const legendBottom = legendSlot.present && legendSlot.verticalAlign === \"bottom\";\n  // Clearance covers the axis labels plus the same breathing room the Recharts\n  // twin leaves between them and the brush. A bottom-axis TITLE renders below the\n  // labels (nameGap), so it needs its own band above the brush frame. The brush\n  // is vertical-layout only, where the bottom (x-position) axis is the category axis.\n  const bottomAxisLabel = isHorizontal ? valueSlot.label : categorySlot.label;\n  const brushGap = showBrush ? brushHeight + 30 + (bottomAxisLabel ? 22 : 0) : 0;\n\n  return {\n    grid: {\n      left: 8,\n      right: 8,\n      top: legendTop ? 42 : 16,\n      bottom: 8 + brushGap + (legendBottom ? 34 : 0),\n    },\n    brushBottom: legendBottom ? 34 : 6,\n  };\n}\n\n// The category + value axes, laid onto x/y per layout. Vertical bars → x is\n// category, y is value; horizontal bars → x is value, y is category.\nfunction buildMainAxes(ctx: OptionBuildContext): { xAxis: XAxisOption; yAxis: YAxisOption } {\n  const {\n    isHorizontal,\n    showGrid,\n    isLoading,\n    isPercent,\n    categories,\n    loadingData,\n    categorySlot,\n    valueSlot,\n  } = ctx;\n  const { tokens } = ctx.resolved;\n\n  const axisLabelColor = tokens.mutedForeground;\n  const splitLineColor = withAlpha(tokens.border, GRID_LINE_OPACITY);\n  // Gridline gray as an opaque color — see flattenColor.\n  const tickDotColor = flattenColor(splitLineColor, tokens.background);\n  const catData = isLoading ? loadingData().map((_, i) => i) : categories;\n  const catFormatter = categorySlot.tickFormatter;\n  const valFormatter = valueSlot.tickFormatter;\n\n  // The axis title (name) follows the axis PART, not its category/value role: the\n  // category axis wears whichever <XAxis>/<YAxis> child renders it per layout, and\n  // its label sits at that child's physical position. nameGap is 30 for the bottom\n  // (x-position) axis and 38 for the side (y-position) one, so it swaps with the\n  // layout alongside the axisLabel styling.\n  const categoryNameGap = isHorizontal ? 38 : 30;\n  const valueNameGap = isHorizontal ? 30 : 38;\n\n  // NOTE: these are left un-annotated so their inferred literal type stays free\n  // of an axis-specific `position` — the layout swap below assigns the category\n  // axis to y (and the value axis to x) for horizontal bars, and XAxisOption vs\n  // YAxisOption disagree on `position`, so a fixed annotation would reject one\n  // branch. `type` is pinned with `as const` to satisfy the axis-kind union.\n  const categoryAxis = {\n    type: \"category\" as const,\n    // Bars sit BETWEEN ticks — the opposite of the area chart's boundaryGap:false.\n    boundaryGap: true,\n    show: true,\n    // The Recharts YAxis lists its first category at the TOP; ECharts' y category\n    // axis defaults to bottom-up, so flip it when the category axis is on y.\n    inverse: isHorizontal,\n    data: catData,\n    // Axis title — same size/color as the tick labels, pushed clear of them. The\n    // category axis carries the label of whichever <XAxis>/<YAxis> child renders it.\n    name: isLoading ? undefined : categorySlot.label,\n    nameLocation: \"middle\" as const,\n    nameGap: categoryNameGap,\n    nameTextStyle: { color: axisLabelColor, fontSize: 10 },\n    axisLine: { show: false },\n    // Tick DOTS: a near-zero-length tick whose round caps form a true circle, in\n    // the gridline gray (flattened opaque so the caps don't stack).\n    axisTick: {\n      show: !isLoading && categorySlot.present && !categorySlot.hideDots,\n      length: 0.5,\n      // Bars use boundaryGap, so ECharts would drop each tick on the BOUNDARY\n      // between two categories — a dot floating between labels rather than under\n      // one. Align them to the labels instead.\n      alignWithLabel: true,\n      lineStyle: { color: tickDotColor, width: 3, cap: \"round\" as const },\n    },\n    splitLine: { show: false },\n    axisLabel: {\n      show: !isLoading && categorySlot.present,\n      color: axisLabelColor,\n      fontSize: 10,\n      margin: 8,\n      formatter: catFormatter\n        ? (value: string, index: number) => catFormatter(value, index)\n        : undefined,\n    },\n  };\n\n  // An ECharts axis with `show: false` hides its splitLines too, but Recharts'\n  // <CartesianGrid> draws with or without a visible value axis. Keep the axis on\n  // whenever <Grid/> is present and gate the LABELS on the slot instead.\n  const valueAxis = {\n    type: \"value\" as const,\n    show: valueSlot.present || showGrid,\n    max: isPercent ? 1 : undefined,\n    // Axis title — same styling as the category axis; the value axis carries the\n    // label of the other of the two <XAxis>/<YAxis> children.\n    name: isLoading ? undefined : valueSlot.label,\n    nameLocation: \"middle\" as const,\n    nameGap: valueNameGap,\n    nameTextStyle: { color: axisLabelColor, fontSize: 10 },\n    axisLine: { show: false },\n    // Same tick dots as the category axis, beside each value label.\n    axisTick: {\n      show: valueSlot.present && !isLoading && !valueSlot.hideDots,\n      length: 0.5,\n      // Inert here — ECharts only honors it for CATEGORY ticks, and this axis is\n      // always type:\"value\". Carried so both axes' tick config stays identical.\n      lineStyle: { color: tickDotColor, width: 3, cap: \"round\" as const },\n    },\n    splitLine: {\n      // Hidden while loading — the skeleton floats on a clean canvas.\n      show: showGrid && !isLoading,\n      lineStyle: { color: splitLineColor, type: [3, 3] as [number, number], width: 1 },\n    },\n    axisLabel: {\n      // Hidden while loading — skeleton values are meaningless, and the Recharts\n      // axes unmount during loading too.\n      show: valueSlot.present && !isLoading,\n      color: axisLabelColor,\n      fontSize: 10,\n      margin: 8,\n      formatter: isPercent\n        ? (value: number) => `${Math.round(value * 100)}%`\n        : valFormatter\n          ? (value: number, index: number) => valFormatter(String(value), index)\n          : undefined,\n    },\n  };\n\n  return isHorizontal\n    ? { xAxis: valueAxis, yAxis: categoryAxis }\n    : { xAxis: categoryAxis, yAxis: valueAxis };\n}\n\n// Tooltip HTML builder, closed over the build context. Dims by the click\n// selection only — the Recharts twin passes `cursor={false}`, so there is no\n// axis-pointer line and hover-highlight never touches the tooltip.\nfunction createTooltipFormatter(ctx: OptionBuildContext) {\n  const { config, selectedDataKey, tooltipSlot } = ctx;\n\n  return (params: unknown): string => {\n    const rows = Array.isArray(params) ? params : [params];\n    if (!rows.length) return \"\";\n\n    const first = rows[0] as { axisValue?: string | number; name?: string };\n    // Label shows the RAW axis value — matches ChartTooltipContent (no tick formatter).\n    const axisValue = first.axisValue ?? first.name ?? \"\";\n    const label = String(axisValue);\n\n    const body = rows\n      .map((param) => {\n        const p = param as {\n          seriesId?: string;\n          seriesName?: string;\n          value?: number | string;\n        };\n        // Internal series (the brush's mini chart, the loading skeleton) never\n        // surface in the tooltip.\n        if (String(p.seriesId ?? \"\").startsWith(\"__\")) return \"\";\n        const key = p.seriesId ?? p.seriesName ?? \"\";\n        const item = config[key];\n        const colorsCount = item ? getColorsCount(item) : 1;\n        const labelText = typeof item?.label === \"string\" ? item.label : (p.seriesName ?? key);\n        const dimmed = selectedDataKey != null && selectedDataKey !== key ? \" opacity-30\" : \"\";\n        const value =\n          typeof p.value === \"number\" ? p.value.toLocaleString() : String(p.value ?? \"\");\n\n        return tooltipRow({\n          indicatorHtml: tooltipIndicatorHtml(key, colorsCount),\n          labelText,\n          valueText: value,\n          dimmed,\n        });\n      })\n      .join(\"\");\n\n    return tooltipShell({\n      label,\n      body,\n      roundness: tooltipSlot.roundness,\n      variant: tooltipSlot.variant,\n    });\n  };\n}\n\nfunction buildTooltipOption(ctx: OptionBuildContext): TooltipComponentOption {\n  const { tooltipSlot, isLoading } = ctx;\n  const { tokens } = ctx.resolved;\n\n  return {\n    ...tooltipBaseOption({\n      present: tooltipSlot.present && !isLoading,\n      // The twin disables the cursor (`cursor={false}`) — no shadow, no line —\n      // so the axisPointer color/width below go unused; only `position` applies.\n      cursor: false,\n      tokens,\n      position: tooltipSlot.position,\n      axisPointerColor: tokens.border,\n      strokeWidth: STROKE_WIDTH,\n    }),\n    formatter: createTooltipFormatter(ctx),\n  };\n}\n\n// ── Brush — the evil-brush \"bar\" look, canvas-style: a real mini chart of the\n// full data in a second grid, with a transparent slider dataZoom laid over it.\n// Both zoom entries target only the MAIN x-axis, so the mini chart never filters\n// itself. Only called for the vertical layout, where the category axis is x.\nfunction buildBrushOption(\n  ctx: OptionBuildContext,\n  brushBottom: number,\n): {\n  miniGrid: GridComponentOption;\n  miniXAxis: XAxisOption;\n  miniYAxis: YAxisOption;\n  miniSeries: BarSeriesOption[];\n  dataZoom: DataZoomComponentOption[];\n} {\n  const { data, bars, isStacked, selectedDataKey, hasSelection, brushHeight, categories } = ctx;\n  const { tokens } = ctx.resolved;\n\n  const miniGrid: GridComponentOption = {\n    left: 8,\n    right: 8,\n    bottom: brushBottom,\n    height: brushHeight,\n    // No visible axes here — opt out of label containment so the mini chart\n    // spans the full brush frame.\n    outerBoundsMode: \"none\",\n  };\n\n  const miniXAxis: XAxisOption = {\n    type: \"category\",\n    gridIndex: 1,\n    boundaryGap: true,\n    show: false,\n    data: categories,\n    axisPointer: { show: false },\n  };\n\n  const miniYAxis: YAxisOption = { type: \"value\", gridIndex: 1, show: false };\n\n  const miniSeries: BarSeriesOption[] = bars.map((bar) => {\n    const key = bar.dataKey;\n    const base = (ctx.resolved.series[key] ?? [])[0] ?? GRAY;\n    // The mini chart mirrors the click selection: unselected series recede.\n    const dim = hasSelection && selectedDataKey !== key ? SELECTION_DIM : 1;\n\n    return {\n      id: `__mini-${key}`,\n      type: \"bar\",\n      xAxisIndex: 1,\n      yAxisIndex: 1,\n      data: data.map((row) => Number(row[key]) || 0),\n      stack: isStacked ? \"__mini-total\" : undefined,\n      silent: true,\n      barCategoryGap: \"20%\",\n      emphasis: { disabled: true },\n      tooltip: { show: false },\n      itemStyle: { color: base, opacity: BRUSH_FILL_OPACITY * dim, borderRadius: 1 },\n      z: 0,\n      animation: false,\n    };\n  });\n\n  const dataZoom = buildBrushDataZoom({\n    brushBottom,\n    brushHeight,\n    brushRange: ctx.brushRange,\n    fillerColor: withAlpha(tokens.foreground, BRUSH_FILLER_OPACITY),\n  });\n\n  return { miniGrid, miniXAxis, miniYAxis, miniSeries, dataZoom };\n}\n\n// Loading skeleton — ONE gray row of bars regardless of declared series (Recharts\n// parity: its skeleton is a single LoadingBar), swept by the shimmer rAF.\nfunction buildLoadingOption(\n  ctx: OptionBuildContext,\n  frame: { grid: GridComponentOption; xAxis: XAxisOption; yAxis: YAxisOption },\n): EChartsOption {\n  const { tokens } = ctx.resolved;\n\n  return {\n    animation: false,\n    grid: frame.grid,\n    xAxis: frame.xAxis,\n    yAxis: frame.yAxis,\n    tooltip: { show: false },\n    series: [\n      {\n        id: \"__loading\",\n        type: \"bar\",\n        data: ctx.loadingData(),\n        barCategoryGap: \"30%\",\n        silent: true,\n        // Invisible until the first shimmer tick positions the clip window.\n        itemStyle: {\n          color: withAlpha(tokens.foreground, 0),\n          borderRadius: barBorderRadius(DEFAULT_BAR_RADIUS, \"default\", ctx.isHorizontal),\n        },\n        z: 1,\n      },\n    ],\n  };\n}\n\nfunction buildBarSeries(ctx: OptionBuildContext): BarSeriesOption[] {\n  const {\n    data,\n    config,\n    bars,\n    seriesKeys,\n    animationType,\n    isHorizontal,\n    isStacked,\n    isPercent,\n    selectedDataKey,\n    hasSelection,\n    barGap,\n    barCategoryGap,\n    resolved,\n  } = ctx;\n\n  const lastIndex = data.length - 1;\n\n  // Optional per-row normalization for the percent (100%) stack.\n  const rowTotals = isPercent\n    ? data.map((row) => seriesKeys.reduce((sum, key) => sum + (Number(row[key]) || 0), 0))\n    : [];\n\n  const series: BarSeriesOption[] = bars.map((bar) => {\n    const key = bar.dataKey;\n    const slots = resolved.series[key] ?? [GRAY];\n    const base = slots[0] ?? GRAY;\n    const isSelected = selectedDataKey === key;\n    const dim = selectionOpacity(selectedDataKey, key);\n    const resolvedRadius = bar.radius ?? ctx.barRadius;\n    const borderRadius = barBorderRadius(resolvedRadius, bar.variant, isHorizontal);\n    // Square segments: the tile's height matches the measured bar width, so each\n    // block is 1:1. Falls back to BLOCK_SIZE on the first push, before layout.\n    const blockSize = ctx.barWidthPx ?? BLOCK_SIZE;\n    const fill = barFillPaint(bar.variant, slots, isHorizontal, blockSize);\n    const barAnim = bar.animationType ?? animationType;\n    const isStripped = bar.variant === \"stripped\";\n    const isExpandable = bar.variant === \"expandable\";\n    // Under enableMaxValueHighlight every column except the tallest is muted, so a\n    // single flat tone replaces whatever fill the variant would have painted.\n    const mutedFill = withAlpha(resolved.tokens.mutedForeground, MAX_HIGHLIGHT_DIM);\n    const isMuted = (i: number) => ctx.maxHighlightIndex != null && i !== ctx.maxHighlightIndex;\n\n    // Openness per datum, driven by the hover rAF. Bars not in the map are shut.\n    const expandOf = (i: number) =>\n      ctx.expand.key === key ? (ctx.expand.progress.get(i) ?? EXPAND_COLLAPSED) : EXPAND_COLLAPSED;\n    const expandHovered = ctx.expand.key === key ? ctx.expand.hovered : null;\n    // The unfilled part of a blocks bar: the same tile in a muted tone, drawn by\n    // ECharts' own bar background so it spans the column's full height.\n    const isBlocks = bar.variant === \"blocks\";\n    const blockTrack = isBlocks\n      ? patternFill(\n          \"blocks\",\n          withAlpha(resolved.tokens.mutedForeground, BLOCK_TRACK_OPACITY),\n          blockSize,\n        )\n      : null;\n\n    const values = data.map((row, i) => {\n      const value = Number(row[key]) || 0;\n      if (!isPercent) return value;\n      const total = rowTotals[i];\n      return total ? value / total : 0;\n    });\n\n    // Buffer bar: the last datum becomes a bare hatched rectangle with a\n    // series-colored outline, marking projected/incomplete data.\n    const bufferStyle = bar.bufferBar\n      ? {\n          color: patternFill(\"buffer\", base) ?? \"transparent\",\n          borderColor: base,\n          borderWidth: STROKE_WIDTH,\n          borderRadius,\n        }\n      : null;\n\n    // Per-bar glow shadow — the shadowColor is sampled from the series gradient\n    // at each bar's horizontal position, so a multi-stop series glows in its own\n    // colors across the plot instead of one flat tint (a single shadowColor was\n    // the bug). A canvas shape carries only one shadow, so the sample is per bar,\n    // not within a bar; the wide, soft shadowBlur reads as the Recharts blur's\n    // colored halo with no hard rim.\n    // `glowing` haloes every bar in the series; enableMaxValueHighlight haloes only\n    // the winning column — the muted ones must stay flat or the \"one bar stands\n    // out\" reading collapses. Same shadow either way, so the two share a builder.\n    const glowAt = (i: number) => ({\n      shadowBlur: GLOW_BLUR,\n      shadowColor: withAlpha(\n        sampleGradient(slots, values.length > 1 ? i / (values.length - 1) : 0),\n        GLOW_OPACITY,\n      ),\n    });\n    const glowFor = bar.glowing\n      ? glowAt\n      : ctx.maxHighlightIndex != null\n        ? (i: number) => (i === ctx.maxHighlightIndex ? glowAt(i) : {})\n        : null;\n\n    // Only wrap a datum in an object when it needs per-point overrides (stripped\n    // cap, buffer tip, or glow); otherwise keep the bare number so the series\n    // itemStyle applies untouched. Stripped and glow touch every datum; buffer only\n    // the last one.\n    const dataPoints =\n      isStripped ||\n      isExpandable ||\n      glowFor ||\n      ctx.maxHighlightIndex != null ||\n      (bufferStyle && lastIndex >= 0)\n        ? values.map((value, i) => {\n            const isBuffer = !!bufferStyle && i === lastIndex;\n            if (!isBuffer && !glowFor && !isStripped && !isExpandable && !isMuted(i)) return value;\n            return {\n              value,\n              ...(isExpandable ? { label: { show: i === expandHovered } } : {}),\n              itemStyle: {\n                // The stripped cap is per datum: its fixed pixel height becomes a\n                // fraction of THIS bar's own height, so the cap is a constant pixel\n                // height across bars. The buffer tip (bare hatched) still wins on\n                // the last datum.\n                ...(isStripped && !isBuffer\n                  ? {\n                      color: strippedDatumPaint(\n                        slots,\n                        isHorizontal,\n                        strippedCapFraction(value, ctx.valuePxPerUnit),\n                      ),\n                    }\n                  : {}),\n                ...(isExpandable && !isBuffer\n                  ? { color: expandableDatumPaint(slots, expandOf(i)) }\n                  : {}),\n                ...(isBuffer && bufferStyle ? bufferStyle : {}),\n                ...(glowFor ? glowFor(i) : {}),\n                // Last so it overrides the variant's own paint.\n                ...(isMuted(i) ? { color: mutedFill } : {}),\n              },\n            };\n          })\n        : values;\n\n    return {\n      id: key,\n      name: typeof config[key]?.label === \"string\" ? config[key]?.label : key,\n      type: \"bar\",\n      data: dataPoints,\n      stack: isStacked ? \"total\" : undefined,\n      barGap,\n      barCategoryGap,\n      cursor: bar.isClickable ? \"pointer\" : \"default\",\n      // Selected series ride on top; when a selection is active the rest sink below.\n      z: isSelected ? 3 : hasSelection ? 1 : 2,\n      // The hovered bar names its value above itself (Recharts twin parity).\n      label: isExpandable\n        ? {\n            show: false,\n            position: \"top\",\n            color: resolved.tokens.foreground,\n            fontFamily: \"var(--font-mono, monospace)\",\n            fontSize: 11,\n          }\n        : undefined,\n      showBackground: isBlocks,\n      backgroundStyle: blockTrack ? { color: blockTrack, borderRadius } : undefined,\n      itemStyle: {\n        color: fill,\n        borderRadius,\n        opacity: dim,\n        // The glow lives on each datum's itemStyle (per-bar sampled shadowColor),\n        // not here — a single series-level shadowColor can't follow a gradient.\n      },\n      // Hover-highlight uses ECharts-native focus/blur: `self` keeps only the\n      // hovered bar lit and dims every other, matching the twin's per-bar dim.\n      // A click-selection OWNS the dim while it is active, so hover highlighting\n      // switches off entirely whenever a selection exists (this option rebuilds on\n      // every selection change, and the notMerge push clears any live blur) and\n      // resumes once the selection clears. Otherwise emphasis is disabled so\n      // hovering leaves the bar untouched.\n      emphasis:\n        bar.enableHoverHighlight && !hasSelection\n          ? { focus: \"self\" as const, blurScope: \"coordinateSystem\" as const }\n          : { disabled: true },\n      blur:\n        bar.enableHoverHighlight && !hasSelection\n          ? { itemStyle: { opacity: HOVER_BLUR } }\n          : undefined,\n      // The grow-in envelope. Only takes effect on the reveal push (top-level\n      // `animation: true`); every later push sends `animation: false`, so the\n      // per-datum stagger is dormant then.\n      animationDuration: BAR_GROW_DURATION,\n      animationEasing: \"cubicOut\",\n      animationDelay: (idx: number) => barStaggerDelay(barAnim, idx, data.length),\n    };\n  });\n\n  // Stacked segments butt together into one solid column, so part them with a REAL\n  // gap: a transparent series stacked between each adjacent pair. A background\n  // -colored border can't do this — a border paints all four sides, outlining every\n  // segment (glaring the moment a bar glows) instead of only separating them.\n  //\n  // The spacer's value is in DATA units, so it is derived from the measured\n  // pixels-per-unit to keep the gap a constant pixel height whatever the scale.\n  // Before the first layout that measurement is null and the gap is simply skipped;\n  // the push re-applies once it exists, in the same frame (see the sync effect).\n  const gapUnits =\n    (isStacked || isPercent) && series.length > 1 && ctx.valuePxPerUnit\n      ? STACK_SEGMENT_GAP / ctx.valuePxPerUnit\n      : 0;\n  if (!gapUnits) return series;\n\n  const spaced: BarSeriesOption[] = [];\n  series.forEach((entry, i) => {\n    spaced.push(entry);\n    if (i === series.length - 1) return;\n    spaced.push({\n      id: `__stackgap-${i}`,\n      type: \"bar\",\n      stack: isStacked ? \"total\" : undefined,\n      data: data.map(() => gapUnits),\n      itemStyle: { color: \"transparent\" },\n      silent: true,\n      tooltip: { show: false },\n      legendHoverLink: false,\n      emphasis: { disabled: true },\n      animation: false,\n      z: 1,\n    });\n  });\n  return spaced;\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Live imperative state — everything the ECharts event handlers, rAF loops, and\n// theme/resize repushes read or write OUTSIDE the React render cycle, grouped in\n// one ref-stable object. None of it is render output, which is exactly why it is\n// not React state.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype LiveState = {\n  resolved: ResolvedColors | null; // colors read off the live DOM — feeds builds and rAF loops\n  hasRevealed: boolean; // the intro grow-in already played on this chart instance\n  revealEndsAt: number; // performance.now() the entrance settles — gates the stripped-cap correction\n  valuePxPerUnit: number | null; // measured value-axis pixels-per-unit — sizes the stripped cap\n  barWidthPx: number | null; // measured bar width — sizes the blocks variant's squares\n  // Openness per bar index for the expandable variant, plus which one the pointer\n  // is on. Per-index so a bar being left keeps easing shut while the next one\n  // opens — a single shared value made the outgoing bar snap.\n  expand: { key: string | null; hovered: number | null; progress: Map<number, number> };\n  expandRaf: number; // in-flight expand animation frame\n  animateExpand: (key: string | null, index: number | null) => void;\n  loadingRows: number[] | null; // skeleton data, lazily rolled and re-rolled per shimmer sweep\n  categories: string[]; // x labels of the last build, for the brush label pills\n  dataLength: number; // row count, for the datazoom index math\n  brushRange: BrushRange; // live zoom window — carried through every rebuild\n  brushGeom: BrushGeometry | null; // brush footer layout of the last build\n  brushOverlay: BrushOverlayElements | null; // zrender elements, owned by syncBrushOverlay\n  brushHover: { inside: boolean; left: boolean; right: boolean };\n  // Latest callbacks/flags for the imperative ECharts event handlers.\n  handlers: {\n    onBrushChange?: (range: { startIndex: number; endIndex: number }) => void;\n    clickableKeys: Set<string>;\n    brushFormatLabel?: (value: string, index: number) => string;\n    seriesKeys: string[];\n    hasStripped: boolean; // any visible stripped bar → run the post-layout cap correction\n    hasBlocks: boolean; // any blocks bar → re-push once the bar width is measurable\n    hasStackGap: boolean; // stacked with >1 series → the segment gap needs the axis scale\n    expandableKey: string | null; // the expandable series, if any — drives the column hover\n    barCategoryGap?: number; // consumer's category gap, needed to derive the bar width\n    isHorizontal: boolean; // layout, for measuring the value axis in the finished handler\n  };\n  // Update-style re-push for paths that bypass React entirely (theme flips,\n  // resizes) — set by the sync effect.\n  repush: () => void;\n  // Rebuilds ONLY the stripped bar series (fresh per-datum cap fractions) and\n  // merges them with a silent lazyUpdate — never notMerge, so it leaves the\n  // dataZoom drag anchor and the running entrance untouched. Set by the sync effect.\n  patchStrippedCaps: () => void;\n};\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Component\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Apache ECharts port of the EvilCharts bar chart, exposing a compound-as-config\n * API so its JSX reads identically to the Recharts twin. The root owns the data,\n * config, selection state, loading skeleton, intro grow-in, and optional zoom\n * brush; every visual part — `<Bar>`, `<XAxis>`, `<YAxis>`, `<Grid>`,\n * `<Tooltip>`, `<Legend>` — is composed as a declarative child that renders\n * nothing. The root walks those children by reference and drives a single\n * imperative ECharts instance. Fully self-contained: its only dependencies are\n * `react`, `echarts`, and `motion`.\n */\nexport function EChartsBarChart<TData extends Record<string, unknown>>({\n  data,\n  config,\n  xDataKey,\n  className,\n  stackType = \"default\",\n  layout = \"vertical\",\n  barRadius = DEFAULT_BAR_RADIUS,\n  animation = true,\n  animationType = \"left-to-right\",\n  barGap,\n  barCategoryGap,\n  defaultSelectedDataKey = null,\n  onSelectionChange,\n  enableMaxValueHighlight = false,\n  isLoading = false,\n  loadingBars = LOADING_DEFAULT_BARS,\n  chartOptions,\n  children,\n}: EChartsBarChartProps<TData>) {\n  const rawId = useId();\n  const chartId = `chart-${rawId.replace(/:/g, \"\")}`;\n\n  const containerRef = useRef<HTMLDivElement>(null);\n  const mountRef = useRef<HTMLDivElement>(null);\n  const echartsRef = useRef<EChartsInstance | null>(null);\n\n  // The single imperative surface (see LiveState). `resolved` lives here rather\n  // than in state: as state it forced an extra render pass and an effect whose\n  // only job was to trigger the option push. The object identity is stable for\n  // the component's lifetime.\n  const live = useRef<LiveState>({\n    resolved: null,\n    hasRevealed: false,\n    revealEndsAt: 0,\n    valuePxPerUnit: null,\n    barWidthPx: null,\n    expand: { key: null, hovered: null, progress: new Map<number, number>() },\n    expandRaf: 0,\n    animateExpand: () => {},\n    loadingRows: null,\n    categories: [],\n    dataLength: 0,\n    brushRange: { start: 0, end: 100 },\n    brushGeom: null,\n    brushOverlay: null,\n    brushHover: { inside: false, left: false, right: false },\n    handlers: {\n      onBrushChange: undefined, // set per-render from the <Brush> child's onChange\n      clickableKeys: new Set<string>(),\n      brushFormatLabel: undefined, // set per-render from the <Brush> child's formatLabel\n      seriesKeys: [],\n      hasStripped: false,\n      hasBlocks: false,\n      hasStackGap: false,\n      expandableKey: null,\n      isHorizontal: false,\n    },\n    repush: () => {},\n    patchStrippedCaps: () => {},\n  }).current;\n\n  // Skeleton rows roll lazily on first use — an impure useRef initializer would\n  // re-roll Math.random() on every render.\n  const loadingData = useCallback(\n    () => (live.loadingRows ??= getLoadingBarData(loadingBars)),\n    [live, loadingBars],\n  );\n  const shouldReduceMotion = useReducedMotion();\n\n  const [selectedDataKey, setSelectedDataKey] = useState<string | null>(defaultSelectedDataKey);\n\n  // ── Declarative config, collected from children by reference ─────────────────\n  const collected = useMemo(() => collectConfig(children), [children]);\n  const {\n    bars,\n    xAxis: xAxisSlot,\n    yAxis: yAxisSlot,\n    showGrid,\n    tooltip: tooltipSlot,\n    legend: legendSlot,\n    brush: brushSlot,\n  } = collected;\n  // Brush is a <Brush> child now (not props): presence turns it on, its props\n  // carry height/formatLabel/onChange.\n  const showBrush = brushSlot.present;\n  const brushHeight = brushSlot.height ?? 56;\n\n  const isHorizontal = layout === \"horizontal\";\n  const isPercent = stackType === \"percent\";\n  const isStacked = stackType === \"stacked\" || isPercent;\n\n  // Category axis is x when vertical, y when horizontal; value axis the other.\n  const categorySlot = isHorizontal ? yAxisSlot : xAxisSlot;\n  const valueSlot = isHorizontal ? xAxisSlot : yAxisSlot;\n\n  const seriesKeys = useMemo(() => bars.map((bar) => bar.dataKey), [bars]);\n\n  // category key: category axis dataKey → root xDataKey → first data column no <Bar> claims.\n  const categoryKey = useMemo(() => {\n    if (categorySlot.dataKey) return categorySlot.dataKey;\n    if (xDataKey) return xDataKey as string;\n    const firstRow = data[0];\n    if (firstRow) {\n      const claimed = new Set(seriesKeys);\n      const found = Object.keys(firstRow).find((key) => !claimed.has(key));\n      if (found) return found;\n    }\n    return \"\";\n  }, [categorySlot.dataKey, xDataKey, data, seriesKeys]);\n\n  // The intro grow-in follows the first bar's setting, falling back to the root default.\n  const effectiveAnimation = bars[0]?.animationType ?? animationType;\n\n  // The tallest COLUMN, comparing totals across every series so a stack or group\n  // wins together rather than one bar inside it. Null when the flag is off.\n  const maxHighlightIndex = useMemo(() => {\n    if (!enableMaxValueHighlight || !data.length || !seriesKeys.length) return null;\n    let best = 0;\n    let bestTotal = -Infinity;\n    data.forEach((row, i) => {\n      const total = seriesKeys.reduce((sum, key) => sum + (Number(row[key]) || 0), 0);\n      if (total > bestTotal) {\n        bestTotal = total;\n        best = i;\n      }\n    });\n    return best;\n  }, [enableMaxValueHighlight, data, seriesKeys]);\n\n  const css = useMemo(() => buildChartCss(chartId, config), [chartId, config]);\n\n  const hasSelection = selectedDataKey !== null;\n\n  // Which series may be clicked to toggle selection (consulted by the click handler).\n  const clickableKeys = useMemo(\n    () => new Set(bars.filter((bar) => bar.isClickable).map((bar) => bar.dataKey)),\n    [bars],\n  );\n\n  // Any visible stripped bar? (never while loading — the skeleton has no stripped\n  // series.) Gates the post-layout cap correction in the `finished` handler.\n  const hasStrippedBars = !isLoading && bars.some((bar) => bar.variant === \"stripped\");\n\n  // Refresh the handlers' snapshot of the latest callbacks/flags every render.\n  live.handlers = {\n    onBrushChange: brushSlot.onChange,\n    clickableKeys,\n    brushFormatLabel: brushSlot.formatLabel,\n    seriesKeys,\n    hasStripped: hasStrippedBars,\n    hasBlocks: bars.some((bar) => bar.variant === \"blocks\"),\n    hasStackGap: (stackType === \"stacked\" || stackType === \"percent\") && bars.length > 1,\n    expandableKey: bars.find((bar) => bar.variant === \"expandable\")?.dataKey ?? null,\n    barCategoryGap,\n    isHorizontal,\n  };\n  live.dataLength = data.length;\n\n  const toggleSelection = useCallback(\n    (key: string) => {\n      setSelectedDataKey((prev) => {\n        const next = prev === key ? null : key;\n        onSelectionChange?.(next);\n        return next;\n      });\n    },\n    [onSelectionChange],\n  );\n\n  // The brush is meaningful only when the category axis is on x (vertical layout).\n  const brushEnabled = showBrush && !isHorizontal;\n\n  // Reposition the brush overlays from the live refs — safe to call from drag\n  // events, hover tracking, and pushes alike, since it never touches setOption.\n  const syncBrushOverlayNow = useCallback(() => {\n    const chart = echartsRef.current;\n    if (!chart) return;\n\n    const geom = live.brushGeom;\n    const tokens = live.resolved?.tokens;\n    if (!geom || !tokens) {\n      syncBrushOverlay(chart, live, null);\n      return;\n    }\n\n    const range = live.brushRange;\n    const categories = live.categories;\n    const format = live.handlers.brushFormatLabel;\n    const lastIndex = Math.max(categories.length - 1, 0);\n    const startIndex = Math.round((range.start / 100) * lastIndex);\n    const endIndex = Math.round((range.end / 100) * lastIndex);\n    const labels =\n      format && categories.length\n        ? {\n            start: format(categories[startIndex] ?? \"\", startIndex),\n            end: format(categories[endIndex] ?? \"\", endIndex),\n          }\n        : null;\n\n    syncBrushOverlay(chart, live, {\n      range,\n      geom,\n      size: { width: chart.getWidth(), height: chart.getHeight() },\n      tokens,\n      labels,\n      showLabels: live.brushHover.inside,\n      hover: live.brushHover,\n    });\n  }, [live]);\n\n  // ── Option builder ─────────────────────────────────────────────────────────\n  const buildOption = useCallback((): EChartsOption => {\n    const resolved = live.resolved;\n    if (!resolved) return {};\n\n    const categories = data.map((row) => String(row[categoryKey]));\n    live.categories = categories;\n\n    const ctx: OptionBuildContext = {\n      data,\n      config,\n      bars,\n      seriesKeys,\n      animationType,\n      barRadius,\n      isHorizontal,\n      isStacked,\n      isPercent,\n      selectedDataKey,\n      hasSelection,\n      showGrid,\n      categorySlot,\n      valueSlot,\n      tooltipSlot,\n      legendSlot,\n      isLoading,\n      loadingData,\n      showBrush: brushEnabled,\n      brushHeight,\n      barGap,\n      barCategoryGap,\n      resolved,\n      categories,\n      brushRange: live.brushRange,\n      valuePxPerUnit: live.valuePxPerUnit,\n      barWidthPx: live.barWidthPx,\n      expand: live.expand,\n      maxHighlightIndex,\n    };\n\n    const { grid, brushBottom } = buildChartLayout(ctx);\n    live.brushGeom = brushEnabled ? { bottom: brushBottom, height: brushHeight } : null;\n\n    const { xAxis, yAxis } = buildMainAxes(ctx);\n\n    if (isLoading) return buildLoadingOption(ctx, { grid, xAxis, yAxis });\n\n    const brush = brushEnabled ? buildBrushOption(ctx, brushBottom) : null;\n\n    return {\n      animation: false,\n      grid: brush ? [grid, brush.miniGrid] : grid,\n      xAxis: brush ? [xAxis, brush.miniXAxis] : xAxis,\n      yAxis: brush ? [yAxis, brush.miniYAxis] : yAxis,\n      tooltip: buildTooltipOption(ctx),\n      dataZoom: brush?.dataZoom,\n      series: [...buildBarSeries(ctx), ...(brush?.miniSeries ?? [])],\n    };\n  }, [\n    live,\n    data,\n    config,\n    bars,\n    seriesKeys,\n    categoryKey,\n    animationType,\n    barRadius,\n    isHorizontal,\n    isStacked,\n    isPercent,\n    selectedDataKey,\n    hasSelection,\n    showGrid,\n    categorySlot,\n    valueSlot,\n    tooltipSlot,\n    legendSlot,\n    isLoading,\n    loadingData,\n    brushEnabled,\n    brushHeight,\n    barGap,\n    barCategoryGap,\n    maxHighlightIndex,\n  ]);\n\n  // ── Init + resize + theme observer (once) ────────────────────────────────────\n  useEffect(() => {\n    const mount = mountRef.current;\n    const container = containerRef.current;\n    if (!mount || !container) return;\n\n    const chart = echarts.init(mount);\n    echartsRef.current = chart;\n\n    const resizeObserver = new ResizeObserver(() => {\n      // Observers always fire once right after observe(). Repushing on that\n      // no-op fire would land one frame into the intro and stomp the grow-in —\n      // only react when the renderer size actually changed.\n      if (mount.clientWidth === chart.getWidth() && mount.clientHeight === chart.getHeight()) {\n        return;\n      }\n      chart.resize();\n      live.repush();\n    });\n    resizeObserver.observe(mount);\n\n    // Light/dark flips change no React state — re-resolve and push directly.\n    const themeObserver = new MutationObserver(() => {\n      live.repush();\n    });\n    themeObserver.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    // Expandable hover, driven by the pointer's COLUMN rather than the bar element.\n    // An expandable bar is a hairline at rest, so element hover would only catch a\n    // couple of pixels — and hovering the empty space above a bar (where the axis\n    // tooltip still responds) would highlight it without expanding it. Converting\n    // the pointer's x back to a category index makes the whole column the target,\n    // matching what the tooltip already does. Registered ONCE here rather than in\n    // the sync effect, which re-runs on every prop/theme change and would stack\n    // duplicate listeners; it calls through live.animateExpand, always the current one.\n    chart.getZr().on(\"mousemove\", (event: { offsetX: number; offsetY: number }) => {\n      const { expandableKey } = live.handlers;\n      if (!expandableKey) return;\n      const point = [event.offsetX, event.offsetY];\n      if (!chart.containPixel({ gridIndex: 0 }, point)) {\n        live.animateExpand(expandableKey, null);\n        return;\n      }\n      // A grid finder returns [xValue, yValue]; on a category axis the x value IS\n      // the index. An xAxisIndex finder returns null for a 2D point.\n      const converted = chart.convertFromPixel({ gridIndex: 0 }, point);\n      const index = Array.isArray(converted) ? converted[0] : converted;\n      live.animateExpand(expandableKey, typeof index === \"number\" ? Math.round(index) : null);\n    });\n    chart.getZr().on(\"globalout\", () => {\n      const { expandableKey } = live.handlers;\n      if (expandableKey) live.animateExpand(expandableKey, null);\n    });\n\n    chart.on(\"click\", (params) => {\n      const { clickableKeys: clickable, seriesKeys: keys } = live.handlers;\n      const p = params as { seriesId?: string; seriesIndex?: number };\n      // Bar clicks carry seriesId; keep the seriesIndex fallback for safety. Main\n      // series come first in the series array, so the index maps directly.\n      const id =\n        p.seriesId ?? (typeof p.seriesIndex === \"number\" ? keys[p.seriesIndex] : undefined);\n      if (typeof id === \"string\" && clickable.has(id)) toggleSelection(id);\n    });\n\n    chart.on(\"datazoom\", () => {\n      const option = chart.getOption() as { dataZoom?: { start?: number; end?: number }[] };\n      const zoom = option.dataZoom?.[0];\n      if (!zoom) return;\n\n      // Ride the selection — pure zrender updates, so the drag stays 1:1.\n      live.brushRange = { start: zoom.start ?? 0, end: zoom.end ?? 100 };\n      syncBrushOverlayNow();\n\n      const { onBrushChange: onChange } = live.handlers;\n      if (!onChange) return;\n      const len = live.dataLength;\n      const startIndex = Math.round(((zoom.start ?? 0) / 100) * (len - 1));\n      const endIndex = Math.round(((zoom.end ?? 100) / 100) * (len - 1));\n      onChange({ startIndex, endIndex });\n    });\n\n    // Every push measures the axis scale and corrects stripped caps before it\n    // paints, so this only catches rescales that BYPASS push — a dataZoom drag\n    // narrowing the window until the value axis re-ranges. The correction is a\n    // SILENT series-only merge (patchStrippedCaps), so it can't reset a dataZoom\n    // drag. Held off until the entrance finishes (revealEndsAt) so it never lands\n    // mid-grow; guarded by an epsilon so a stable measurement doesn't loop.\n    chart.on(\"finished\", () => {\n      const { hasStripped, isHorizontal: horiz } = live.handlers;\n      if (!hasStripped || performance.now() < live.revealEndsAt) return;\n      const measured = measureValuePxPerUnit(chart, horiz);\n      if (measured == null) return;\n      if (live.valuePxPerUnit != null && Math.abs(measured - live.valuePxPerUnit) < 0.5) return;\n      live.valuePxPerUnit = measured;\n      live.patchStrippedCaps();\n    });\n\n    // Hover tracking for the overlay: labels show while the pointer is over the\n    // brush, and each pill brightens when the pointer is near its edge.\n    const zr = chart.getZr();\n    const applyHover = (next: { inside: boolean; left: boolean; right: boolean }) => {\n      const prev = live.brushHover;\n      if (prev.inside === next.inside && prev.left === next.left && prev.right === next.right) {\n        return;\n      }\n      live.brushHover = next;\n      syncBrushOverlayNow();\n    };\n    const onZrMove = (event: { offsetX?: number; offsetY?: number }) => {\n      const geom = live.brushGeom;\n      if (!geom) return;\n      const x = event.offsetX ?? -1;\n      const y = event.offsetY ?? -1;\n      const top = chart.getHeight() - geom.bottom - geom.height;\n      const inside = y >= top - 4 && y <= top + geom.height + 4;\n      const trackLeft = 8;\n      const trackWidth = Math.max(chart.getWidth() - 16, 1);\n      const { start, end } = live.brushRange;\n      const selectionLeft = trackLeft + (trackWidth * start) / 100;\n      const selectionRight = trackLeft + (trackWidth * end) / 100;\n      applyHover({\n        inside,\n        left: inside && Math.abs(x - selectionLeft) <= 8,\n        right: inside && Math.abs(x - selectionRight) <= 8,\n      });\n    };\n    const onZrOut = () => applyHover({ inside: false, left: false, right: false });\n    zr.on(\"mousemove\", onZrMove);\n    zr.on(\"globalout\", onZrOut);\n\n    return () => {\n      zr.off(\"mousemove\", onZrMove);\n      zr.off(\"globalout\", onZrOut);\n      resizeObserver.disconnect();\n      themeObserver.disconnect();\n      chart.dispose();\n      echartsRef.current = null;\n      // The overlay elements died with the zrender instance.\n      live.brushOverlay = null;\n      // The reveal guard belongs to the chart instance it guarded. Without this\n      // reset, StrictMode's dev-only mount→unmount→remount plays the entrance on\n      // the throwaway instance and the surviving one renders without it.\n      live.hasRevealed = false;\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  // ── Sync ECharts with props/theme/selection — resolve, build, push ────────────\n  useEffect(() => {\n    const chart = echartsRef.current;\n    const container = containerRef.current;\n    if (!chart || !container) return;\n\n    // Colors come from the <style> committed just before this effect ran — read\n    // them here, right before the push, rather than round-tripping through state.\n    live.resolved = resolveColors(container, config, seriesKeys);\n\n    const push = (withEntrance: boolean) => {\n      // Refresh the value-axis pixel scale before building, so stripped caps get the\n      // right per-bar fraction on this same push (after a resize the coordinate\n      // system is already updated here). Null before the very first push, and stale\n      // when this push rescales the axis (loading skeleton → real data) — the\n      // post-apply re-measure below corrects both before anything paints.\n      const measured = measureValuePxPerUnit(chart, isHorizontal);\n      if (measured != null) live.valuePxPerUnit = measured;\n\n      const apply = () => {\n        const option = buildOption();\n        const merged = chartOptions ? { ...option, ...chartOptions } : option;\n        Object.assign(merged, {\n          animation: withEntrance,\n          animationDuration: BAR_GROW_DURATION,\n          animationDurationUpdate: 0,\n        });\n        // chartOptions is an untyped escape hatch — the spread erases the option's\n        // shape, so re-assert it. The only cast in the file.\n        chart.setOption(merged as EChartsOption, { notMerge: true });\n      };\n\n      apply();\n\n      // Some things can only be sized once a coordinate system has been laid out, so\n      // the first build uses fallbacks: the `blocks` variant's square segments (bar\n      // width), the stacked-segment gap, and the stripped variant's constant-pixel\n      // cap (both value-axis pixels-per-unit). Measure now and, if anything moved,\n      // rebuild IMMEDIATELY — still inside this task, before the browser paints, so\n      // the corrected chart is the only thing ever shown. Doing this from the async\n      // `finished` handler instead made the bars visibly re-align a frame later —\n      // exactly the stripped-cap flicker this replaces.\n      let needsRebuild = false;\n      if (live.handlers.hasBlocks) {\n        const width = measureBarWidthPx(chart, isHorizontal, barCategoryGap);\n        if (width != null && (live.barWidthPx == null || Math.abs(width - live.barWidthPx) > 0.5)) {\n          live.barWidthPx = width;\n          needsRebuild = true;\n        }\n      }\n      if (live.handlers.hasStackGap || live.handlers.hasStripped) {\n        const scale = measureValuePxPerUnit(chart, isHorizontal);\n        if (scale != null && (live.valuePxPerUnit == null || live.valuePxPerUnit !== scale)) {\n          live.valuePxPerUnit = scale;\n          needsRebuild = true;\n        }\n      }\n      if (needsRebuild) apply();\n      // Mark when the entrance settles, so the stripped-cap correction holds off\n      // until the grow finishes (0 = nothing animating, correct immediately).\n      const maxStagger = data.length > 1 ? (data.length - 1) * BAR_STAGGER : 0;\n      live.revealEndsAt = withEntrance ? performance.now() + BAR_GROW_DURATION + maxStagger : 0;\n      // Overlays live outside the option — reposition them after every push.\n      syncBrushOverlayNow();\n    };\n\n    // A stripped-cap correction that never disturbs the entrance or a brush drag:\n    // rebuild only the stripped series with fresh per-datum cap fractions (the just\n    // -measured live.valuePxPerUnit) and merge them silently.\n    // Drives the `expandable` hover: eases live.expand.progress toward its target\n    // and re-merges ONLY the expandable series each frame, so the strip grows out\n    // of the bar's middle. A series-scoped silent merge (same shape as\n    // patchStrippedCaps) — never a full notMerge push, which would fight the\n    // hover state it is animating.\n    live.animateExpand = (key: string | null, index: number | null) => {\n      const expandKeys = new Set(\n        bars.filter((bar) => bar.variant === \"expandable\").map((bar) => bar.dataKey),\n      );\n      if (!expandKeys.size) return;\n\n      const next = index != null && key != null ? index : null;\n      if (live.expand.hovered === next && (key == null || live.expand.key === key)) return;\n      if (key != null) live.expand.key = key;\n      live.expand.hovered = next;\n      // Seed the newly hovered bar so it has something to ease from.\n      if (next != null && !live.expand.progress.has(next)) {\n        live.expand.progress.set(next, EXPAND_COLLAPSED);\n      }\n      if (live.expandRaf) return; // a loop is already running; it picks up the new target\n\n      const patchOnce = () => {\n        const option = buildOption();\n        const series = Array.isArray(option.series)\n          ? option.series\n          : option.series\n            ? [option.series]\n            : [];\n        const patch = series.filter(\n          (s): s is BarSeriesOption => typeof s?.id === \"string\" && expandKeys.has(s.id),\n        );\n        if (patch.length) chart.setOption({ series: patch }, { silent: true, lazyUpdate: true });\n      };\n\n      let last = performance.now();\n      const step = () => {\n        const now = performance.now();\n        const dt = Math.min(64, now - last);\n        last = now;\n        // Exponential approach — every bar eases toward its own target, so the one\n        // being left keeps animating shut while the next one opens.\n        const k = 1 - Math.exp(-dt / EXPAND_TAU);\n        let moving = false;\n        for (const [i, value] of live.expand.progress) {\n          const target = i === live.expand.hovered ? 1 : EXPAND_COLLAPSED;\n          const eased = value + (target - value) * k;\n          if (Math.abs(target - eased) < 0.004) {\n            if (target === EXPAND_COLLAPSED) live.expand.progress.delete(i);\n            else live.expand.progress.set(i, target);\n          } else {\n            live.expand.progress.set(i, eased);\n            moving = true;\n          }\n        }\n        patchOnce();\n        live.expandRaf = moving ? requestAnimationFrame(step) : 0;\n      };\n      live.expandRaf = requestAnimationFrame(step);\n    };\n\n    live.patchStrippedCaps = () => {\n      const option = buildOption();\n      const series = Array.isArray(option.series)\n        ? option.series\n        : option.series\n          ? [option.series]\n          : [];\n      const strippedKeys = new Set(\n        bars.filter((bar) => bar.variant === \"stripped\").map((bar) => bar.dataKey),\n      );\n      const patch = series.filter(\n        (s): s is BarSeriesOption => typeof s?.id === \"string\" && strippedKeys.has(s.id),\n      );\n      if (patch.length) chart.setOption({ series: patch }, { silent: true, lazyUpdate: true });\n    };\n\n    // Intro grow-in — ECharts' native bar entrance (bars rise from the baseline),\n    // staggered per-datum by animationType, enabled only for the first real\n    // render: every later push (selection, theme, zoom) applies instantly, since\n    // notMerge would otherwise replay the entrance on each. A loading cycle\n    // re-arms it: the Recharts twin remounts its <Bar>s while loading and replays\n    // the intro, so data → loading → data grows in again here too.\n    if (isLoading) live.hasRevealed = false;\n    const shouldReveal = !live.hasRevealed && !isLoading;\n    if (shouldReveal) live.hasRevealed = true;\n    const revealEnabled =\n      animation && shouldReveal && effectiveAnimation !== \"none\" && !shouldReduceMotion;\n    push(revealEnabled);\n\n    // Theme flips and resizes re-enter here without touching React: re-read the\n    // tokens (the .dark class changed, or textures need renderer-sized rebakes)\n    // and push an update-style option.\n    live.repush = () => {\n      live.resolved = resolveColors(container, config, seriesKeys);\n      push(false);\n    };\n  }, [\n    live,\n    buildOption,\n    chartOptions,\n    isLoading,\n    animation,\n    effectiveAnimation,\n    shouldReduceMotion,\n    config,\n    seriesKeys,\n    data.length,\n    bars,\n    isHorizontal,\n    barCategoryGap,\n    syncBrushOverlayNow,\n  ]);\n\n  // ── Default tooltip — show the tooltip at `defaultIndex` with no hover ────────\n  // Recharts' `defaultIndex` keeps a tooltip open on load; ECharts has no static\n  // equivalent, so dispatch `showTip` once the layout has settled.\n  useEffect(() => {\n    const chart = echartsRef.current;\n    const index = tooltipSlot.defaultIndex;\n    if (!chart || isLoading || !tooltipSlot.present || index == null) return;\n    const timer = setTimeout(() => {\n      chart.dispatchAction({ type: \"showTip\", seriesIndex: 0, dataIndex: index });\n    }, 300);\n    return () => clearTimeout(timer);\n  }, [tooltipSlot.present, tooltipSlot.defaultIndex, isLoading, data.length, seriesKeys.length]);\n\n  // ── Loading shimmer — rAF sweeps a bright band across the gray bars ──────────\n  useEffect(() => {\n    const chart = echartsRef.current;\n    if (!chart || !isLoading) return;\n\n    let raf = 0;\n    let lastPhase = 0;\n    const start = performance.now();\n    const tick = (now: number) => {\n      const phase = ((((now - start) / LOADING_ANIMATION_DURATION) % 1) + 1) % 1;\n      // Wrapped past 1 → the band is off-screen; swap in fresh random heights.\n      if (phase < lastPhase) live.loadingRows = getLoadingBarData(loadingBars);\n      lastPhase = phase;\n\n      // Read tokens per frame, so a theme flip mid-loading retints the shimmer.\n      const foreground = live.resolved?.tokens.foreground ?? GRAY;\n      const w = chart.getWidth();\n      const h = chart.getHeight();\n      if (!w || !h) {\n        raf = requestAnimationFrame(tick);\n        return;\n      }\n      // Sweep the clip window from fully off-screen to fully off-screen, leaned\n      // 45°. The gradient runs on ABSOLUTE pixel coordinates (0,0)→(w,w) shared\n      // by every bar — the whole skeleton lives in one coordinate frame, so each\n      // bar brightens as the diagonal band passes diagonally over it, the same\n      // sweep language as the area chart's loading shimmer. `maxT` is the farthest\n      // plot corner projected onto the 45° axis, keeping the sweep tight instead\n      // of dawdling off-plot at the end of each loop.\n      const maxT = (w + h) / (2 * w);\n      const center = phase * (maxT + 2 * LOADING_SHIMMER_BAND) - LOADING_SHIMMER_BAND;\n      const fill = new echarts.graphic.LinearGradient(\n        0,\n        0,\n        w,\n        w,\n        shimmerWindowStops(center, foreground, LOADING_SHIMMER_MAX_OPACITY),\n        true,\n      );\n      chart.setOption(\n        { series: [{ id: \"__loading\", data: loadingData(), itemStyle: { color: fill } }] },\n        { silent: true, lazyUpdate: true },\n      );\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [live, isLoading, loadingBars, loadingData]);\n\n  // ── Legend overlay position ──────────────────────────────────────────────────\n  // Insets match the Recharts legend's breathing room inside the plot frame.\n  const legendStyle: CSSProperties = {\n    position: \"absolute\",\n    left: 16,\n    right: 16,\n    pointerEvents: \"auto\",\n    ...(legendSlot.verticalAlign === \"top\"\n      ? { top: 12 }\n      : legendSlot.verticalAlign === \"bottom\"\n        ? { bottom: brushEnabled ? brushHeight + 16 : 12 }\n        : { top: \"50%\", transform: \"translateY(-50%)\" }),\n  };\n\n  return (\n    <div\n      ref={containerRef}\n      data-chart={chartId}\n      className={`relative flex flex-col text-xs ${className ?? \"\"}`}\n    >\n      <style dangerouslySetInnerHTML={{ __html: css }} />\n\n      <div className=\"relative min-h-0 w-full flex-1\">\n        <div ref={mountRef} className=\"h-full min-h-0 w-full\" />\n      </div>\n\n      {legendSlot.present && !isLoading && (\n        <LegendOverlay\n          seriesKeys={seriesKeys}\n          config={config}\n          variant={legendSlot.variant}\n          align={legendSlot.align}\n          verticalAlign={legendSlot.verticalAlign}\n          selectedKey={selectedDataKey}\n          hoveredKey={null}\n          isClickable={legendSlot.isClickable}\n          onToggle={toggleSelection}\n          style={legendStyle}\n        />\n      )}\n\n      {isLoading && (\n        <div className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center\">\n          <motion.div\n            initial={shouldReduceMotion ? false : { opacity: 0, scale: 0.92 }}\n            animate={{ opacity: 1, scale: 1 }}\n            transition={{ duration: 0.25, ease: \"easeOut\" }}\n            className=\"text-primary bg-background flex items-center justify-center gap-2 rounded-md border px-2 py-0.5 text-sm\"\n          >\n            <div className=\"border-border border-t-primary h-3 w-3 animate-spin rounded-full border\" />\n            <span>Loading</span>\n          </motion.div>\n        </div>\n      )}\n    </div>\n  );\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Loading skeleton helpers\n// ─────────────────────────────────────────────────────────────────────────────\n\n// Skeleton bar heights as a smooth random walk in a comfortable band — reads\n// like a resting chart instead of raw noise spikes.\nfunction getLoadingBarData(bars: number): number[] {\n  const rows: number[] = [];\n  let value = 40 + Math.random() * 25;\n  for (let i = 0; i < bars; i++) {\n    value = Math.min(85, Math.max(20, value + (Math.random() - 0.5) * 30));\n    rows.push(Math.round(value));\n  }\n  return rows;\n}\n\n// Gradient stops forming a hard clip window around `center`: full `peak` alpha\n// inside, zero outside, with a small feather so the edge isn't aliased.\n// `center` may run outside [0, 1] so the window fully enters and exits the frame.\nfunction shimmerWindowStops(center: number, color: string, peak: number) {\n  const half = LOADING_SHIMMER_BAND;\n  const feather = LOADING_SHIMMER_FEATHER;\n\n  const alphaAt = (x: number) => {\n    const dist = Math.abs(x - center);\n    if (dist <= half - feather) return peak;\n    if (dist >= half) return 0;\n    // Sine-eased falloff — a linear ramp still reads as a hard cut.\n    return peak * Math.sin(((1 - (dist - (half - feather)) / feather) * Math.PI) / 2);\n  };\n\n  const offsets = [\n    0,\n    center - half,\n    center - half + feather,\n    center,\n    center + half - feather,\n    center + half,\n    1,\n  ]\n    .filter((x) => x >= 0 && x <= 1)\n    .sort((a, b) => a - b);\n\n  const stops: { offset: number; color: string }[] = [];\n  for (const offset of offsets) {\n    if (stops.length === 0 || offset - stops[stops.length - 1].offset > 1e-4) {\n      stops.push({ offset, color: withAlpha(color, alphaAt(offset)) });\n    }\n  }\n  return stops;\n}\n\n// Compound API: every part hangs off the root as a static member, so a consumer\n// writes <EChartsBarChart.Bar/>, <EChartsBarChart.Tooltip/>, … from a single\n// import — no colliding named marker exports when several charts share one file.\nEChartsBarChart.Bar = Bar;\nEChartsBarChart.XAxis = XAxis;\nEChartsBarChart.YAxis = YAxis;\nEChartsBarChart.Grid = Grid;\nEChartsBarChart.Tooltip = Tooltip;\nEChartsBarChart.Legend = Legend;\nEChartsBarChart.Brush = Brush;\n",
      "type": "registry:component",
      "target": "components/evilcharts/charts/echarts-bar-chart.tsx"
    }
  ],
  "type": "registry:component"
}