{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "echarts-radar-chart",
  "description": "Radar chart component rendered with Apache ECharts",
  "dependencies": [
    "echarts",
    "motion"
  ],
  "registryDependencies": [
    "@evilcharts/echarts-chart",
    "@evilcharts/echarts-tooltip",
    "@evilcharts/echarts-dot",
    "@evilcharts/echarts-legend"
  ],
  "files": [
    {
      "path": "src/registry/charts/echarts-radar-chart.tsx",
      "content": "\"use client\";\n\nimport {\n  resolveTooltipPosition,\n  roundnessClass,\n  tooltipIndicatorHtml,\n  tooltipRow,\n  tooltipVariantClass,\n  type TooltipPosition,\n  type TooltipRoundness,\n  type TooltipVariant,\n} from \"@/registry/ui/echarts-tooltip\";\nimport {\n  Children,\n  isValidElement,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type CSSProperties,\n  type FC,\n  type ReactNode,\n} from \"react\";\nimport {\n  buildChartCss,\n  getColorsCount,\n  resolveColors,\n  withAlpha,\n  type ChartConfig,\n  type ResolvedColors,\n} from \"@/registry/ui/echarts-chart\";\nimport {\n  RadarComponent,\n  TooltipComponent,\n  type RadarComponentOption,\n  type TooltipComponentOption,\n} from \"echarts/components\";\nimport { dotStyle, sampleGradient, type DotVariant } from \"@/registry/ui/echarts-dot\";\nimport { LegendOverlay, type LegendVariant } from \"@/registry/ui/echarts-legend\";\nimport { RadarChart, type RadarSeriesOption } from \"echarts/charts\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { CanvasRenderer } from \"echarts/renderers\";\nimport type { ComposeOption } from \"echarts/core\";\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// `RadarComponent` is the polar coordinate system (indicators, rings, spokes);\n// `RadarChart` is the series that draws polygons inside it. No GraphicComponent —\n// this chart has no zrender overlays (the recharts twin has no brush).\necharts.use([RadarChart, RadarComponent, TooltipComponent, CanvasRenderer]);\n\ntype EChartsInstance = ReturnType<typeof echarts.init>;\n\n// The exact option surface this chart uses — radar series, the radar coordinate\n// component, and the tooltip. Narrower than echarts' full EChartsOption, so a\n// misspelled key fails the compile instead of silently reaching setOption. Radar\n// has no cartesian axes, so unlike the area chart there are no derived x/y types.\ntype EChartsOption = ComposeOption<\n  RadarSeriesOption | RadarComponentOption | TooltipComponentOption\n>;\n\n// Single-item view of the radar component's array-or-single field, used where a\n// single radar coordinate system is built.\ntype ArrayItem<T> = T extends readonly (infer U)[] ? U : T;\ntype RadarOption = ArrayItem<NonNullable<EChartsOption[\"radar\"]>>;\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Constants\n// ─────────────────────────────────────────────────────────────────────────────\n\nconst STROKE_WIDTH = 1;\nconst DEFAULT_FILL_OPACITY = 0.3; // resting fill opacity for a filled radar (twin default)\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 is DRIVEN BY DATA, not ECharts' native radar entrance.\n// ECharts grows the polygon from the center but SNAPS the vertex symbols straight\n// to their final positions (its RadarView reads `oldPoints` from the polyline\n// AFTER `initProps` has already committed the target points, so every symbol\n// starts and ends at the same place and never moves — verified empirically). That\n// left the dots detached from the still-collapsing shape. Instead we disable the\n// native entrance and ramp every series' VALUES from 0 (all vertices at the center)\n// to their final magnitude over REVEAL_DURATION, re-pushing the data each frame.\n// Because the symbols track their datum, they ride the polygon vertices out from\n// the center and stay glued to the shape at every frame. Radar has no directional\n// wipe like the area chart, so `animation` is the single master switch.\nconst LOADING_DEFAULT_POINTS = 6; // matches the recharts twin's LOADING_POINTS\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 the polar grid at ~border/20, but SVG dashes render pixel-crisp\n// while 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 rings + radial spokes, × border alpha\n// The skeleton is CLIPPED to a small sweeping window — only the polygon section\n// inside it exists (stroke + fill), everything outside is fully transparent, like\n// 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.05; // 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\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Public types\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport type RadarVariant = \"filled\" | \"lines\";\nexport type GridType = \"polygon\" | \"circle\";\n// DotVariant, TooltipVariant, TooltipRoundness, TooltipPosition, LegendVariant,\n// and ChartConfig now live in the shared @/registry/ui/echarts/* modules and are\n// imported + re-exported at the top of this file.\n\nexport interface EChartsRadarChartProps<TData extends Record<string, unknown>> {\n  data: TData[]; // rows rendered by the chart — each row is one angle-axis category\n  config: ChartConfig; // series colors + labels\n  className?: string; // extra classes for the chart container\n  animation?: boolean; // master switch for the intro draw-in — false renders instantly\n  defaultSelectedDataKey?: string | null; // series selected on first render\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 — <Radar>, <PolarGrid>, <PolarAngleAxis>, …\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Composible parts — DECLARATIVE CONFIG. Every part renders `null`; the root\n// walks `children` by reference (child.type === Radar, …) 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 RadarProps {\n  dataKey: string; // series key — must exist on the data + config\n  variant?: RadarVariant; // \"filled\" shows the fill, \"lines\" only the outline\n  fillOpacity?: number; // opacity of the filled area when variant=\"filled\"\n  isClickable?: boolean; // lets this radar be selected by clicking it\n  children?: ReactNode; // optional <Dot> and <ActiveDot> config\n}\n\n/**\n * A single radar series — one polygon over all angle-axis categories. Declares\n * its own fill/clickability and, optionally, resting/active vertex markers\n * via composed <Dot> / <ActiveDot>. Renders nothing — the root reads these props\n * to build the ECharts radar series.\n */\nconst Radar: FC<RadarProps> = () => null;\n\nexport interface DotProps {\n  variant?: DotVariant; // visual style of the vertex marker\n}\n\n/** Declares the resting vertex marker for the enclosing <Radar>. Renders nothing. */\nconst Dot: FC<DotProps> = () => null;\n\n/** Declares the hovered/active vertex marker for the enclosing <Radar>. Renders nothing. */\nconst ActiveDot: FC<DotProps> = () => null;\n\nexport interface PolarGridProps {\n  gridType?: GridType; // \"polygon\" (angular rings) or \"circle\" (circular rings)\n}\n\n/**\n * Presence draws the polar grid — the concentric rings and the radial spokes.\n * `gridType` picks the ring shape. Renders nothing.\n */\nconst PolarGrid: FC<PolarGridProps> = () => null;\n\nexport interface PolarAngleAxisProps {\n  dataKey?: string; // data key whose values label the perimeter — overrides auto-detect\n}\n\n/** Presence shows the angle-axis category labels around the perimeter. Renders nothing. */\nconst PolarAngleAxis: FC<PolarAngleAxisProps> = () => null;\n\n/** Presence shows the radial value scale running from the center outward. Renders nothing. */\nconst PolarRadiusAxis: FC = () => null;\n\nexport interface TooltipProps {\n  variant?: TooltipVariant; // visual style of the tooltip surface\n  roundness?: TooltipRoundness; // border-radius of the tooltip\n  position?: TooltipPosition; // \"variable\" follows the pointer (default); \"fixed\" pins the tooltip near the top and tracks the pointer's X\n  // Data index shown by default with no hover. On canvas the radar tooltip is\n  // item-triggered (per polygon), so this selects the DEFAULT SERIES to reveal\n  // — see the note in the sync effect.\n  defaultIndex?: number;\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 <Radar>'s own\n// children; a missing dot child means that marker does not render.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype RadarSeriesConfig = {\n  dataKey: string;\n  variant: RadarVariant;\n  fillOpacity: number;\n  isClickable: boolean;\n  dotVariant: DotVariant; // \"none\" when no <Dot> child is present\n  activeDotVariant: DotVariant; // \"none\" when no <ActiveDot> child is present\n};\n\ntype PolarGridSlot = { present: boolean; gridType: GridType };\ntype PolarAngleAxisSlot = { present: boolean; dataKey?: string };\ntype PolarRadiusAxisSlot = { present: boolean };\ntype TooltipSlot = {\n  present: boolean;\n  variant: TooltipVariant;\n  roundness: TooltipRoundness;\n  position: TooltipPosition;\n  defaultIndex?: number;\n};\ntype LegendSlot = {\n  present: boolean;\n  variant: LegendVariant;\n  align: \"left\" | \"center\" | \"right\";\n  verticalAlign: \"top\" | \"middle\" | \"bottom\";\n  isClickable: boolean;\n};\n\ntype CollectedConfig = {\n  radars: RadarSeriesConfig[];\n  grid: PolarGridSlot;\n  angleAxis: PolarAngleAxisSlot;\n  radiusAxis: PolarRadiusAxisSlot;\n  tooltip: TooltipSlot;\n  legend: LegendSlot;\n};\n\nfunction collectConfig(children: ReactNode): CollectedConfig {\n  const radars: RadarSeriesConfig[] = [];\n  let grid: PolarGridSlot = { present: false, gridType: \"polygon\" };\n  let angleAxis: PolarAngleAxisSlot = { present: false };\n  let radiusAxis: PolarRadiusAxisSlot = { present: false };\n  let tooltip: TooltipSlot = {\n    present: false,\n    variant: \"default\",\n    roundness: \"lg\",\n    position: \"variable\",\n  };\n  let legend: LegendSlot = {\n    present: false,\n    variant: \"rounded-square\",\n    align: \"center\",\n    verticalAlign: \"bottom\",\n    isClickable: false,\n  };\n\n  Children.forEach(children, (child) => {\n    if (!isValidElement(child)) return;\n    const type = child.type;\n\n    if (type === Radar) {\n      const props = child.props as RadarProps;\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      radars.push({\n        dataKey: props.dataKey,\n        variant: props.variant ?? \"filled\",\n        fillOpacity: props.fillOpacity ?? DEFAULT_FILL_OPACITY,\n        isClickable: props.isClickable ?? false,\n        dotVariant,\n        activeDotVariant,\n      });\n    } else if (type === PolarGrid) {\n      const props = child.props as PolarGridProps;\n      grid = { present: true, gridType: props.gridType ?? \"polygon\" };\n    } else if (type === PolarAngleAxis) {\n      const props = child.props as PolarAngleAxisProps;\n      angleAxis = { present: true, dataKey: props.dataKey };\n    } else if (type === PolarRadiusAxis) {\n      radiusAxis = { present: 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        position: props.position ?? \"variable\",\n        defaultIndex: props.defaultIndex,\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 ?? \"center\",\n        verticalAlign: props.verticalAlign ?? \"bottom\",\n        isClickable: props.isClickable ?? false,\n      };\n    }\n  });\n\n  return { radars, grid, angleAxis, radiusAxis, tooltip, legend };\n}\n\n// Color plumbing (ChartConfig, getColorsCount, distributeColors, buildChartCss,\n// normalizeColor, withAlpha, ResolvedColors, resolveColors) now lives in the shared\n// @/registry/ui/echarts-chart module, imported at the top of this file.\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Radar paints — the ECharts analogue of the twin's SVG gradients.\n// ─────────────────────────────────────────────────────────────────────────────\n\nconst GRAY = \"rgba(120, 120, 120, 1)\";\n\n// Diagonal multi-stop stroke, mirroring the twin's StrokeGradient (x1,y1 → x2,y2\n// = 0,0 → 1,1). A solid string when there is only one color. The gradient is\n// bbox-relative, so each polygon vertex takes the color at its position — the\n// canvas echo of the SVG stroke gradient clipping the polygon path.\nfunction radarStrokePaint(slots: string[]): string | echarts.graphic.LinearGradient {\n  if (slots.length <= 1) return slots[0] ?? GRAY;\n  const stops = slots.map((color, i) => ({ offset: i / (slots.length - 1), color }));\n  return new echarts.graphic.LinearGradient(0, 0, 1, 1, stops);\n}\n\n// Radial center→edge fill, mirroring the twin's FillGradient: first color at 0.8\n// alpha in the middle fading to 0.3 at the rim. bbox-relative (global: false), so\n// the gradient's center lands on the radar's center. The consumer's `fillOpacity`\n// multiplies this via areaStyle.opacity, exactly like the twin's fillOpacity prop.\nfunction radarFillPaint(slots: string[]): echarts.graphic.RadialGradient {\n  if (slots.length <= 1) {\n    const base = slots[0] ?? GRAY;\n    return new echarts.graphic.RadialGradient(0.5, 0.5, 0.5, [\n      { offset: 0, color: withAlpha(base, 0.8) },\n      { offset: 1, color: withAlpha(base, 0.3) },\n    ]);\n  }\n  return new echarts.graphic.RadialGradient(\n    0.5,\n    0.5,\n    0.5,\n    slots.map((color, i) => ({\n      offset: i / (slots.length - 1),\n      color: withAlpha(color, i === 0 ? 0.8 : 0.3),\n    })),\n  );\n}\n\n// Vertex dots (dotStyle/dotItemStyle/DOT_SIZES) and gradient sampling (sampleGradient)\n// now live in the shared @/registry/ui/echarts-dot module, imported at the top of this\n// file. A radar series is a SINGLE data item, so every vertex shares one itemStyle:\n// multi-color radars give all dots one representative color via sampleGradient(slots,\n// 0.5) rather than tinting each vertex individually.\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Selection opacity — the twin only dims a CLICKABLE radar that isn't selected;\n// a non-clickable radar keeps full opacity even when a sibling is selected.\n// Split into stroke/fill/dot factors (mirroring the area chart's getOpacity): the\n// base state keeps stroke and dots at full, and a dimmed radar's fill drops twice\n// as far as its stroke/dots so the receding outline still reads.\n// ─────────────────────────────────────────────────────────────────────────────\n\nfunction selectionOpacity(\n  selected: string | null,\n  key: string,\n  isClickable: boolean,\n): { fill: number; stroke: number; dot: number } {\n  const isSelected = selected === null || selected === key;\n  if (!isClickable || isSelected) return { fill: 1, stroke: 1, dot: 1 };\n  return { fill: 0.1, stroke: 0.2, dot: 0.2 };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Loading skeleton helpers\n// ─────────────────────────────────────────────────────────────────────────────\n\n// Skeleton polygon as a smooth random walk in a comfortable band — reads like a\n// resting radar instead of raw noise spikes. Values live in [0, LOADING_MAX].\nconst LOADING_MAX = 100;\nfunction getLoadingData(points: number): number[] {\n  const rows: number[] = [];\n  let value = 45 + Math.random() * 25;\n  for (let i = 0; i < points; i++) {\n    value = Math.min(90, Math.max(35, value + (Math.random() - 0.5) * 35));\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// ─────────────────────────────────────────────────────────────────────────────\n// Tooltip + legend HTML — the tooltip shell styling (roundnessClass,\n// tooltipVariantClass, tooltipRow, tooltipIndicatorHtml) and the legend overlay\n// (LegendOverlay + its indicators) now live in the shared\n// @/registry/ui/echarts/{tooltip,legend} modules, imported at the top of this file.\n// ─────────────────────────────────────────────────────────────────────────────\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Option builders — pure functions from a snapshot context to ECharts option\n// fragments. The component reads its refs and props ONCE per build into this\n// context; nothing below touches React state or the chart instance, so each\n// fragment can be reasoned about (and tested) in isolation.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype OptionBuildContext = {\n  data: Record<string, unknown>[];\n  config: ChartConfig;\n  radars: RadarSeriesConfig[];\n  seriesKeys: string[];\n  selectedDataKey: string | null;\n  hasSelection: boolean;\n  gridSlot: PolarGridSlot;\n  angleAxisSlot: PolarAngleAxisSlot;\n  radiusAxisSlot: PolarRadiusAxisSlot;\n  tooltipSlot: TooltipSlot;\n  legendSlot: LegendSlot;\n  isLoading: boolean;\n  loadingData: () => number[];\n  loadingPoints: number;\n  resolved: ResolvedColors;\n  categories: string[]; // angle-axis labels (indicator names)\n  indicatorMax: number; // shared radius-axis max across every spoke\n};\n\n// The radar's vertical placement. When a legend rides the bottom (the default)\n// the coordinate system floats up to leave room, mirroring the Recharts layout.\nfunction radarCenterY(legendSlot: LegendSlot): string {\n  if (!legendSlot.present) return \"50%\";\n  if (legendSlot.verticalAlign === \"bottom\") return \"46%\";\n  if (legendSlot.verticalAlign === \"top\") return \"54%\";\n  return \"50%\";\n}\n\n// The radar coordinate system — indicators (one per category), rings, spokes, and\n// the two label sets. Grid visibility is gated on <PolarGrid>, perimeter labels\n// on <PolarAngleAxis>, and the radial scale on <PolarRadiusAxis>, matching the\n// twin's presence semantics.\nfunction buildRadarComponent(ctx: OptionBuildContext): RadarOption {\n  const { gridSlot, angleAxisSlot, radiusAxisSlot, isLoading, categories, indicatorMax } = ctx;\n  const { tokens } = ctx.resolved;\n  const gridColor = withAlpha(tokens.border, GRID_LINE_OPACITY);\n\n  return {\n    center: [\"50%\", radarCenterY(ctx.legendSlot)],\n    radius: \"68%\",\n    // Recharts starts the first category at the top and reads clockwise; startAngle\n    // 90 places the first indicator at the top to match.\n    startAngle: 90,\n    shape: gridSlot.gridType,\n    splitNumber: 4,\n    indicator: categories.map((name) => ({ name, max: indicatorMax })),\n    // Perimeter category labels — the twin's <PolarAngleAxis>.\n    axisName: {\n      show: angleAxisSlot.present && !isLoading,\n      color: tokens.mutedForeground,\n      fontSize: 10,\n    },\n    // Radial spokes from the center — part of the twin's <PolarGrid>.\n    axisLine: {\n      show: gridSlot.present && !isLoading,\n      lineStyle: { color: gridColor },\n    },\n    axisTick: { show: false },\n    // Concentric rings — the other half of <PolarGrid>. Dashed [3, 4] mirrors the\n    // twin's strokeDasharray. Hidden while loading so the skeleton floats clean.\n    splitLine: {\n      show: gridSlot.present && !isLoading,\n      lineStyle: { color: gridColor, type: [3, 4] as [number, number] },\n    },\n    splitArea: { show: false },\n    // Radial value scale — the twin's <PolarRadiusAxis>. Hidden while loading.\n    axisLabel: {\n      show: radiusAxisSlot.present && !isLoading,\n      color: tokens.mutedForeground,\n      fontSize: 10,\n      showMinLabel: false, // the 0 at the dead center reads as clutter\n    },\n  };\n}\n\n// Tooltip HTML builder, closed over the build context. Radar tooltips are\n// item-triggered (one polygon at a time), so — unlike the axis-triggered area\n// chart — the hovered series is the header and its per-category values are the\n// rows. Accepted deviation from the Recharts twin, which anchors on a category.\nfunction createTooltipFormatter(ctx: OptionBuildContext) {\n  const { config, selectedDataKey, tooltipSlot, categories } = ctx;\n\n  return (params: unknown): string => {\n    const param = (Array.isArray(params) ? params[0] : params) as {\n      seriesId?: string;\n      seriesName?: string;\n      value?: number[];\n    } | null;\n    if (!param) return \"\";\n\n    const key = param.seriesId ?? \"\";\n    // The loading skeleton never surfaces in the tooltip.\n    if (key.startsWith(\"__\")) return \"\";\n\n    const item = config[key];\n    const colorsCount = item ? getColorsCount(item) : 1;\n    const labelText = typeof item?.label === \"string\" ? item.label : (param.seriesName ?? key);\n    const values = Array.isArray(param.value) ? param.value : [];\n    const dimmed = selectedDataKey != null && selectedDataKey !== key ? \" opacity-30\" : \"\";\n\n    // One row per angle-axis category. Radar rows share the area chart's\n    // indicator + label + value shape, so they reuse the shared tooltipRow +\n    // tooltipIndicatorHtml. dimmed is \"\" per row because a radar tooltip is a\n    // single series — the dim is applied to the whole shell below instead.\n    const body = categories\n      .map((category, i) => {\n        const raw = values[i];\n        const value = typeof raw === \"number\" ? raw.toLocaleString() : String(raw ?? \"\");\n        return tooltipRow({\n          indicatorHtml: tooltipIndicatorHtml(key, colorsCount),\n          labelText: category,\n          valueText: value,\n          dimmed: \"\",\n        });\n      })\n      .join(\"\");\n\n    // Custom shell (not the shared tooltipShell): a radar tooltip dims the WHOLE\n    // surface for a non-selected series and keeps a foreground-colored header (no\n    // text-primary) — both byte-identical to the pre-refactor markup.\n    return `<div class=\"grid min-w-32 items-start gap-1.5 border border-border/50 px-2.5 py-1.5 text-xs shadow-xl${dimmed} ${roundnessClass[tooltipSlot.roundness]} ${tooltipVariantClass[tooltipSlot.variant]}\">\n      <div class=\"font-medium\">${labelText}</div>\n      <div class=\"grid gap-1.5\">${body}</div>\n    </div>`;\n  };\n}\n\nfunction buildTooltipOption(ctx: OptionBuildContext): TooltipComponentOption {\n  const { tooltipSlot, isLoading } = ctx;\n\n  return {\n    show: tooltipSlot.present && !isLoading,\n    // Radar tooltips are item-triggered (per polygon), so this chart does NOT use\n    // the shared tooltipBaseOption (which builds an axis-triggered tooltip with an\n    // axis pointer). It shares only resolveTooltipPosition to wire the position\n    // prop: \"variable\" → default follow behavior, \"fixed\" → pinned near the top.\n    trigger: \"item\",\n    confine: true,\n    backgroundColor: \"transparent\",\n    borderWidth: 0,\n    padding: 0,\n    extraCssText: \"box-shadow:none;\",\n    displayTransition: false,\n    position: resolveTooltipPosition(tooltipSlot.position),\n    formatter: createTooltipFormatter(ctx),\n  };\n}\n\n// One radar series per <Radar> — each carries a single polygon (one data item)\n// over every category, so all of its styling lives at the series level.\nfunction buildRadarSeries(ctx: OptionBuildContext): RadarSeriesOption[] {\n  const { data, config, radars, selectedDataKey, hasSelection, categories, resolved } = ctx;\n\n  return radars.map((radar) => {\n    const key = radar.dataKey;\n    const slots = resolved.series[key] ?? [GRAY];\n    const strokePaint = radarStrokePaint(slots);\n    // A radar series is one data item — every vertex shares one dot color (§sampleGradient).\n    const dotColor = sampleGradient(slots, 0.5);\n    const isSelected = selectedDataKey === null || selectedDataKey === key;\n    const opacity = selectionOpacity(selectedDataKey, key, radar.isClickable);\n    const isFilled = radar.variant === \"filled\";\n\n    const restingVisible = radar.dotVariant !== \"none\";\n    const activeVisible = radar.activeDotVariant !== \"none\";\n    const restingDot = dotStyle(radar.dotVariant, dotColor, resolved.tokens.background);\n    // The active marker falls back to \"default\" when only a resting dot is declared,\n    // so hover always has a marker to promote to.\n    const activeDot = dotStyle(\n      radar.activeDotVariant === \"none\" ? \"default\" : radar.activeDotVariant,\n      dotColor,\n      resolved.tokens.background,\n    );\n\n    const value = categories.map((_, i) => Number(data[i]?.[key]) || 0);\n\n    // ECharts radar has no per-state symbolSize and no numeric emphasis scale, so\n    // the hover marker swaps STYLE (e.g. colored-border → solid) at the resting\n    // size rather than growing — the twin's r3→r3 markers already match sizes.\n    const symbol = restingVisible || activeVisible ? \"circle\" : \"none\";\n\n    return {\n      id: key,\n      name: typeof config[key]?.label === \"string\" ? (config[key]?.label as string) : key,\n      type: \"radar\",\n      radarIndex: 0,\n      data: [{ value }],\n      symbol,\n      symbolSize: restingVisible ? restingDot.size : activeDot.size,\n      cursor: radar.isClickable ? \"pointer\" : \"default\",\n      // The selected radar rides on top; while a selection is active the rest sink.\n      z: isSelected ? 3 : hasSelection ? 1 : 2,\n      lineStyle: { color: strokePaint, width: STROKE_WIDTH, opacity: opacity.stroke },\n      areaStyle: isFilled\n        ? { color: radarFillPaint(slots), opacity: radar.fillOpacity * opacity.fill }\n        : undefined,\n      // Resting dots invisible when only an <ActiveDot> is declared.\n      itemStyle: restingVisible\n        ? { ...restingDot.itemStyle, opacity: opacity.dot }\n        : { ...activeDot.itemStyle, opacity: 0 },\n      // While a click selection is active it owns the canvas: native hover\n      // emphasis (the dot promotion) forces a dimmed radar's dot back to full\n      // opacity, fighting the selection dim — so hover highlighting stops entirely\n      // until the selection clears. The option rebuilds on every selection change,\n      // so this is a build-time switch.\n      emphasis: hasSelection\n        ? { disabled: true }\n        : {\n            // Promote the resting marker to the active variant on hover; keep the\n            // line and fill exactly as they rest so only the dot changes (twin parity).\n            itemStyle: { ...activeDot.itemStyle, opacity: 1 },\n            lineStyle: { color: strokePaint, width: STROKE_WIDTH, opacity: opacity.stroke },\n            ...(isFilled\n              ? {\n                  areaStyle: {\n                    color: radarFillPaint(slots),\n                    opacity: radar.fillOpacity * opacity.fill,\n                  },\n                }\n              : {}),\n          },\n    };\n  });\n}\n\n// Loading skeleton — ONE gray polygon regardless of declared radars (Recharts\n// parity: its skeleton is a single LoadingRadar), swept by the shimmer rAF. The\n// radar coordinate system exists but every visual axis element is hidden, so the\n// polygon floats on a clean canvas exactly like the twin (all its axis parts\n// return null while loading).\nfunction buildLoadingOption(ctx: OptionBuildContext): EChartsOption {\n  const { tokens } = ctx.resolved;\n  const points = ctx.loadingPoints;\n\n  return {\n    animation: false,\n    radar: {\n      center: [\"50%\", radarCenterY(ctx.legendSlot)],\n      radius: \"68%\",\n      startAngle: 90,\n      shape: ctx.gridSlot.gridType,\n      splitNumber: 4,\n      indicator: Array.from({ length: points }, (_, i) => ({ name: `${i}`, max: LOADING_MAX })),\n      axisName: { show: false },\n      axisLine: { show: false },\n      axisTick: { show: false },\n      splitLine: { show: false },\n      splitArea: { show: false },\n      axisLabel: { show: false },\n    },\n    tooltip: { show: false },\n    series: [\n      {\n        id: \"__loading\",\n        type: \"radar\",\n        radarIndex: 0,\n        silent: true,\n        symbol: \"none\",\n        data: [{ value: ctx.loadingData() }],\n        // Invisible until the first shimmer tick positions the clip window.\n        lineStyle: { color: withAlpha(tokens.foreground, 0), width: 2 },\n        areaStyle: { color: withAlpha(tokens.foreground, 0) },\n        z: 1,\n      },\n    ],\n  };\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  hasRevealed: boolean; // the intro draw-in already played on this chart instance\n  revealRaf: number; // rAF handle of the data-driven entrance ramp (0 when idle)\n  loadingRows: number[] | null; // skeleton data, lazily rolled and re-rolled per shimmer sweep\n  categories: string[]; // angle-axis labels of the last build, for the tooltip\n  // Latest callbacks/flags for the imperative ECharts click handler.\n  handlers: {\n    onSelectionChange?: (key: string | null) => void;\n    clickableKeys: Set<string>;\n    selectedDataKey: string | null;\n    seriesKeys: string[];\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 radar 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, and intro reveal; every visual part\n * — `<Radar>`, `<PolarGrid>`, `<PolarAngleAxis>`, `<PolarRadiusAxis>`,\n * `<Tooltip>`, `<Legend>` — is composed as a declarative child that renders\n * nothing. The root walks those children by reference and drives a single\n * imperative ECharts instance. Fully self-contained: its only dependencies are\n * `react`, `echarts`, and `motion`.\n */\nexport function EChartsRadarChart<TData extends Record<string, unknown>>({\n  data,\n  config,\n  className,\n  animation = true,\n  defaultSelectedDataKey = null,\n  onSelectionChange,\n  isLoading = false,\n  loadingPoints = LOADING_DEFAULT_POINTS,\n  chartOptions,\n  children,\n}: EChartsRadarChartProps<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    hasRevealed: false,\n    revealRaf: 0,\n    loadingRows: null,\n    categories: [],\n    handlers: {\n      onSelectionChange,\n      clickableKeys: new Set<string>(),\n      selectedDataKey: defaultSelectedDataKey,\n      seriesKeys: [],\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  const [selectedDataKey, setSelectedDataKey] = useState<string | null>(defaultSelectedDataKey);\n\n  // ── Declarative config, collected from children by reference ─────────────────\n  const collected = useMemo(() => collectConfig(children), [children]);\n  const {\n    radars,\n    grid: gridSlot,\n    angleAxis: angleAxisSlot,\n    radiusAxis: radiusAxisSlot,\n    tooltip: tooltipSlot,\n    legend: legendSlot,\n  } = collected;\n\n  const seriesKeys = useMemo(() => radars.map((radar) => radar.dataKey), [radars]);\n\n  // angle category key: <PolarAngleAxis dataKey> → first data column no <Radar> claims.\n  const angleKey = useMemo(() => {\n    if (angleAxisSlot.dataKey) return angleAxisSlot.dataKey;\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  }, [angleAxisSlot.dataKey, data, seriesKeys]);\n\n  const css = useMemo(() => buildChartCss(chartId, config), [chartId, config]);\n\n  const hasSelection = selectedDataKey !== null;\n\n  // Which series may be clicked to toggle selection (consulted by the click handler).\n  const clickableKeys = useMemo(\n    () => new Set(radars.filter((radar) => radar.isClickable).map((radar) => radar.dataKey)),\n    [radars],\n  );\n\n  // Refresh the handlers' snapshot of the latest callbacks/flags every render.\n  live.handlers = {\n    onSelectionChange,\n    clickableKeys,\n    selectedDataKey,\n    seriesKeys,\n  };\n\n  const toggleSelection = useCallback(\n    (key: string) => {\n      setSelectedDataKey((prev) => {\n        const next = prev === key ? null : key;\n        onSelectionChange?.(next);\n        return next;\n      });\n    },\n    [onSelectionChange],\n  );\n\n  // ── Option builder ─────────────────────────────────────────────────────────\n  // Thin orchestrator over the pure builders above: snapshot the imperative\n  // surface (refs) 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[angleKey]));\n    live.categories = categories;\n\n    // Every spoke shares one radius scale, so the largest value across all series\n    // touches the outer ring — the recharts default domain.\n    let indicatorMax = 0;\n    for (const key of seriesKeys) {\n      for (const row of data) indicatorMax = Math.max(indicatorMax, Number(row[key]) || 0);\n    }\n    indicatorMax = indicatorMax || 1;\n\n    const ctx: OptionBuildContext = {\n      data,\n      config,\n      radars,\n      seriesKeys,\n      selectedDataKey,\n      hasSelection,\n      gridSlot,\n      angleAxisSlot,\n      radiusAxisSlot,\n      tooltipSlot,\n      legendSlot,\n      isLoading,\n      loadingData,\n      loadingPoints,\n      resolved,\n      categories,\n      indicatorMax,\n    };\n\n    if (isLoading) return buildLoadingOption(ctx);\n\n    return {\n      animation: false,\n      radar: buildRadarComponent(ctx),\n      tooltip: buildTooltipOption(ctx),\n      // The radar polygons — their seriesIndex feeds the click handler.\n      series: buildRadarSeries(ctx),\n    };\n  }, [\n    live,\n    data,\n    config,\n    radars,\n    seriesKeys,\n    angleKey,\n    selectedDataKey,\n    hasSelection,\n    gridSlot,\n    angleAxisSlot,\n    radiusAxisSlot,\n    tooltipSlot,\n    legendSlot,\n    isLoading,\n    loadingData,\n    loadingPoints,\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 radar's\n      // reveal — only react when the renderer size actually changed.\n      if (mount.clientWidth === chart.getWidth() && mount.clientHeight === chart.getHeight()) {\n        return;\n      }\n      chart.resize();\n      live.repush();\n    });\n    resizeObserver.observe(mount);\n\n    // Light/dark flips change no React state — re-resolve and push directly.\n    const themeObserver = new MutationObserver(() => {\n      live.repush();\n    });\n    themeObserver.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    chart.on(\"click\", (params) => {\n      const { clickableKeys: clickable, seriesKeys: keys } = live.handlers;\n      const p = params as { seriesId?: string; seriesIndex?: number };\n      // Symbol clicks carry seriesId; polygon clicks may only carry seriesIndex —\n      // recover the key by position. Main radar series come first in the series\n      // array (the loading skeleton is silent), so the index maps directly.\n      const id =\n        p.seriesId ?? (typeof p.seriesIndex === \"number\" ? keys[p.seriesIndex] : undefined);\n      if (typeof id === \"string\" && clickable.has(id)) toggleSelection(id);\n    });\n\n    return () => {\n      resizeObserver.disconnect();\n      themeObserver.disconnect();\n      if (live.revealRaf) {\n        cancelAnimationFrame(live.revealRaf);\n        live.revealRaf = 0;\n      }\n      chart.dispose();\n      echartsRef.current = 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    // A radar series carries one data item: `{ value: number[] }`. The entrance\n    // ramp reads and rewrites just those magnitudes each frame.\n    type RevealSeries = { id?: string; data?: { value?: number[] }[] };\n\n    const cancelReveal = () => {\n      if (live.revealRaf) {\n        cancelAnimationFrame(live.revealRaf);\n        live.revealRaf = 0;\n      }\n    };\n\n    const buildMerged = (): EChartsOption => {\n      const option = buildOption();\n      const merged = chartOptions ? { ...option, ...chartOptions } : option;\n      // Native animation stays off — the entrance is data-driven (see below), and\n      // notMerge would otherwise replay a native entrance on every repush.\n      Object.assign(merged, { animation: false, animationDurationUpdate: 0 });\n      // chartOptions is an untyped escape hatch — the spread erases the option's\n      // shape, so re-assert it.\n      return merged as EChartsOption;\n    };\n\n    // Settled push — full-magnitude data, no entrance. Every non-first render\n    // (selection, theme, resize) and the animation-off path lands here.\n    const pushStatic = () => {\n      cancelReveal();\n      chart.setOption(buildMerged(), { notMerge: true });\n    };\n\n    // Entrance push — collapse every radar series to the center, then ramp the\n    // values back out to full magnitude. Because each\n    // vertex symbol tracks its datum, the dots ride the polygon out from the center\n    // and stay glued to the shape at every frame — the fix for ECharts snapping\n    // radar symbols straight to their final positions during its native entrance.\n    const pushReveal = () => {\n      cancelReveal();\n      const merged = buildMerged();\n      const series = (merged.series as unknown as RevealSeries[] | undefined) ?? [];\n      const finals = series.map((s) => s.data?.[0]?.value ?? []);\n\n      // First paint fully collapsed — no flash of the final shape before the ramp.\n      chart.setOption(\n        {\n          ...merged,\n          series: series.map((s, i) => ({ ...s, data: [{ value: finals[i].map(() => 0) }] })),\n        } as EChartsOption,\n        { notMerge: true },\n      );\n\n      const start = performance.now();\n      const frame = (now: number) => {\n        const t = Math.min((now - start) / REVEAL_DURATION, 1);\n        const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic — decelerate into place\n        chart.setOption(\n          {\n            series: series.map((s, i) => ({\n              id: s.id,\n              data: [{ value: finals[i].map((v) => v * eased) }],\n            })),\n          },\n          { silent: true, lazyUpdate: true },\n        );\n        live.revealRaf = t < 1 ? requestAnimationFrame(frame) : 0;\n      };\n      live.revealRaf = requestAnimationFrame(frame);\n    };\n\n    // Intro reveal — plays only on the first real render. A loading cycle re-arms\n    // it: the Recharts twin remounts its <Radar>s while loading and replays the\n    // intro 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 = animation && shouldReveal && !shouldReduceMotion;\n    if (revealEnabled) pushReveal();\n    else pushStatic();\n\n    // Default tooltip. On canvas the radar tooltip is item-triggered per polygon,\n    // so the twin's `defaultIndex` (a category index) is remapped to the DEFAULT\n    // SERIES whose tooltip shows on load. Only fired on the reveal push, never on\n    // theme/resize repushes (that would re-pop the tooltip on every flip).\n    if (\n      !isLoading &&\n      tooltipSlot.present &&\n      tooltipSlot.defaultIndex != null &&\n      seriesKeys.length\n    ) {\n      const idx = Math.min(Math.max(tooltipSlot.defaultIndex, 0), seriesKeys.length - 1);\n      chart.dispatchAction({ type: \"showTip\", seriesIndex: idx, dataIndex: 0 });\n    }\n\n    // Theme flips and resizes re-enter here without touching React: re-read the\n    // tokens (the .dark class changed) and push a settled option, cancelling any\n    // in-flight entrance ramp.\n    live.repush = () => {\n      live.resolved = resolveColors(container, config, seriesKeys);\n      pushStatic();\n    };\n  }, [\n    live,\n    buildOption,\n    chartOptions,\n    isLoading,\n    animation,\n    shouldReduceMotion,\n    config,\n    seriesKeys,\n    tooltipSlot.present,\n    tooltipSlot.defaultIndex,\n  ]);\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 ?? GRAY;\n      const w = chart.getWidth();\n      const h = chart.getHeight();\n      if (!w || !h) {\n        raf = requestAnimationFrame(tick);\n        return;\n      }\n      // Sweep the clip window from fully off-screen left to fully off-screen\n      // right, leaned 45°. The gradient uses ABSOLUTE pixel coordinates shared by\n      // stroke and fill so both reveal the same slice of the polygon at once.\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: [{ value: loadingData() }],\n              lineStyle: { color: clip(LOADING_STROKE_OPACITY), width: 2 },\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: 12 }\n        : { top: \"50%\", transform: \"translateY(-50%)\" }),\n  };\n\n  return (\n    <div\n      ref={containerRef}\n      data-chart={chartId}\n      className={`relative flex flex-col text-xs ${className ?? \"\"}`}\n    >\n      <style dangerouslySetInnerHTML={{ __html: css }} />\n\n      <div className=\"relative min-h-0 w-full flex-1\">\n        <div ref={mountRef} className=\"h-full min-h-0 w-full\" />\n      </div>\n\n      {legendSlot.present && !isLoading && (\n        <LegendOverlay\n          seriesKeys={seriesKeys}\n          config={config}\n          variant={legendSlot.variant}\n          align={legendSlot.align}\n          verticalAlign={legendSlot.verticalAlign}\n          selectedKey={selectedDataKey}\n          hoveredKey={null}\n          isClickable={legendSlot.isClickable}\n          onToggle={toggleSelection}\n          style={legendStyle}\n        />\n      )}\n\n      {isLoading && (\n        <div className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center\">\n          <motion.div\n            initial={shouldReduceMotion ? false : { opacity: 0, scale: 0.92 }}\n            animate={{ opacity: 1, scale: 1 }}\n            transition={{ duration: 0.25, ease: \"easeOut\" }}\n            className=\"text-primary bg-background flex items-center justify-center gap-2 rounded-md border px-2 py-0.5 text-sm\"\n          >\n            <div className=\"border-border border-t-primary h-3 w-3 animate-spin rounded-full border\" />\n            <span>Loading</span>\n          </motion.div>\n        </div>\n      )}\n    </div>\n  );\n}\n\n// Compound API: every part hangs off the root as a static member, so a consumer\n// writes <EChartsRadarChart.Radar/>, <EChartsRadarChart.Tooltip/>, … from a single\n// import — no colliding named marker exports when several charts share one file.\nEChartsRadarChart.Radar = Radar;\nEChartsRadarChart.Dot = Dot;\nEChartsRadarChart.ActiveDot = ActiveDot;\nEChartsRadarChart.PolarGrid = PolarGrid;\nEChartsRadarChart.PolarAngleAxis = PolarAngleAxis;\nEChartsRadarChart.PolarRadiusAxis = PolarRadiusAxis;\nEChartsRadarChart.Tooltip = Tooltip;\nEChartsRadarChart.Legend = Legend;\n",
      "type": "registry:component",
      "target": "components/evilcharts/charts/echarts-radar-chart.tsx"
    }
  ],
  "type": "registry:component"
}