{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "echarts-area-chart",
  "description": "Area chart component rendered with Apache ECharts",
  "dependencies": [
    "echarts",
    "motion"
  ],
  "registryDependencies": [
    "@evilcharts/echarts-chart",
    "@evilcharts/echarts-tooltip",
    "@evilcharts/echarts-legend",
    "@evilcharts/echarts-dot",
    "@evilcharts/echarts-brush"
  ],
  "files": [
    {
      "path": "src/registry/charts/echarts-area-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  buildChartCss,\n  flattenColor,\n  getColorsCount,\n  resolveColors,\n  seriesPaint,\n  withAlpha,\n  type ChartConfig,\n  type ResolvedColors,\n} from \"@/registry/ui/echarts-chart\";\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 { dotItemStyle, dotStyle, sampleGradient, type DotVariant } from \"@/registry/ui/echarts-dot\";\nimport { LegendOverlay, type LegendVariant } from \"@/registry/ui/echarts-legend\";\nimport type { ComposeOption, ImagePatternObject } from \"echarts/core\";\nimport { LineChart, type LineSeriesOption } from \"echarts/charts\";\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 {\n  ChartConfig,\n  DotVariant,\n  LegendVariant,\n  TooltipPosition,\n  TooltipRoundness,\n  TooltipVariant,\n};\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.\necharts.use([LineChart, GridComponent, TooltipComponent, DataZoomComponent, CanvasRenderer]);\n\ntype EChartsInstance = ReturnType<typeof echarts.init>;\n\n// The exact option surface this chart uses — line 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  LineSeriesOption | 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 STROKE_WIDTH = 0.8; // default series stroke — <Area strokeWidth> overrides it\nconst LOADING_ANIMATION_DURATION = 2000; // shimmer loop, in milliseconds\nconst REVEAL_DURATION = 1000; // intro draw-in length, in milliseconds\n// NOTE: the intro draw-in runs ECharts' RAW default entrance animation. Custom\n// easing was tried and abandoned — ECharts hardcodes the line-entrance clip to\n// linear and ignores animationEasing at every level (verified empirically).\nconst LOADING_DEFAULT_POINTS = 14;\n// Buffer line: the last segment's stroke renders as this dash while the rest of\n// the area stays solid, echoing the Recharts twin's 4px dash / 3px gap forecast\n// tail. Ported from the line-chart twin.\nconst BUFFER_DASH: [number, number] = [4, 3];\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 y-axis split lines, × border alpha\nconst AXIS_POINTER_OPACITY = 1; // tooltip cursor line, × border alpha\n// The skeleton is CLIPPED to a small sweeping window — only the wave section\n// inside it exists (stroke + fill), everything outside is fully transparent,\n// like a clip-path sliding across the chart.\nconst LOADING_STROKE_OPACITY = 0.5; // outline inside the window, × foreground alpha\nconst LOADING_SHIMMER_MAX_OPACITY = 0.03; // fill inside the window, × foreground alpha\nconst LOADING_SHIMMER_BAND = 0.2; // window half-width, fraction of chart width\nconst LOADING_SHIMMER_FEATHER = 0.2; // eased edge softening of the clip window\nconst BRUSH_STROKE_OPACITY = 0.5; // mini-chart series stroke\nconst BRUSH_FILL_OPACITY = 0.15; // mini-chart series fade, at the top stop\nconst BRUSH_FILLER_OPACITY = 0; // selected-range wash — evil-brush draws none\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Public types\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport type AreaVariant =\n  | \"gradient\"\n  | \"gradient-reverse\"\n  | \"solid\"\n  | \"dotted\"\n  | \"lines\"\n  | \"hatched\"\n  | \"none\"; // stroke only — no fill at all\nexport type StrokeVariant = \"solid\" | \"dashed\" | \"animated-dashed\";\nexport type StackType = \"default\" | \"stacked\" | \"expanded\";\nexport type AreaAnimationType =\n  | \"none\"\n  | \"left-to-right\"\n  | \"right-to-left\"\n  | \"center-out\"\n  | \"edges-in\";\nexport type CurveType =\n  | \"linear\"\n  | \"smooth\"\n  | \"bump\"\n  | \"monotone\"\n  | \"monotoneX\"\n  | \"monotoneY\"\n  | \"natural\"\n  | \"step\";\n// DotVariant, TooltipVariant, TooltipRoundness, LegendVariant, and ChartConfig\n// now live in the shared @/registry/ui/echarts/* modules and are imported +\n// re-exported at the top of this file.\n\nexport interface EChartsAreaChartProps<TData extends Record<string, unknown>> {\n  data: TData[]; // rows rendered by the chart\n  config: ChartConfig; // series colors + labels\n  xDataKey?: keyof TData & string; // x category key — falls back to the <XAxis> dataKey / first free column\n  className?: string; // extra classes for the chart container\n  curveType?: CurveType; // default curve interpolation each <Area> inherits\n  stackType?: StackType; // how multiple areas combine\n  animation?: boolean; // master switch for the intro draw-in — false renders instantly\n  animationType?: AreaAnimationType; // default intro reveal (first <Area> overrides)\n  enableHoverHighlight?: boolean; // hovering a series dims the others, like a temporary selection\n  enableHoverReveal?: boolean; // hovering colors each area up to the pointer's x and mutes the rest\n  defaultSelectedDataKey?: string | null; // series selected on first render\n  selectedDataKey?: string | null; // controlled selection — overrides internal state when set\n  onSelectionChange?: (key: string | null) => void; // fires when the selected series changes\n  isLoading?: boolean; // shows the animated loading skeleton\n  loadingPoints?: number; // number of points in the loading skeleton\n  chartOptions?: Record<string, unknown>; // escape hatch merged over the built ECharts option\n  children?: ReactNode; // declarative config — <Area>, <XAxis>, <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 === Area, …) 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 AreaProps {\n  dataKey: string; // series key — must exist on the data + config\n  variant?: AreaVariant; // fill style for this area only\n  strokeVariant?: StrokeVariant; // stroke style for this area\n  strokeWidth?: number; // stroke thickness in pixels for this area\n  curveType?: CurveType; // curve interpolation — falls back to the root curveType\n  animationType?: AreaAnimationType; // intro reveal — first area drives the wrapper wipe\n  connectNulls?: boolean; // join segments across null/missing values\n  isClickable?: boolean; // lets this area be selected by clicking it\n  enableBufferLine?: boolean; // renders this area's last segment as a dashed, fill-less buffer\n  children?: ReactNode; // optional <Dot> and <ActiveDot> config\n}\n\n/**\n * A single area series. Declares its own fill/stroke/curve/clickability and,\n * optionally, resting/active point markers via composed <Dot> / <ActiveDot>.\n * Renders nothing — the root reads these props to build the ECharts series.\n */\nconst Area: FC<AreaProps> = () => null;\n\nexport interface DotProps {\n  variant?: DotVariant; // visual style of the point marker\n}\n\n/** Declares the resting point marker for the enclosing <Area>. Renders nothing. */\nconst Dot: FC<DotProps> = () => null;\n\n/** Declares the hovered/active point marker for the enclosing <Area>. Renders nothing. */\nconst ActiveDot: FC<DotProps> = () => null;\n\nexport interface XAxisProps {\n  dataKey?: string; // x category key — overrides the root xDataKey\n  // Category-axis values are always stringified, so the formatter 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 tick labels\n  hideDots?: boolean; // hides the tick dots beside this axis's labels\n}\n\n/** Presence shows the x-axis category labels. Renders nothing. */\nconst XAxis: FC<XAxisProps> = () => null;\n\nexport interface YAxisProps {\n  dataKey?: string; // reserved for parity with the Recharts twin\n  tickFormatter?: (value: number, index: number) => string; // formats y tick labels\n  label?: string; // axis title, rotated alongside the tick labels\n  hideDots?: boolean; // hides the tick dots beside this axis's labels\n}\n\n/** Presence shows the y value axis. Renders nothing. */\nconst YAxis: FC<YAxisProps> = () => null;\n\n/** Presence shows the dashed horizontal split lines. 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  cursor?: boolean; // whether the vertical cursor line follows the pointer\n  position?: TooltipPosition; // \"variable\" follows both axes (default); \"fixed\" pins the tooltip near the top and 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. <Dot> / <ActiveDot> are read from each <Area>'s own\n// children; a missing dot child means that marker does not render.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype AreaSeriesConfig = {\n  dataKey: string;\n  variant: AreaVariant;\n  strokeVariant: StrokeVariant;\n  strokeWidth: number;\n  curveType?: CurveType;\n  animationType?: AreaAnimationType;\n  connectNulls: boolean;\n  isClickable: boolean;\n  enableBufferLine: boolean;\n  dotVariant: DotVariant; // \"none\" when no <Dot> child is present\n  activeDotVariant: DotVariant; // \"none\" when no <ActiveDot> child is present\n};\n\ntype XAxisSlot = {\n  present: boolean;\n  dataKey?: string;\n  tickFormatter?: (value: string, index: number) => string;\n  label?: string;\n  hideDots: boolean;\n};\ntype YAxisSlot = {\n  present: boolean;\n  dataKey?: string;\n  tickFormatter?: (value: number, index: number) => string;\n  label?: string;\n  hideDots: boolean;\n};\ntype TooltipSlot = {\n  present: boolean;\n  variant: TooltipVariant;\n  roundness: TooltipRoundness;\n  cursor: boolean;\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  areas: AreaSeriesConfig[];\n  xAxis: XAxisSlot;\n  yAxis: YAxisSlot;\n  showGrid: boolean;\n  tooltip: TooltipSlot;\n  legend: LegendSlot;\n  brush: BrushSlot;\n};\n\nfunction collectConfig(children: ReactNode): CollectedConfig {\n  const areas: AreaSeriesConfig[] = [];\n  let xAxis: XAxisSlot = { present: false, hideDots: false };\n  let yAxis: YAxisSlot = { present: false, hideDots: false };\n  let showGrid = false;\n  let tooltip: TooltipSlot = {\n    present: false,\n    variant: \"default\",\n    roundness: \"lg\",\n    cursor: true,\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 === Area) {\n      const props = child.props as AreaProps;\n      let dotVariant: DotVariant = \"none\";\n      let activeDotVariant: DotVariant = \"none\";\n      Children.forEach(props.children, (dotChild) => {\n        if (!isValidElement(dotChild)) return;\n        if (dotChild.type === Dot) {\n          dotVariant = (dotChild.props as DotProps).variant ?? \"default\";\n        } else if (dotChild.type === ActiveDot) {\n          activeDotVariant = (dotChild.props as DotProps).variant ?? \"default\";\n        }\n      });\n      areas.push({\n        dataKey: props.dataKey,\n        variant: props.variant ?? \"gradient\",\n        strokeVariant: props.strokeVariant ?? \"dashed\",\n        strokeWidth: props.strokeWidth ?? STROKE_WIDTH,\n        curveType: props.curveType,\n        animationType: props.animationType,\n        connectNulls: props.connectNulls ?? false,\n        isClickable: props.isClickable ?? false,\n        enableBufferLine: props.enableBufferLine ?? false,\n        dotVariant,\n        activeDotVariant,\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        cursor: props.cursor ?? true,\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 { areas, xAxis, yAxis, showGrid, tooltip, legend, brush };\n}\n\n// Color plumbing (ChartConfig, getColorsCount, distributeColors, buildChartCss,\n// normalizeColor, withAlpha, ResolvedColors, resolveColors, seriesPaint) now\n// lives in @/registry/ui/echarts-chart and is imported at the top of this file.\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Fill paints — the ECharts analogue of the Recharts fill variants (§1.1).\n// The first three are alpha fades; the last three are tiling canvas patterns.\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: \"dotted\" | \"lines\" | \"hatched\" | \"stripe\",\n  color: string,\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 === \"dotted\") {\n    size(6, 6);\n    // Slightly larger dots at 0.7 — the vertical fade + areaStyle opacity temper\n    // them, so at the old 0.5/r0.6 they washed out (especially on shorter areas).\n    ctx.fillStyle = withAlpha(color, 0.7);\n    ctx.beginPath();\n    ctx.arc(3, 3, 0.85, 0, Math.PI * 2);\n    ctx.fill();\n    return pattern();\n  }\n\n  if (kind === \"lines\" || kind === \"stripe\") {\n    // Vertical 1px line every 5px, rotated 45° by the pattern transform.\n    size(5, 5);\n    ctx.strokeStyle = withAlpha(color, 0.3);\n    ctx.lineWidth = 1;\n    ctx.beginPath();\n    ctx.moveTo(2.5, -1);\n    ctx.lineTo(2.5, 6);\n    ctx.stroke();\n    return pattern(-Math.PI / 4);\n  }\n\n  // hatched: bold two-tone stripes leaning ~20°, echoing the Recharts\n  // gradient-edged stripe fill.\n  size(20, 20);\n  ctx.fillStyle = withAlpha(color, 0.06);\n  ctx.fillRect(0, 0, 10, 20);\n  ctx.fillStyle = withAlpha(color, 0.22);\n  ctx.fillRect(10, 0, 10, 20);\n  return pattern((20 * Math.PI) / 180);\n}\n\n// Canvas can't express \"multi-stop color horizontally × alpha fade vertically\"\n// as one gradient, so multi-color fills composite the two on an offscreen canvas\n// sized to the chart: paint the horizontal color run, then mask it with a\n// vertical alpha ramp via destination-in. Regenerated on resize (patterns anchor\n// to the renderer's origin at natural pixel size).\nfunction gradientFillTexture(\n  slots: string[],\n  width: number,\n  height: number,\n  reverse: boolean,\n): HTMLCanvasElement | null {\n  if (typeof document === \"undefined\" || width < 1 || height < 1) return null;\n\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = Math.ceil(width);\n  canvas.height = Math.ceil(height);\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) return null;\n\n  const colors = ctx.createLinearGradient(0, 0, canvas.width, 0);\n  slots.forEach((color, i) => colors.addColorStop(i / (slots.length - 1), color));\n  ctx.fillStyle = colors;\n  ctx.fillRect(0, 0, canvas.width, canvas.height);\n\n  const fade = ctx.createLinearGradient(0, 0, 0, canvas.height);\n  fade.addColorStop(0, `rgba(0, 0, 0, ${reverse ? 0 : 0.1})`);\n  fade.addColorStop(1, `rgba(0, 0, 0, ${reverse ? 0.1 : 0})`);\n  ctx.globalCompositeOperation = \"destination-in\";\n  ctx.fillStyle = fade;\n  ctx.fillRect(0, 0, canvas.width, canvas.height);\n\n  return canvas;\n}\n\n// A pattern fill (dotted/lines/hatched) faded vertically — opaque at the top\n// (near the line), transparent toward the baseline — so it reads like the\n// gradient variant instead of a flat wall of pattern. The tiling pattern can't\n// itself carry an alpha ramp, so bake it into a plot-sized texture: tile the\n// pattern (reusing patternFill's tile + rotation), then mask it with a vertical\n// alpha ramp via destination-in.\nfunction patternFadeTexture(\n  kind: \"dotted\" | \"lines\" | \"hatched\",\n  color: string,\n  width: number,\n  height: number,\n): HTMLCanvasElement | null {\n  const patternObj = patternFill(kind, color);\n  if (!patternObj || typeof document === \"undefined\" || width < 1 || height < 1) return null;\n  const tile = patternObj.image;\n  if (!(tile instanceof HTMLCanvasElement)) return null;\n  const rotation = patternObj.rotation ?? 0;\n  const tileScale = patternObj.scaleX ?? 1; // patternFill draws the tile at dpr; 1/dpr scales it back\n\n  const w = Math.ceil(width);\n  const h = Math.ceil(height);\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = w;\n  canvas.height = h;\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) return null;\n\n  const pat = ctx.createPattern(tile, \"repeat\");\n  if (!pat) return null;\n  // Replicate the ECharts ImagePattern transform (rotate the tiling, scale the\n  // dpr tile back to CSS size) so the baked texture matches the plain pattern.\n  if (typeof pat.setTransform === \"function\") {\n    const m = new DOMMatrix();\n    m.rotateSelf((rotation * 180) / Math.PI);\n    m.scaleSelf(tileScale, tileScale);\n    pat.setTransform(m);\n  }\n  ctx.fillStyle = pat;\n  ctx.fillRect(0, 0, w, h);\n\n  const fade = ctx.createLinearGradient(0, 0, 0, h);\n  fade.addColorStop(0, \"rgba(0, 0, 0, 1)\"); // top: keep the pattern\n  fade.addColorStop(1, \"rgba(0, 0, 0, 0)\"); // baseline: fade it out\n  ctx.globalCompositeOperation = \"destination-in\";\n  ctx.fillStyle = fade;\n  ctx.fillRect(0, 0, w, h);\n\n  return canvas;\n}\n\n// Resolves the area fill for a variant into an ECharts color value. `size` is the\n// full renderer size, used to bake 2D gradients for multi-color series.\nfunction fillPaint(\n  variant: AreaVariant,\n  showUnselected: boolean,\n  slots: string[],\n  size: { width: number; height: number },\n): string | echarts.graphic.LinearGradient | ImagePatternObject {\n  const base = slots[0] ?? \"rgba(120, 120, 120, 1)\";\n  const multi = slots.length > 1;\n\n  // \"none\" — stroke only, no fill (even when unselected).\n  if (variant === \"none\") return \"transparent\";\n\n  // A non-selected area in a clickable chart recedes as a 45° stripe texture.\n  if (showUnselected) {\n    return patternFill(\"stripe\", base) ?? withAlpha(base, 0.1);\n  }\n\n  switch (variant) {\n    case \"gradient\":\n    case \"gradient-reverse\": {\n      const reverse = variant === \"gradient-reverse\";\n      if (multi) {\n        const texture = gradientFillTexture(slots, size.width, size.height, reverse);\n        if (texture) return { image: texture, repeat: \"no-repeat\" };\n      }\n      return new echarts.graphic.LinearGradient(0, 0, 0, 1, [\n        { offset: 0, color: withAlpha(base, reverse ? 0 : 0.1) },\n        { offset: 1, color: withAlpha(base, reverse ? 0.1 : 0) },\n      ]);\n    }\n    case \"solid\": {\n      // Uniform alpha, so the horizontal color run survives as one gradient.\n      if (multi) {\n        return new echarts.graphic.LinearGradient(\n          0,\n          0,\n          1,\n          0,\n          slots.map((color, i) => ({\n            offset: i / (slots.length - 1),\n            color: withAlpha(color, 0.1),\n          })),\n        );\n      }\n      return withAlpha(base, 0.1);\n    }\n    case \"dotted\":\n    case \"lines\":\n    case \"hatched\": {\n      // Faded by default (opaque near the line, transparent at the baseline).\n      const texture = patternFadeTexture(variant, base, size.width, size.height);\n      if (texture) return { image: texture, repeat: \"no-repeat\" };\n      return patternFill(variant, base) ?? withAlpha(base, 0.1);\n    }\n    default:\n      return withAlpha(base, 0.1);\n  }\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Curve mapping — linear → straight, step → step:\"end\", everything else → smooth.\n// ─────────────────────────────────────────────────────────────────────────────\n\nfunction curveConfig(curveType: CurveType): { smooth: boolean; step: \"middle\" | false } {\n  // Recharts \"step\" is d3's curveStep: the transition happens at the MIDPOINT\n  // between points, so each dot sits centered on its plateau.\n  if (curveType === \"step\") return { smooth: false, step: \"middle\" };\n  if (curveType === \"linear\") return { smooth: false, step: false };\n  return { smooth: true, step: false };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Selection opacities (§4.3) — dims a series only when another one is selected.\n// ─────────────────────────────────────────────────────────────────────────────\n\nfunction getOpacity(selected: string | null, key: string) {\n  if (selected === null || selected === key) return { fill: 0.8, stroke: 1, dot: 1 };\n  return { fill: 0.1, stroke: 0.3, dot: 0.3 };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Loading skeleton helpers\n// ─────────────────────────────────────────────────────────────────────────────\n\n// Skeleton data as a smooth random walk in a comfortable band — reads like a\n// resting chart instead of raw noise spikes.\nfunction getLoadingData(points: number): number[] {\n  const rows: number[] = [];\n  let value = 30 + Math.random() * 20;\n  for (let i = 0; i < points; i++) {\n    value = Math.min(58, Math.max(16, value + (Math.random() - 0.5) * 16));\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// The `__buffer-` prefix marks the dashed forecast overlay of a buffer area; it\n// carries the SAME key's value, so the tooltip recovers the key from it (see\n// createTooltipFormatter). Every other `__`-prefixed series (mini chart, loading\n// skeleton, hover-reveal base) is truly internal and never surfaces.\nconst BUFFER_PREFIX = \"__buffer-\";\n// The fill-only patch under a buffer area's dashed tail (the main area's fill\n// stops one point early). Internal, so the tooltip drops it.\nconst BUFFERFILL_PREFIX = \"__bufferfill-\";\n// The `__reveal-` prefix marks the muted base layer of a hover-reveal area — see\n// buildAreaSeries. Internal, so the tooltip drops it like the mini/loading rows.\nconst REVEAL_PREFIX = \"__reveal-\";\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Option builders — pure functions from a snapshot context to ECharts option\n// fragments. The component reads its refs and renderer size ONCE per build into\n// this context; nothing below touches React state or the chart instance, so\n// each fragment can be reasoned about (and tested) in isolation.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype OptionBuildContext = {\n  data: Record<string, unknown>[];\n  config: ChartConfig;\n  areas: AreaSeriesConfig[];\n  seriesKeys: string[];\n  curveType: CurveType;\n  isStacked: boolean;\n  isExpanded: boolean;\n  selectedDataKey: string | null;\n  hasSelection: boolean;\n  showGrid: boolean;\n  xAxisSlot: XAxisSlot;\n  yAxisSlot: YAxisSlot;\n  tooltipSlot: TooltipSlot;\n  legendSlot: LegendSlot;\n  isLoading: boolean;\n  loadingData: () => number[];\n  showBrush: boolean;\n  brushHeight: number;\n  enableHoverHighlight: boolean;\n  enableHoverReveal: boolean; // hover colors each area up to the pointer, mutes the rest\n  revealIndex: number | null; // pointer's x-index while revealing (null = idle → chart looks normal)\n  revealSink: Record<string, unknown[]>; // buildAreaSeries writes each area's full per-datum points here for the hover handler\n  resolved: ResolvedColors;\n  rendererSize: { width: number; height: number }; // 2D gradient textures bake at renderer size\n  categories: string[];\n  brushRange: BrushRange; // zoom window carried through rebuilds\n  getHoveredKey: () => string | null; // read per tooltip render — hover never repushes the option\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({ legendSlot, xAxisSlot, showBrush, brushHeight }: 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 x-axis labels plus the same breathing room the\n  // Recharts twin leaves between them and the brush. An x-axis TITLE renders\n  // below the labels (nameGap), so it needs its own band above the brush frame.\n  const brushGap = showBrush ? brushHeight + 30 + (xAxisSlot.label ? 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\nfunction buildMainAxes(ctx: OptionBuildContext): { xAxis: XAxisOption; yAxis: YAxisOption } {\n  const { xAxisSlot, yAxisSlot, showGrid, isLoading, isExpanded, categories, loadingData } = 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\n  const xTickFormatter = xAxisSlot.tickFormatter;\n  const yTickFormatter = yAxisSlot.tickFormatter;\n\n  const xAxis: XAxisOption = {\n    type: \"category\",\n    boundaryGap: false,\n    show: true,\n    data: isLoading ? loadingData().map((_, i) => i) : categories,\n    // Axis title — same size/color as the tick labels, pushed clear of them.\n    name: isLoading ? undefined : xAxisSlot.label,\n    nameLocation: \"middle\",\n    nameGap: 30,\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,\n    // in the gridline gray (flattened opaque so the caps don't stack).\n    axisTick: {\n      show: !isLoading && xAxisSlot.present && !xAxisSlot.hideDots,\n      // Ticks default to the BOUNDARY between categories, which on a boundaryGap\n      // axis drops the dot in the gap instead of under its label. A no-op here\n      // (boundaryGap is false), kept for parity with the bar/composed charts.\n      alignWithLabel: true,\n      length: 0.5,\n      lineStyle: { color: tickDotColor, width: 3, cap: \"round\" },\n    },\n    splitLine: { show: false },\n    axisLabel: {\n      show: !isLoading && xAxisSlot.present,\n      color: axisLabelColor,\n      fontSize: 10,\n      margin: 8,\n      formatter: xTickFormatter\n        ? (value: string, index: number) => xTickFormatter(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 <YAxis>. Keep the axis on\n  // whenever <Grid/> is present and gate the LABELS on <YAxis/> instead.\n  const yAxis: YAxisOption = {\n    type: \"value\",\n    show: yAxisSlot.present || showGrid,\n    max: isExpanded ? 1 : undefined,\n    // Axis title — rendered rotated alongside the tick labels, same styling.\n    name: isLoading ? undefined : yAxisSlot.label,\n    nameLocation: \"middle\",\n    nameGap: 38,\n    nameTextStyle: { color: axisLabelColor, fontSize: 10 },\n    axisLine: { show: false },\n    // Same tick dots as the x-axis, beside each value label. No alignWithLabel\n    // here: ECharts types it on the CATEGORY axis only, and a value axis already\n    // puts its ticks on the labels.\n    axisTick: {\n      show: yAxisSlot.present && !isLoading && !yAxisSlot.hideDots,\n      length: 0.5,\n      lineStyle: { color: tickDotColor, width: 3, cap: \"round\" },\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\n      // Recharts YAxis unmounts during loading too.\n      show: yAxisSlot.present && !isLoading,\n      color: axisLabelColor,\n      fontSize: 10,\n      margin: 8,\n      formatter: isExpanded\n        ? (value: number) => `${Math.round(value * 100)}%`\n        : yTickFormatter\n          ? (value: number, index: number) => yTickFormatter(value, index)\n          : undefined,\n    },\n  };\n\n  return { xAxis, yAxis };\n}\n\n// Tooltip HTML builder, closed over the build context. `getHoveredKey` is read\n// per invocation — ECharts calls the formatter at hover time, and syncing hover\n// through an option push instead would reset the native blur state mid-hover.\nfunction createTooltipFormatter(ctx: OptionBuildContext) {\n  const { config, selectedDataKey, tooltipSlot, getHoveredKey } = 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    // Dedupe by effective key: a buffer area contributes both its solid part\n    // (id=key) and its dashed overlay (id=`__buffer-{key}`) at the shared\n    // second-to-last point. Keep the first non-null value seen per key so the\n    // final point (only the overlay has data there) still shows its number.\n    const seen = new Set<string>();\n    const body = rows\n      .map((param) => {\n        const p = param as {\n          seriesId?: string;\n          seriesName?: string;\n          value?: number | string | null;\n        };\n        const rawId = String(p.seriesId ?? \"\");\n        // Map the dashed buffer overlay back onto its series; drop every other\n        // internal series (mini chart, loading skeleton, hover-reveal base).\n        const key = rawId.startsWith(BUFFER_PREFIX)\n          ? rawId.slice(BUFFER_PREFIX.length)\n          : rawId.startsWith(\"__\")\n            ? \"\"\n            : (p.seriesId ?? p.seriesName ?? \"\");\n        if (!key) return \"\";\n        // A null value means this series does not reach the hovered x (a buffer\n        // area's solid part stops before the last point, a revealed series stops\n        // at the cursor) — skip it, letting another row for the key stand in.\n        if (p.value === null || p.value === undefined) return \"\";\n        if (seen.has(key)) return \"\";\n        seen.add(key);\n\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 hovered = getHoveredKey();\n        const dimmed =\n          (selectedDataKey != null && selectedDataKey !== key) ||\n          (hovered != null && hovered !== key)\n            ? \" opacity-30\"\n            : \"\";\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      cursor: tooltipSlot.cursor,\n      tokens,\n      position: tooltipSlot.position,\n      axisPointerColor: withAlpha(tokens.border, AXIS_POINTER_OPACITY),\n      strokeWidth: STROKE_WIDTH,\n    }),\n    formatter: createTooltipFormatter(ctx),\n  };\n}\n\n// ── Brush — the evil-brush look, canvas-style: a real mini chart of the full\n// data in a second grid, with a transparent slider dataZoom laid over it. Both\n// zoom entries target only the MAIN x-axis, so the mini chart never filters\n// itself. Only called when `showBrush` is set.\nfunction buildBrushOption(\n  ctx: OptionBuildContext,\n  brushBottom: number,\n): {\n  miniGrid: GridComponentOption;\n  miniXAxis: XAxisOption;\n  miniYAxis: YAxisOption;\n  miniSeries: LineSeriesOption[];\n  dataZoom: DataZoomComponentOption[];\n} {\n  const { data, areas, curveType, isStacked, selectedDataKey, 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: false,\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: LineSeriesOption[] = areas.map((area) => {\n    const key = area.dataKey;\n    const base = (ctx.resolved.series[key] ?? [])[0] ?? \"rgba(120, 120, 120, 1)\";\n    const curve = curveConfig(area.curveType ?? curveType);\n\n    // The mini chart mirrors the click selection: unselected series recede\n    // by the same ratios as the main plot.\n    // Dim ratios normalize against the base opacities (stroke 1, fill 0.8).\n    const opacity = getOpacity(selectedDataKey, key);\n    const strokeDim = opacity.stroke;\n    const fillDim = opacity.fill / 0.8;\n\n    return {\n      id: `__mini-${key}`,\n      type: \"line\",\n      xAxisIndex: 1,\n      yAxisIndex: 1,\n      data: data.map((row) => Number(row[key]) || 0),\n      stack: isStacked ? \"__mini-total\" : undefined,\n      smooth: curve.smooth,\n      step: curve.step,\n      connectNulls: area.connectNulls,\n      silent: true,\n      showSymbol: false,\n      emphasis: { disabled: true },\n      tooltip: { show: false },\n      lineStyle: { color: base, width: 1, opacity: BRUSH_STROKE_OPACITY * strokeDim },\n      areaStyle: {\n        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [\n          { offset: 0, color: withAlpha(base, BRUSH_FILL_OPACITY * fillDim) },\n          { offset: 1, color: withAlpha(base, 0) },\n        ]),\n      },\n      z: 0,\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 wave regardless of declared areas (Recharts\n// parity: its skeleton is a single LoadingArea), 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  const curve = curveConfig(ctx.curveType);\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: \"line\",\n        data: ctx.loadingData(),\n        smooth: curve.smooth,\n        step: curve.step,\n        showSymbol: false,\n        silent: true,\n        // Invisible until the first shimmer tick positions the clip window.\n        lineStyle: { color: withAlpha(tokens.foreground, 0), width: 1 },\n        areaStyle: { color: withAlpha(tokens.foreground, 0) },\n        z: 1,\n      },\n    ],\n  };\n}\n\nfunction buildAreaSeries(ctx: OptionBuildContext): LineSeriesOption[] {\n  const {\n    data,\n    config,\n    areas,\n    seriesKeys,\n    curveType,\n    isStacked,\n    isExpanded,\n    selectedDataKey,\n    hasSelection,\n    enableHoverHighlight,\n    enableHoverReveal,\n    revealIndex,\n    revealSink,\n    resolved,\n    rendererSize,\n  } = ctx;\n\n  // Optional per-row normalization for the expanded (100%) stack.\n  const rowTotals = isExpanded\n    ? data.map((row) => seriesKeys.reduce((sum, key) => sum + (Number(row[key]) || 0), 0))\n    : [];\n\n  return areas.flatMap((area): LineSeriesOption[] => {\n    const key = area.dataKey;\n    const slots = resolved.series[key] ?? [\"rgba(120, 120, 120, 1)\"];\n    const paint = seriesPaint(slots);\n    const isSelected = selectedDataKey === key;\n    const showUnselected = hasSelection && !isSelected;\n    const opacity = getOpacity(selectedDataKey, key);\n    const curve = curveConfig(area.curveType ?? curveType);\n\n    const values = data.map((row, i) => {\n      const value = Number(row[key]) || 0;\n      if (!isExpanded) return value;\n      const total = rowTotals[i];\n      return total ? value / total : 0;\n    });\n    const n = values.length;\n    // Hover-reveal is a root-level mode and owns the whole area rendering, so it\n    // takes precedence over a per-area buffer tail when both are set.\n    const reveal = enableHoverReveal;\n    const buffer = !reveal && area.enableBufferLine && n >= 2;\n    const revealActive = reveal && revealIndex !== null;\n\n    const restingDot = dotStyle(area.dotVariant, paint, resolved.tokens.background);\n    const activeDot = dotStyle(area.activeDotVariant, paint, resolved.tokens.background);\n    const restingVisible = area.dotVariant !== \"none\";\n    const dotOpacity = opacity.dot;\n    const multiColor = slots.length > 1;\n\n    // The reveal truncates the line to the cursor, which would COMPRESS a\n    // bbox-relative stroke gradient into the shorter span — misaligning it from\n    // the plot-anchored fill texture and the index-sampled dots. Anchor the\n    // stroke to the plot in absolute pixels so every x keeps its own color.\n    const strokePaint =\n      reveal && multiColor\n        ? new echarts.graphic.LinearGradient(\n            8,\n            0,\n            Math.max(rendererSize.width - 8, 9),\n            0,\n            slots.map((color, i) => ({ offset: i / (slots.length - 1), color })),\n            true,\n          )\n        : paint;\n\n    // Turn a value list into ECharts data — attaching per-datum symbol colors\n    // for multi-color areas (like the Recharts dots; the line/fill keep the full\n    // gradient), and passing `null` gaps through so a buffer area's two parts\n    // each draw only their own segment.\n    type AreaPoint =\n      | number\n      | null\n      | {\n          value: number;\n          itemStyle: Record<string, unknown>;\n          emphasis: { itemStyle: Record<string, unknown> };\n        };\n    const toPoints = (vals: (number | null)[]): AreaPoint[] =>\n      !multiColor\n        ? vals\n        : vals.map((value, i): AreaPoint => {\n            if (value === null) return null;\n            const t = vals.length > 1 ? i / (vals.length - 1) : 0;\n            const pointColor = sampleGradient(slots, t);\n            return {\n              value,\n              itemStyle: {\n                ...dotItemStyle(\n                  restingVisible ? area.dotVariant : area.activeDotVariant,\n                  pointColor,\n                  resolved.tokens.background,\n                ),\n                opacity: dotOpacity,\n              },\n              emphasis: {\n                itemStyle: {\n                  ...dotItemStyle(\n                    area.activeDotVariant === \"none\" ? \"default\" : area.activeDotVariant,\n                    pointColor,\n                    resolved.tokens.background,\n                  ),\n                  opacity: 1,\n                },\n              },\n            };\n          });\n\n    // Buffer area: the solid MAIN part drops the last point (its final segment —\n    // both fill and stroke — becomes the dashed, fill-less overlay); the overlay\n    // carries only the last two points. Reveal instead TRUNCATES the real series\n    // at the cursor's x-index (points beyond it null'd), so its line + fill stop\n    // there and the muted base layer shows through past it. When idle\n    // (revealIndex null) the real series carries its full data — the chart looks\n    // completely normal.\n    // Snapshot the FULL per-datum points (with the multi-color dot itemStyle) so\n    // the reveal hover handler can slice them without losing each dot's sampled\n    // gradient color — plain values would fall back to the default palette.\n    if (reveal) revealSink[key] = toPoints(values);\n\n    const mainValues: (number | null)[] = buffer\n      ? values.map((v, i) => (i === n - 1 ? null : v))\n      : revealActive\n        ? sliceToNull(values, revealIndex as number)\n        : values;\n\n    // A buffer area keeps its body solid and dashes only the tail overlay, so\n    // the main stroke is always solid regardless of strokeVariant (matches the\n    // Recharts twin, which suppresses the base dasharray while buffering).\n    const mainDash: \"solid\" | [number, number] =\n      buffer || area.strokeVariant === \"solid\" ? \"solid\" : ([3, 3] as [number, number]);\n\n    const z = isSelected ? 3 : hasSelection ? 1 : 2;\n\n    const mainSeries: LineSeriesOption = {\n      id: key,\n      name: typeof config[key]?.label === \"string\" ? config[key]?.label : key,\n      type: \"line\",\n      data: toPoints(mainValues),\n      stack: isStacked ? \"total\" : undefined,\n      smooth: curve.smooth,\n      step: curve.step,\n      connectNulls: area.connectNulls,\n      cursor: area.isClickable ? \"pointer\" : \"default\",\n      // By default ECharts only fires mouse events on the symbols — this makes\n      // the line AND the filled area clickable, like the Recharts <Area>.\n      // (`true` covers both; the deprecated `triggerLineEvent` did the same.)\n      triggerEvent: area.isClickable,\n      showSymbol: restingVisible,\n      symbol: \"circle\",\n      symbolSize: restingVisible ? restingDot.size : activeDot.size,\n      z,\n      lineStyle: {\n        color: strokePaint,\n        width: area.strokeWidth,\n        opacity: opacity.stroke,\n        type: mainDash,\n        dashOffset: 0,\n      },\n      itemStyle: multiColor\n        ? { opacity: dotOpacity }\n        : {\n            ...(restingVisible ? restingDot.itemStyle : activeDot.itemStyle),\n            opacity: dotOpacity,\n          },\n      areaStyle: {\n        color: fillPaint(area.variant, showUnselected, slots, rendererSize),\n        opacity: opacity.fill,\n      },\n      emphasis: {\n        // focus \"series\" blurs every other series in this grid while one is\n        // hovered — the hover twin of the click selection. Suppressed entirely\n        // while a series is click-selected: the selection dim owns the canvas,\n        // so hover highlighting stops until the selection clears (the option\n        // rebuilds on selection change, making this a build-time conditional).\n        // Reveal owns the hover visual, so native focus-blur stands down when it\n        // is on (they must not blend).\n        focus: enableHoverHighlight && !enableHoverReveal && !hasSelection ? \"series\" : \"none\",\n        scale: restingVisible ? activeDot.size / Math.max(restingDot.size, 1) : 1,\n        ...(multiColor ? {} : { itemStyle: { ...activeDot.itemStyle, opacity: 1 } }),\n      },\n      // Blur styling mirrors the click-selection dim (fill 0.1 / stroke 0.3 / dot 0.3).\n      blur: {\n        lineStyle: { opacity: 0.3 },\n        areaStyle: { opacity: 0.1 },\n        itemStyle: { opacity: 0.3 },\n      },\n    };\n\n    // Hover-reveal: a muted gray BASE layer of the FULL series sits one z below\n    // the real one. It is invisible while idle (opacity 0 → the chart looks\n    // normal) and fades in only while hovering, so the region PAST the cursor —\n    // where the truncated real series has stopped — shows as neutral gray.\n    if (reveal) {\n      const muted = resolved.tokens.mutedForeground;\n      const revealBase: LineSeriesOption = {\n        id: `${REVEAL_PREFIX}${key}`,\n        type: \"line\",\n        // Only the region FROM the cursor onward (null before it), so the gray\n        // never sits under the colored part — the two meet exactly at the\n        // pointer and their colors can't mix.\n        data: revealActive ? sliceFrom(values, revealIndex as number) : values,\n        // Its OWN stack, not \"total\" — a second series in the real stack would\n        // double every key's contribution (broken geometry). This mirror stack\n        // reproduces the same cumulative shape in a separate layer.\n        stack: isStacked ? \"__reveal-total\" : undefined,\n        smooth: curve.smooth,\n        step: curve.step,\n        connectNulls: false,\n        silent: true,\n        showSymbol: false,\n        symbol: \"circle\",\n        z: z - 1,\n        // Neutral gray, NO fill, SAME dash pattern as the colored line.\n        lineStyle: {\n          color: muted,\n          width: area.strokeWidth,\n          type: mainDash,\n          opacity: revealActive ? 0.3 : 0,\n        },\n        emphasis: { disabled: true },\n        blur: { lineStyle: { opacity: revealActive ? 0.3 : 0 } },\n        tooltip: { show: false },\n      };\n      return [revealBase, mainSeries];\n    }\n\n    if (!buffer) return [mainSeries];\n\n    // Dashed forecast overlay — draws ONLY the last segment's stroke, with NO\n    // fill (matching the line twin's fill-less buffer). Silent, so it never\n    // intercepts clicks/hover; it still feeds the axis tooltip (silent series\n    // are aggregated by axis), which is why the last point keeps its number.\n    const bufferValues: (number | null)[] = values.map((v, i) => (i >= n - 2 ? v : null));\n    const bufferSeries: LineSeriesOption = {\n      id: `${BUFFER_PREFIX}${key}`,\n      type: \"line\",\n      data: toPoints(bufferValues),\n      // Own mirror stack — a second series in \"total\" would double the last\n      // points' stacked height (buffer drawn too high). Same values in the same\n      // order give the identical cumulative height, so the dash lines up.\n      stack: isStacked ? \"__buffer-total\" : undefined,\n      smooth: curve.smooth,\n      step: curve.step,\n      connectNulls: true,\n      silent: true,\n      showSymbol: restingVisible,\n      symbol: \"circle\",\n      symbolSize: restingVisible ? restingDot.size : activeDot.size,\n      z,\n      lineStyle: {\n        color: paint,\n        width: area.strokeWidth,\n        opacity: opacity.stroke,\n        type: BUFFER_DASH,\n      },\n      itemStyle: multiColor\n        ? { opacity: dotOpacity }\n        : {\n            ...(restingVisible ? restingDot.itemStyle : activeDot.itemStyle),\n            opacity: dotOpacity,\n          },\n      // The dashed tail is a separate silent series, so focus:\"series\" on its\n      // parent would blur it apart from the area it belongs to. The root\n      // dispatch-links this id (companionIdsByKey) so it focuses WITH its parent;\n      // these styles give it the parent's look while focused and the\n      // click-selection dim while another series is hovered.\n      emphasis: {\n        focus: \"none\",\n        scale: false,\n        lineStyle: { opacity: opacity.stroke },\n        itemStyle: { opacity: dotOpacity },\n      },\n      blur: { lineStyle: { opacity: 0.3 }, itemStyle: { opacity: 0.3 } },\n    };\n\n    // Fill patch — the main area drops its last point (so the tail stroke can be\n    // the dashed overlay), which also removes the FILL under that last segment.\n    // This fill-only layer (no stroke, no dots) fills just that segment so the\n    // area reads as full under the dashed tail. Its OWN mirror stack keeps the\n    // stacked geometry right — a second series in \"total\" would double the last\n    // points' cumulative height (same reason as the reveal base + mini chart).\n    const bufferFillSeries: LineSeriesOption = {\n      id: `${BUFFERFILL_PREFIX}${key}`,\n      type: \"line\",\n      data: toPoints(bufferValues),\n      stack: isStacked ? \"__bufferfill-total\" : undefined,\n      smooth: curve.smooth,\n      step: curve.step,\n      connectNulls: true,\n      silent: true,\n      showSymbol: false,\n      z: z - 1,\n      lineStyle: { opacity: 0 },\n      areaStyle: {\n        color: fillPaint(area.variant, showUnselected, slots, rendererSize),\n        opacity: opacity.fill,\n      },\n      emphasis: { disabled: true },\n      blur: { areaStyle: { opacity: 0.1 } },\n      tooltip: { show: false },\n    };\n\n    return [mainSeries, bufferSeries, bufferFillSeries];\n  });\n}\n\n// Copy a value list with everything AFTER `idx` nulled — the hover-reveal cut:\n// the colored real series keeps its data up to the cursor and drops the rest, so\n// (with connectNulls false) its line and fill stop dead at the pointer.\nfunction sliceToNull<T>(vals: readonly T[], idx: number): (T | null)[] {\n  return vals.map((v, i) => (i > idx ? null : v));\n}\n\n// Copy a value list with everything BEFORE `idx` nulled — the reveal's gray tail.\n// The muted base keeps only the region from the cursor onward, so it never sits\n// under the colored part; both include `idx` so they meet at the pointer.\n// Generic so it preserves per-datum point objects (multi-color dot itemStyle).\nfunction sliceFrom<T>(vals: readonly T[], idx: number): (T | null)[] {\n  return vals.map((v, i) => (i < idx ? null : v));\n}\n\n// Per-series PLOTTED top value per category index — expanded normalization and\n// stack accumulation applied — so pointer hit-testing can reason in data space.\nfunction computePlottedTops(ctx: OptionBuildContext): Record<string, number[]> {\n  const { data, areas, seriesKeys, isStacked, isExpanded } = ctx;\n  const rowTotals = isExpanded\n    ? data.map((row) => seriesKeys.reduce((sum, key) => sum + (Number(row[key]) || 0), 0))\n    : [];\n  const running = new Array(data.length).fill(0);\n  const tops: Record<string, number[]> = {};\n  for (const area of areas) {\n    const key = area.dataKey;\n    tops[key] = data.map((row, i) => {\n      let value = Number(row[key]) || 0;\n      if (isExpanded) value = rowTotals[i] ? value / rowTotals[i] : 0;\n      return isStacked ? (running[i] += value) : value;\n    });\n  }\n  return tops;\n}\n\n// Overlapping area polygons all contain the same pixel, so ECharts' native hit\n// test lands on whichever series drew topmost — not the band the user SEES.\n// Resolve the intended series geometrically: a plotted line within grab\n// distance of the pointer wins outright; otherwise the point belongs to the\n// nearest line ABOVE it (the boundary of the band the pointer is inside).\n// Returns null when the pointer is outside the grid or above every line.\nfunction resolveAreaAtPixel(\n  chart: EChartsInstance,\n  tops: Record<string, number[]>,\n  keys: string[],\n  x: number,\n  y: number,\n): string | null {\n  if (keys.length < 2) return null;\n  if (!chart.containPixel({ gridIndex: 0 }, [x, y])) return null;\n  const [rawIndex] = chart.convertFromPixel({ gridIndex: 0 }, [x, y]);\n  const index = Math.round(rawIndex);\n\n  let nearest: string | null = null;\n  let nearestDist = Infinity;\n  let above: string | null = null;\n  let abovePixelY = -Infinity;\n  for (const key of keys) {\n    const value = tops[key]?.[index];\n    if (value === undefined) continue;\n    const pixelY = chart.convertToPixel({ gridIndex: 0 }, [index, value])[1];\n    const dist = Math.abs(pixelY - y);\n    if (dist < nearestDist) {\n      nearestDist = dist;\n      nearest = key;\n    }\n    // Pixel y grows downward: a line above the pointer has the larger pixelY\n    // among those ≤ the pointer's.\n    if (pixelY <= y && pixelY > abovePixelY) {\n      abovePixelY = pixelY;\n      above = key;\n    }\n  }\n  return nearestDist <= 10 ? nearest : above;\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 so the whole imperative surface is visible at a glance.\n// None of it is render output, which is exactly why it is not React state.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype LiveState = {\n  resolved: ResolvedColors | null; // colors read off the live DOM — feeds builds and rAF loops\n  hoveredKey: string | null; // tooltip's view of hover — the legend's twin lives in React state\n  hasRevealed: boolean; // the intro draw-in already played on this chart instance\n  revealEndsAt: number; // performance.now() timestamp when the entrance settles\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  plottedTops: Record<string, number[]>; // per-series plotted line value per index, for pointer hit-testing\n  seriesKeyByIndex: (string | undefined)[]; // built series order → key, so a polygon click's seriesIndex recovers its key past interleaved buffer/reveal/mini series\n  companionIdsByKey: Map<string, string[]>; // per-key silent companion series ids (buffer tail, reveal base) — highlighted/downplayed with their parent\n  revealIndex: number | null; // hover-reveal pointer x-index (null = idle); read by builds and the reveal hover handler\n  revealValues: Record<string, unknown[]>; // per-area FULL per-datum points (with dot itemStyle), sliced to the cursor on hover without a rebuild\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    onSelectionChange?: (key: string | null) => void;\n    clickableKeys: Set<string>;\n    selectedDataKey: string | null;\n    brushFormatLabel?: (value: string, index: number) => string;\n    seriesKeys: string[];\n    enableHoverHighlight: boolean;\n    enableHoverReveal: boolean;\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};\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Component\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Apache ECharts port of the EvilCharts area 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 reveal, and optional zoom\n * brush; every visual part — `<Area>`, `<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` and `echarts`.\n */\nexport function EChartsAreaChart<TData extends Record<string, unknown>>({\n  data,\n  config,\n  xDataKey,\n  className,\n  curveType = \"linear\",\n  stackType = \"default\",\n  animation = true,\n  animationType = \"left-to-right\",\n  enableHoverHighlight = false,\n  enableHoverReveal = false,\n  defaultSelectedDataKey = null,\n  selectedDataKey: selectedDataKeyProp,\n  onSelectionChange,\n  isLoading = false,\n  loadingPoints = LOADING_DEFAULT_POINTS,\n  chartOptions,\n  children,\n}: EChartsAreaChartProps<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 \"chain of computations\"\n  // react.dev/learn/you-might-not-need-an-effect warns about. The object\n  // identity is stable for the component's lifetime.\n  const live = useRef<LiveState>({\n    resolved: null,\n    hoveredKey: null,\n    hasRevealed: false,\n    revealEndsAt: 0,\n    loadingRows: null,\n    categories: [],\n    dataLength: 0,\n    plottedTops: {},\n    seriesKeyByIndex: [],\n    companionIdsByKey: new Map<string, string[]>(),\n    revealIndex: null,\n    revealValues: {},\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      onSelectionChange,\n      clickableKeys: new Set<string>(),\n      selectedDataKey: defaultSelectedDataKey,\n      brushFormatLabel: undefined, // set per-render from the <Brush> child's formatLabel\n      seriesKeys: [],\n      enableHoverHighlight,\n      enableHoverReveal,\n    },\n    repush: () => {},\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 ??= getLoadingData(loadingPoints)),\n    [live, loadingPoints],\n  );\n  const shouldReduceMotion = useReducedMotion();\n\n  // Selection is controlled when the `selectedDataKey` prop is provided;\n  // otherwise the internal state (seeded by defaultSelectedDataKey) drives it.\n  const [internalSelectedKey, setSelectedDataKey] = useState<string | null>(defaultSelectedDataKey);\n  const selectedDataKey =\n    selectedDataKeyProp !== undefined ? selectedDataKeyProp : internalSelectedKey;\n\n  // Hover-highlight mirrors into the legend (React state) and tooltip\n  // (live.hoveredKey — its formatter runs on every hover, and pushing an option\n  // to sync it would reset ECharts' blur state mid-hover).\n  const [hoveredDataKey, setHoveredDataKey] = useState<string | null>(null);\n\n  // ── Declarative config, collected from children by reference ─────────────────\n  const collected = useMemo(() => collectConfig(children), [children]);\n  const {\n    areas,\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 seriesKeys = useMemo(() => areas.map((area) => area.dataKey), [areas]);\n\n  // x category key: <XAxis dataKey> → root xDataKey → first data column no <Area> claims.\n  const xCategoryKey = useMemo(() => {\n    if (xAxisSlot.dataKey) return xAxisSlot.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  }, [xAxisSlot.dataKey, xDataKey, data, seriesKeys]);\n\n  // The intro draw-in follows the first area's setting, falling back to the root default.\n  const effectiveAnimation = areas[0]?.animationType ?? animationType;\n\n  const css = useMemo(() => buildChartCss(chartId, config), [chartId, config]);\n\n  const hasSelection = selectedDataKey !== null;\n  const isExpanded = stackType === \"expanded\";\n  const isStacked = stackType === \"stacked\" || isExpanded;\n\n  // Which series may be clicked to toggle selection (consulted by the click handler).\n  const clickableKeys = useMemo(\n    () => new Set(areas.filter((area) => area.isClickable).map((area) => area.dataKey)),\n    [areas],\n  );\n\n  // Refresh the handlers' snapshot of the latest callbacks/flags every render.\n  live.handlers = {\n    onBrushChange: brushSlot.onChange,\n    onSelectionChange,\n    clickableKeys,\n    selectedDataKey,\n    brushFormatLabel: brushSlot.formatLabel,\n    seriesKeys,\n    enableHoverHighlight,\n    enableHoverReveal,\n  };\n  live.dataLength = data.length;\n\n  // Reads the CURRENT selection through live.handlers so the identity stays\n  // stable for the init effect's click closure, and stays correct when the\n  // selection is controlled from outside.\n  const toggleSelection = useCallback(\n    (key: string) => {\n      const next = live.handlers.selectedDataKey === key ? null : key;\n      // Making a selection hands the canvas to the selection dim — clear any\n      // active hover highlight at that moment so it doesn't linger in the\n      // legend/tooltip or fight the notMerge rebuild that follows.\n      if (next !== null && live.hoveredKey !== null) {\n        const previous = live.hoveredKey;\n        live.hoveredKey = null;\n        setHoveredDataKey(null);\n        echartsRef.current?.dispatchAction({\n          type: \"downplay\",\n          seriesIndex: live.handlers.seriesKeys.indexOf(previous),\n        });\n      }\n      setSelectedDataKey(next);\n      live.handlers.onSelectionChange?.(next);\n    },\n    [live],\n  );\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  // Thin orchestrator over the pure builders above: snapshot the imperative\n  // surface (refs, renderer size) into an OptionBuildContext, then assemble.\n  const buildOption = useCallback((): EChartsOption => {\n    const resolved = live.resolved;\n    if (!resolved) return {};\n\n    const categories = data.map((row) => String(row[xCategoryKey]));\n    live.categories = categories;\n\n    // buildAreaSeries fills this with each area's full per-datum points (with the\n    // multi-color dot itemStyle) so the reveal hover handler slices real data.\n    const revealSink: Record<string, unknown[]> = {};\n\n    const ctx: OptionBuildContext = {\n      data,\n      config,\n      areas,\n      seriesKeys,\n      curveType,\n      isStacked,\n      isExpanded,\n      selectedDataKey,\n      hasSelection,\n      showGrid,\n      xAxisSlot,\n      yAxisSlot,\n      tooltipSlot,\n      legendSlot,\n      isLoading,\n      loadingData,\n      showBrush,\n      brushHeight,\n      enableHoverHighlight,\n      enableHoverReveal,\n      revealIndex: live.revealIndex,\n      resolved,\n      rendererSize: {\n        width: echartsRef.current?.getWidth() ?? mountRef.current?.clientWidth ?? 0,\n        height: echartsRef.current?.getHeight() ?? mountRef.current?.clientHeight ?? 0,\n      },\n      categories,\n      brushRange: live.brushRange,\n      getHoveredKey: () => live.hoveredKey,\n      revealSink,\n    };\n\n    live.plottedTops = computePlottedTops(ctx);\n\n    const { grid, brushBottom } = buildChartLayout(ctx);\n    live.brushGeom = showBrush ? { 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 = showBrush ? buildBrushOption(ctx, brushBottom) : null;\n\n    const series = [...buildAreaSeries(ctx), ...(brush?.miniSeries ?? [])];\n    // buildAreaSeries has now filled revealSink with each area's full per-datum\n    // points — hand them to the hover handler for slicing.\n    if (enableHoverReveal) live.revealValues = revealSink;\n    // Record the exact series order so an area-polygon click (which reports only\n    // a seriesIndex) can recover its key — buffer/reveal/mini/loading series\n    // break the \"index === key position\" shortcut, so map each index to its id.\n    live.seriesKeyByIndex = series.map((s) => {\n      const id = String(s.id ?? \"\");\n      return id && !id.startsWith(\"__\") ? id : undefined;\n    });\n    // Map each key to its silent companion series ids (buffer tail, hover-reveal\n    // base), mirroring exactly what buildAreaSeries emits — the hover handlers\n    // highlight/downplay these with the parent so focus:\"series\" never strands\n    // an area's own forecast tail or muted reveal base apart from it.\n    const companionIdsByKey = new Map<string, string[]>();\n    for (const area of areas) {\n      const ids: string[] = [];\n      if (area.enableBufferLine && data.length >= 2) {\n        ids.push(`${BUFFER_PREFIX}${area.dataKey}`, `${BUFFERFILL_PREFIX}${area.dataKey}`);\n      }\n      if (enableHoverReveal) ids.push(`${REVEAL_PREFIX}${area.dataKey}`);\n      if (ids.length) companionIdsByKey.set(area.dataKey, ids);\n    }\n    live.companionIdsByKey = companionIdsByKey;\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,\n    };\n  }, [\n    live,\n    data,\n    config,\n    areas,\n    seriesKeys,\n    xCategoryKey,\n    curveType,\n    isStacked,\n    isExpanded,\n    selectedDataKey,\n    hasSelection,\n    showGrid,\n    xAxisSlot,\n    yAxisSlot,\n    tooltipSlot,\n    legendSlot,\n    isLoading,\n    loadingData,\n    showBrush,\n    brushHeight,\n    enableHoverHighlight,\n    enableHoverReveal,\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 line's\n      // reveal clip — 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      // 2D gradient textures are baked at renderer size — rebuild them to fit.\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    chart.on(\"click\", (params) => {\n      const { clickableKeys: clickable, seriesKeys: keys } = live.handlers;\n      const p = params as {\n        seriesId?: string;\n        seriesIndex?: number;\n        event?: { offsetX?: number; offsetY?: number };\n      };\n      // Symbol clicks carry seriesId; area-polygon clicks (triggerEvent)\n      // only carry seriesIndex — recover the key from the last build's index map,\n      // which accounts for the extra `__buffer-`/`__reveal-`/`__mini-` series\n      // interleaved between the main ones (a raw seriesKeys lookup would miss).\n      let id =\n        p.seriesId ??\n        (typeof p.seriesIndex === \"number\" ? live.seriesKeyByIndex[p.seriesIndex] : undefined);\n      // Overlapping polygons: the native hit is the topmost series, not the\n      // band the pointer is visually inside — resolve geometrically.\n      if (typeof p.event?.offsetX === \"number\" && typeof p.event?.offsetY === \"number\") {\n        const resolved = resolveAreaAtPixel(\n          chart,\n          live.plottedTops,\n          keys,\n          p.event.offsetX,\n          p.event.offsetY,\n        );\n        if (resolved) id = resolved;\n      }\n      if (typeof id === \"string\" && clickable.has(id)) toggleSelection(id);\n    });\n\n    // Hover-highlight is POINTER-driven, not series-mouseover-driven:\n    // overlapping polygons would pin the native hover on the topmost series and\n    // never re-fire while the pointer moves within it. A zrender mousemove\n    // tracker resolves the visually-hovered band, drives the canvas emphasis\n    // via dispatchAction, and mirrors into the HTML legend (React state) and\n    // tooltip (live.hoveredKey — its formatter runs per pointer move).\n    const applyHoverKey = (key: string | null) => {\n      if (live.hoveredKey === key) return;\n      const previous = live.hoveredKey;\n      live.hoveredKey = key;\n      setHoveredDataKey(key);\n      // Dispatch by seriesId (buffer/reveal series shift the numeric indices), and\n      // link each key's silent companions (buffer tail, reveal base) so\n      // focus:\"series\" never strands them apart from their parent.\n      if (previous) {\n        chart.dispatchAction({ type: \"downplay\", seriesId: previous });\n        for (const id of live.companionIdsByKey.get(previous) ?? [])\n          chart.dispatchAction({ type: \"downplay\", seriesId: id });\n      }\n      if (key) {\n        chart.dispatchAction({ type: \"highlight\", seriesId: key });\n        for (const id of live.companionIdsByKey.get(key) ?? [])\n          chart.dispatchAction({ type: \"highlight\", seriesId: id });\n      }\n    };\n\n    // Hover-reveal: color each area up to the pointer's x-index, mute the rest.\n    // Purely TARGETED series updates (real series data + muted base opacity) — we\n    // NEVER rebuild the whole option on mousemove, which would replay transitions\n    // and fight the tooltip's axis pointer.\n    const pushReveal = (idx: number | null) => {\n      const keys = live.handlers.seriesKeys;\n      const on = idx !== null;\n      chart.setOption(\n        {\n          series: keys.flatMap((key) => [\n            {\n              id: key,\n              data: on\n                ? sliceToNull(live.revealValues[key] ?? [], idx)\n                : (live.revealValues[key] ?? []),\n            },\n            {\n              id: `${REVEAL_PREFIX}${key}`,\n              // Gray tail keeps only the region from the cursor onward.\n              data: on\n                ? sliceFrom(live.revealValues[key] ?? [], idx)\n                : (live.revealValues[key] ?? []),\n              lineStyle: { opacity: on ? 0.3 : 0 },\n            },\n          ]),\n        },\n        // NOT lazy: the highlight dispatched just below re-draws the active dot\n        // the setOption wipes, so the option must be committed first — a queued\n        // (lazy) update would land after the dispatch and erase the dot again.\n        { silent: true },\n      );\n      // The per-frame setOption above cancels the axis tooltip's transient hover\n      // symbol, so the <ActiveDot> never lands at the cursor. Re-assert it:\n      // highlighting a real series at the cursor index draws its emphasis symbol\n      // (the active dot) even with showSymbol:false; downplay clears it on exit.\n      for (const key of keys) {\n        chart.dispatchAction(\n          on\n            ? { type: \"highlight\", seriesId: key, dataIndex: idx as number }\n            : { type: \"downplay\", seriesId: key },\n        );\n      }\n    };\n    const applyReveal = (event: { offsetX?: number; offsetY?: number }) => {\n      const len = live.dataLength;\n      if (len < 1) return;\n      const x = event.offsetX ?? -1;\n      const y = event.offsetY ?? -1;\n      if (!chart.containPixel({ gridIndex: 0 }, [x, y])) {\n        clearReveal();\n        return;\n      }\n      const raw = chart.convertFromPixel({ gridIndex: 0 }, [x, y])[0];\n      const idx = Math.max(0, Math.min(len - 1, Math.round(raw)));\n      if (idx === live.revealIndex) return;\n      live.revealIndex = idx;\n      pushReveal(idx);\n    };\n    const clearReveal = () => {\n      if (live.revealIndex === null) return;\n      live.revealIndex = null;\n      pushReveal(null);\n    };\n\n    const zrHover = chart.getZr();\n    const onZrHoverMove = (event: { offsetX?: number; offsetY?: number }) => {\n      // Reveal is a standalone hover mode and takes precedence over highlight.\n      if (live.handlers.enableHoverReveal) {\n        applyReveal(event);\n        return;\n      }\n      if (!live.handlers.enableHoverHighlight) return;\n      // A click selection owns the canvas dim — hover highlighting stops\n      // entirely while one exists and resumes once it clears.\n      if (live.handlers.selectedDataKey !== null) return;\n      applyHoverKey(\n        resolveAreaAtPixel(\n          chart,\n          live.plottedTops,\n          live.handlers.seriesKeys,\n          event.offsetX ?? -1,\n          event.offsetY ?? -1,\n        ),\n      );\n    };\n    const onZrHoverOut = () => {\n      if (live.handlers.enableHoverReveal) clearReveal();\n      else if (live.handlers.enableHoverHighlight) applyHoverKey(null);\n    };\n    zrHover.on(\"mousemove\", onZrHoverMove);\n    zrHover.on(\"globalout\", onZrHoverOut);\n\n    // The native hover still emphasizes whichever element the pointer entered —\n    // cancel it whenever it disagrees with the tracker's resolved key.\n    chart.on(\"mouseover\", (params) => {\n      const { enableHoverHighlight: hoverOn, enableHoverReveal: revealOn } = live.handlers;\n      if (!hoverOn || revealOn) return;\n      // While a selection is active, hover highlighting is disabled — never\n      // dispatch emphasis/downplay so the selection dim is the only dimming.\n      if (live.handlers.selectedDataKey !== null) return;\n      const p = params as { seriesIndex?: number; componentType?: string };\n      if (p.componentType !== \"series\" || typeof p.seriesIndex !== \"number\") return;\n      const key = live.seriesKeyByIndex[p.seriesIndex];\n      if (!key || key.startsWith(\"__\")) return;\n      if (key !== live.hoveredKey) {\n        chart.dispatchAction({ type: \"downplay\", seriesIndex: p.seriesIndex });\n        if (live.hoveredKey) {\n          chart.dispatchAction({ type: \"highlight\", seriesId: live.hoveredKey });\n        }\n      }\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    // 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      zrHover.off(\"mousemove\", onZrHoverMove);\n      zrHover.off(\"globalout\", onZrHoverOut);\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      const option = buildOption();\n      const merged = chartOptions ? { ...option, ...chartOptions } : option;\n      Object.assign(merged, {\n        animation: withEntrance,\n        animationDuration: REVEAL_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      // Overlays live outside the option — reposition them after every push.\n      syncBrushOverlayNow();\n    };\n\n    // Intro reveal — ECharts' native progressive draw, enabled only for the first\n    // real render: the line traces in, dots pop up as its front passes. Every\n    // later push (selection, theme, zoom) applies instantly, since notMerge would\n    // otherwise replay the entrance on each of them. A loading cycle re-arms it:\n    // the Recharts twin unmounts its <Area>s while loading and replays the intro\n    // on remount, so data → loading → data draws 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    if (revealEnabled) live.revealEndsAt = performance.now() + REVEAL_DURATION;\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    syncBrushOverlayNow,\n  ]);\n\n  // ── Animated dashed stroke — rAF sweeps the dash offset while unselected ─────\n  useEffect(() => {\n    const chart = echartsRef.current;\n    if (!chart || isLoading) return;\n    const animatedKeys = areas\n      .filter((area) => area.strokeVariant === \"animated-dashed\" && !area.enableBufferLine)\n      .map((area) => area.dataKey);\n    if (animatedKeys.length === 0 || hasSelection) return;\n\n    let raf = 0;\n    let delayTimer: ReturnType<typeof setTimeout> | undefined;\n    const begin = () => {\n      const loopStart = performance.now();\n      const tick = (now: number) => {\n        const offset = -(((now - loopStart) / 1000) % 1) * 6; // 0 → -6 per second\n        chart.setOption(\n          { series: animatedKeys.map((id) => ({ id, lineStyle: { dashOffset: offset } })) },\n          { silent: true, lazyUpdate: true },\n        );\n        raf = requestAnimationFrame(tick);\n      };\n      raf = requestAnimationFrame(tick);\n    };\n\n    // Per-frame setOption churn fights the intro draw-in (each update pass\n    // recomputes the reveal clip, crawling it to a standstill) — hold the dash\n    // sweep until the entrance has finished.\n    const delay = Math.max(0, live.revealEndsAt - performance.now());\n    if (delay > 0) delayTimer = setTimeout(begin, delay + 50);\n    else begin();\n\n    return () => {\n      if (delayTimer !== undefined) clearTimeout(delayTimer);\n      cancelAnimationFrame(raf);\n    };\n  }, [live, areas, hasSelection, isLoading]);\n\n  // ── Loading shimmer — rAF sweeps a bright band, regenerating data off-screen ─\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 data.\n      if (phase < lastPhase) live.loadingRows = getLoadingData(loadingPoints);\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 ?? \"rgba(120, 120, 120, 1)\";\n      // Sweep the clip window from fully off-screen left to fully off-screen\n      // right, leaned 45°. The gradient uses ABSOLUTE pixel coordinates shared\n      // by stroke and fill — bbox-relative coords put the window at different\n      // positions for the line vs the area polygon (their bounding boxes\n      // differ), which made the line trail the fill near the sweep's end.\n      const w = chart.getWidth();\n      const h = chart.getHeight();\n      if (!w || !h) {\n        raf = requestAnimationFrame(tick);\n        return;\n      }\n      // Farthest plot corner projected onto the 45° axis — keeps the sweep\n      // tight instead 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 clip = (peak: number) =>\n        new echarts.graphic.LinearGradient(\n          0,\n          0,\n          w,\n          w,\n          shimmerWindowStops(center, foreground, peak),\n          true,\n        );\n      chart.setOption(\n        {\n          series: [\n            {\n              id: \"__loading\",\n              data: loadingData(),\n              lineStyle: { color: clip(LOADING_STROKE_OPACITY), width: 1 },\n              areaStyle: { color: clip(LOADING_SHIMMER_MAX_OPACITY) },\n            },\n          ],\n        },\n        { silent: true, lazyUpdate: true },\n      );\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [live, isLoading, loadingPoints, 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: showBrush ? 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={hoveredDataKey}\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// Compound API: every part hangs off the root as a static member, so a consumer\n// writes <EChartsAreaChart.Area/>, <EChartsAreaChart.Tooltip/>, … from a single\n// import — no colliding named marker exports when several charts share one file.\nEChartsAreaChart.Area = Area;\nEChartsAreaChart.Dot = Dot;\nEChartsAreaChart.ActiveDot = ActiveDot;\nEChartsAreaChart.XAxis = XAxis;\nEChartsAreaChart.YAxis = YAxis;\nEChartsAreaChart.Grid = Grid;\nEChartsAreaChart.Tooltip = Tooltip;\nEChartsAreaChart.Legend = Legend;\nEChartsAreaChart.Brush = Brush;\n",
      "type": "registry:component",
      "target": "components/evilcharts/charts/echarts-area-chart.tsx"
    }
  ],
  "type": "registry:component"
}