{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "echarts-heatmap-chart",
  "description": "Calendar heatmap component rendered with Apache ECharts — GitHub contribution graph style",
  "dependencies": [
    "echarts",
    "motion"
  ],
  "registryDependencies": [
    "@evilcharts/echarts-chart",
    "@evilcharts/echarts-tooltip"
  ],
  "files": [
    {
      "path": "src/registry/charts/echarts-heatmap-chart.tsx",
      "content": "\"use client\";\n\nimport {\n  DEFAULT_ECHARTS_RENDERER,\n  buildChartCss,\n  getColorsCount,\n  resolveColors,\n  withAlpha,\n  type ChartConfig,\n  type EChartsRenderer,\n  type ResolvedColors,\n} from \"@/registry/ui/echarts-chart\";\nimport {\n  resolveTooltipPosition,\n  tooltipRow,\n  tooltipShell,\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 FC,\n  type ReactNode,\n} from \"react\";\nimport {\n  CalendarComponent,\n  TooltipComponent,\n  type CalendarComponentOption,\n  type TooltipComponentOption,\n} from \"echarts/components\";\nimport { CustomChart, type CustomSeriesOption } from \"echarts/charts\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport type { ComposeOption } from \"echarts/core\";\nimport * as echarts from \"echarts/core\";\n\n// Re-export the shared types so consumers/examples import everything they need\n// from the chart module, like every other EvilCharts chart.\nexport type { ChartConfig, EChartsRenderer, TooltipPosition, TooltipRoundness, TooltipVariant };\n\n// Modular registration keeps the bundle lean — only the pieces this chart needs.\n// The calendar coordinate system does the date → cell math (week columns, first\n// day of week, month/day labels); the cells themselves are drawn by a custom\n// series so they can carry a gap, rounded corners, and a per-cell color from\n// the config ramp — none of which the built-in heatmap series can do on its\n// own. The tooltip is the one extra component.\necharts.use([CustomChart, CalendarComponent, TooltipComponent]);\n\ntype EChartsInstance = ReturnType<typeof echarts.init>;\n\n// The exact option surface this chart uses. Narrower than echarts' full\n// EChartsOption, so a misspelled key fails the compile instead of silently\n// reaching setOption.\ntype EChartsOption = ComposeOption<\n  CustomSeriesOption | CalendarComponentOption | TooltipComponentOption\n>;\n\n// The modular entry points don't export the renderItem types directly — derive\n// them from the series option so the cell painter stays fully type-checked.\ntype RenderItem = NonNullable<CustomSeriesOption[\"renderItem\"]>;\ntype RenderItemParams = Parameters<RenderItem>[0];\ntype RenderItemApi = Parameters<RenderItem>[1];\ntype RenderItemReturn = ReturnType<RenderItem>;\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Constants\n// ─────────────────────────────────────────────────────────────────────────────\n\nconst DAY_MS = 86_400_000;\nconst DAYS_PER_WEEK = 7;\nconst TRAILING_YEAR_DAYS = 365; // default range — the trailing year, GitHub style\n\n// Intro reveal — cells pop in column by column, left to right, so the eye\n// reads the year in order. Times are in milliseconds.\nconst INTRO_COLUMN_STAGGER = 14; // delay between one week column and the next\nconst INTRO_CELL_GROW = 320; // a single cell scaling up + fading in\nconst INTRO_CELL_SCALE_FROM = 0.4; // a cell opens from this fraction of its size\nconst UPDATE_DURATION = 180; // selection/theme/resize transitions\nconst LOADING_ANIMATION_DURATION = 2000; // shimmer loop, in milliseconds\n\nconst DEFAULT_CELL_RADIUS = 2;\nconst DEFAULT_CELL_GAP = 3;\nconst MIN_CELL_SIZE = 4; // never fit cells smaller than this\nconst FALLBACK_CELL_SIZE = 12; // used before the container has a measured size\n\n// Space reserved around the calendar rect for its labels, in pixels.\nconst DAY_LABEL_WIDTH = 32;\nconst MONTH_LABEL_HEIGHT = 22;\nconst LABEL_MARGIN = 8;\nconst EDGE_PADDING = 2; // keeps the outermost cells' hover ring inside the canvas\n\nconst GRAY = \"rgba(120, 120, 120, 1)\"; // fallback when the ramp has no resolved color\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Theme knobs — every opacity in the chart draws from these. Base colors come\n// from the consumer's CSS tokens (resolved from the live DOM), so only the\n// opacity factors live here. `withAlpha` MULTIPLIES a token's own alpha.\n// ─────────────────────────────────────────────────────────────────────────────\nconst EMPTY_CELL_OPACITY = 0.08; // a day with no value — foreground at a whisper (GitHub's #ebedf0 / #161b22)\nconst CELL_DIM_OPACITY = 0.55; // cells outside the current selection — soft, so the ramp still reads\nconst HOVER_RING_WIDTH = 1; // outline drawn around the hovered cell, in pixels\nconst HOVER_RING_OPACITY = 0.6; // × foreground alpha\nconst SELECTED_RING_WIDTH = 1.5; // outline drawn around the selected cell, in pixels\nconst SELECTED_RING_OPACITY = 0.9; // × foreground alpha\n\n// The loading skeleton is the same grid, painted in foreground gray and swept by\n// a shimmer band. A low BASE floor keeps the grid legible between sweeps.\nconst LOADING_CELL_FLOOR = 0.07; // cell fill outside the sweep, × foreground alpha\nconst LOADING_CELL_PEAK = 0.22; // cell fill inside the sweep, × foreground alpha\nconst LOADING_SHIMMER_BAND = 0.22; // sweep half-width, fraction of chart width\nconst LOADING_SHIMMER_FEATHER = 0.22; // eased edge softening of the sweep\n\nconst DEFAULT_DAY_LABELS = [\"\", \"Mon\", \"\", \"Wed\", \"\", \"Fri\", \"\"]; // Sunday first, like GitHub\nconst DEFAULT_MONTH_LABELS = [\n  \"Jan\",\n  \"Feb\",\n  \"Mar\",\n  \"Apr\",\n  \"May\",\n  \"Jun\",\n  \"Jul\",\n  \"Aug\",\n  \"Sep\",\n  \"Oct\",\n  \"Nov\",\n  \"Dec\",\n];\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Public types\n// ─────────────────────────────────────────────────────────────────────────────\n\n// One day of data. `date` is an ISO calendar date (`YYYY-MM-DD`); days absent\n// from `data` render as empty cells.\nexport type HeatmapDatum = {\n  date: string;\n  value: number;\n};\n\n// The calendar window: a year (`2025`), a month (`\"2025-03\"`), or an inclusive\n// `[start, end]` pair of ISO dates. Omit it for the trailing year that ends on\n// the latest date in `data`.\nexport type HeatmapRange = number | string | [string, string];\n\nexport type WeekStart = \"sunday\" | \"monday\";\nexport type HeatmapScale = \"quantile\" | \"linear\";\n// A heatmap's entrance sweeps the grid left to right: \"default\" plays it,\n// \"none\" turns it off. Kept as a small union for parity with the other\n// EvilCharts entrance switches.\nexport type HeatmapAnimationType = \"none\" | \"default\";\n\nexport type HeatmapSelection = { date: string; value: number };\n\nexport interface EChartsHeatmapChartProps {\n  data: HeatmapDatum[]; // one entry per day that has a value\n  config: ChartConfig; // the series key's label + color ramp (one color per intensity level)\n  children: ReactNode; // composed parts — <Cell>, <MonthLabel>, <DayLabel>, <Tooltip>, <Legend>\n  className?: string; // extra classes for the chart container\n  renderer?: EChartsRenderer; // rendering engine — canvas by default, or SVG\n  range?: HeatmapRange; // calendar window; defaults to the trailing year\n  weekStart?: WeekStart; // which day starts each column\n  cellSize?: number; // fixed cell size in pixels; omit to fit the container width\n  scale?: HeatmapScale; // how values map onto the color ramp when no thresholds are given\n  thresholds?: number[]; // explicit ascending level boundaries; values below the first are level 1\n  defaultSelectedDate?: string | null; // cell selected on first render\n  onSelectionChange?: (selection: HeatmapSelection | null) => void; // fires when the selected cell changes\n  isLoading?: boolean; // shows the animated loading skeleton\n  animation?: boolean; // master switch for the intro sweep — false renders instantly\n  animationType?: HeatmapAnimationType; // \"none\" disables the intro sweep\n  chartOptions?: Record<string, unknown>; // escape hatch merged over the built ECharts option\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Composible parts — DECLARATIVE CONFIG. Every part renders `null`; the root\n// walks `children` by reference (child.type === Cell, …) to collect its props.\n// The cells are intrinsic to the data, so <Cell> always renders — it only\n// CONFIGURES the grid and names the config key to color it with. The labels,\n// tooltip, and legend follow presence semantics: omit one and it does not render.\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport interface CellProps {\n  dataKey: string; // config key whose colors form the intensity ramp\n  radius?: number; // corner radius of each cell in pixels\n  gap?: number; // space between cells in pixels\n  isClickable?: boolean; // lets cells be selected by clicking them\n}\n\n/**\n * Configures the heatmap cells. A configuration slot — the root reads its props\n * and wires them into the ECharts custom series, so it renders nothing itself.\n * `dataKey` picks the config entry whose color array becomes the intensity ramp:\n * the first color is the lightest level, the last the strongest.\n */\nconst Cell: FC<CellProps> = () => null;\n\nexport interface MonthLabelProps {\n  labels?: string[]; // twelve month names, January first\n}\n\n/** Presence shows month names above the grid. Renders nothing. */\nconst MonthLabel: FC<MonthLabelProps> = () => null;\n\nexport interface DayLabelProps {\n  labels?: string[]; // seven day names, Sunday first; an empty string hides that row's label\n}\n\n/** Presence shows weekday names beside the grid. Renders nothing. */\nconst DayLabel: FC<DayLabelProps> = () => 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  dateFormatter?: (date: string) => string; // formats the hovered day for the tooltip title\n  valueFormatter?: (value: number) => string; // formats the hovered day's value\n}\n\n/** Presence enables the hover tooltip. Renders nothing. */\nconst Tooltip: FC<TooltipProps> = () => null;\n\nexport interface LegendProps {\n  align?: \"left\" | \"right\"; // which side of the chart the ramp sits on\n  lessLabel?: string; // text before the ramp\n  moreLabel?: string; // text after the ramp\n}\n\n/** Presence renders the \"Less → More\" ramp under the grid. Renders nothing itself. */\nconst Legend: FC<LegendProps> = () => null;\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Children collection — walk the declarative config into plain objects the\n// option builder consumes.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype CellSlot = {\n  dataKey: string;\n  radius: number;\n  gap: number;\n  isClickable: boolean;\n};\ntype MonthLabelSlot = { labels: string[] };\ntype DayLabelSlot = { labels: string[] };\ntype TooltipSlot = {\n  present: boolean;\n  variant: TooltipVariant;\n  roundness: TooltipRoundness;\n  position: TooltipPosition;\n  dateFormatter: (date: string) => string;\n  valueFormatter: (value: number) => string;\n};\ntype LegendSlot = {\n  align: \"left\" | \"right\";\n  lessLabel: string;\n  moreLabel: string;\n};\n\ntype CollectedConfig = {\n  cell: CellSlot;\n  monthLabel: MonthLabelSlot | null;\n  dayLabel: DayLabelSlot | null;\n  tooltip: TooltipSlot;\n  legend: LegendSlot | null;\n};\n\n// \"Mar 3, 2026\" — parsed as a calendar date, not a local instant, so the label\n// never slips a day across time zones.\nfunction defaultDateFormatter(date: string): string {\n  const ms = parseIsoDate(date);\n  if (Number.isNaN(ms)) return date;\n  return new Date(ms).toLocaleDateString(\"en-US\", {\n    month: \"short\",\n    day: \"numeric\",\n    year: \"numeric\",\n    timeZone: \"UTC\",\n  });\n}\n\nfunction collectConfig(children: ReactNode): CollectedConfig {\n  let cell: CellSlot = {\n    dataKey: \"\",\n    radius: DEFAULT_CELL_RADIUS,\n    gap: DEFAULT_CELL_GAP,\n    isClickable: false,\n  };\n  let monthLabel: MonthLabelSlot | null = null;\n  let dayLabel: DayLabelSlot | null = null;\n  let tooltip: TooltipSlot = {\n    present: false,\n    variant: \"default\",\n    roundness: \"lg\",\n    position: \"variable\",\n    dateFormatter: defaultDateFormatter,\n    valueFormatter: (value) => value.toLocaleString(),\n  };\n  let legend: LegendSlot | null = null;\n\n  Children.forEach(children, (child) => {\n    if (!isValidElement(child)) return;\n    const type = child.type;\n\n    if (type === Cell) {\n      const props = child.props as CellProps;\n      cell = {\n        dataKey: props.dataKey,\n        radius: props.radius ?? DEFAULT_CELL_RADIUS,\n        gap: props.gap ?? DEFAULT_CELL_GAP,\n        isClickable: props.isClickable ?? false,\n      };\n    } else if (type === MonthLabel) {\n      const props = child.props as MonthLabelProps;\n      monthLabel = { labels: props.labels ?? DEFAULT_MONTH_LABELS };\n    } else if (type === DayLabel) {\n      const props = child.props as DayLabelProps;\n      dayLabel = { labels: props.labels ?? DEFAULT_DAY_LABELS };\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        dateFormatter: props.dateFormatter ?? defaultDateFormatter,\n        valueFormatter: props.valueFormatter ?? ((value) => value.toLocaleString()),\n      };\n    } else if (type === Legend) {\n      const props = child.props as LegendProps;\n      legend = {\n        align: props.align ?? \"right\",\n        lessLabel: props.lessLabel ?? \"Less\",\n        moreLabel: props.moreLabel ?? \"More\",\n      };\n    }\n  });\n\n  return { cell, monthLabel, dayLabel, tooltip, legend };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Date helpers — all arithmetic runs on UTC midnights so a DST change never\n// adds or drops a day. ECharts parses the same ISO strings on its side, and a\n// weekday is a property of the calendar date, so both agree on every cell.\n// ─────────────────────────────────────────────────────────────────────────────\n\nfunction parseIsoDate(iso: string): number {\n  const [y, m, d] = iso.split(\"-\").map(Number);\n  if (!y || !m || !d) return Number.NaN;\n  return Date.UTC(y, m - 1, d);\n}\n\nfunction toIsoDate(ms: number): string {\n  return new Date(ms).toISOString().slice(0, 10);\n}\n\nfunction todayUtc(): number {\n  const now = new Date();\n  return Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());\n}\n\ntype ResolvedRange = { start: number; end: number }; // inclusive UTC midnights\n\n// The calendar window as a pair of days. A bare year or `YYYY-MM` string\n// expands to that period; a tuple is used as given; nothing falls back to the\n// trailing year ending on the latest day in the data (or today, with no data).\nfunction resolveRange(range: HeatmapRange | undefined, data: HeatmapDatum[]): ResolvedRange {\n  if (typeof range === \"number\") {\n    return { start: Date.UTC(range, 0, 1), end: Date.UTC(range, 11, 31) };\n  }\n  if (typeof range === \"string\") {\n    const [y, m] = range.split(\"-\").map(Number);\n    if (y && m) return { start: Date.UTC(y, m - 1, 1), end: Date.UTC(y, m, 0) };\n    if (y) return { start: Date.UTC(y, 0, 1), end: Date.UTC(y, 11, 31) };\n  }\n  if (Array.isArray(range)) {\n    const start = parseIsoDate(range[0]);\n    const end = parseIsoDate(range[1]);\n    if (!Number.isNaN(start) && !Number.isNaN(end) && start <= end) return { start, end };\n  }\n\n  let latest = Number.NEGATIVE_INFINITY;\n  for (const datum of data) {\n    const ms = parseIsoDate(datum.date);\n    if (!Number.isNaN(ms) && ms > latest) latest = ms;\n  }\n  const end = Number.isFinite(latest) ? latest : todayUtc();\n  return { start: end - (TRAILING_YEAR_DAYS - 1) * DAY_MS, end };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Grid model — every day in the range, in order, with its value, its intensity\n// level, and its week column. Built once per data/range change and shared by\n// the option builder, the tooltip, the click handler, and the intro stagger.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype GridCell = {\n  date: string; // ISO calendar date\n  value: number; // 0 for days absent from the data\n  level: number; // 0 = empty, 1..levels = ramp intensity\n  column: number; // week column, 0-based from the range start\n};\n\ntype GridModel = {\n  cells: GridCell[];\n  weeks: number; // number of week columns — matches ECharts' own calendar math\n  levels: number; // number of non-empty intensity levels\n  range: ResolvedRange;\n};\n\n// Ascending level boundaries for `levels` buckets above empty. Quantile cuts\n// share the non-zero values evenly across the ramp (GitHub's approach), so a\n// few huge days don't wash out the rest; linear cuts divide min→max evenly.\nfunction computeThresholds(values: number[], levels: number, scale: HeatmapScale): number[] {\n  const cuts = levels - 1;\n  if (cuts <= 0) return [];\n  const nonZero = values.filter((value) => value > 0).sort((a, b) => a - b);\n  if (nonZero.length === 0) return Array.from({ length: cuts }, (_, i) => i + 1);\n\n  if (scale === \"linear\") {\n    const min = nonZero[0];\n    const max = nonZero[nonZero.length - 1];\n    if (max === min) return Array.from({ length: cuts }, () => max + 1);\n    return Array.from({ length: cuts }, (_, i) => min + ((max - min) * (i + 1)) / levels);\n  }\n\n  return Array.from({ length: cuts }, (_, i) => {\n    const position = ((i + 1) / levels) * (nonZero.length - 1);\n    const lower = Math.floor(position);\n    const upper = Math.min(nonZero.length - 1, lower + 1);\n    const fraction = position - lower;\n    return nonZero[lower] + (nonZero[upper] - nonZero[lower]) * fraction;\n  });\n}\n\nfunction levelOf(value: number, thresholds: number[]): number {\n  if (value <= 0) return 0;\n  let level = 1;\n  for (const threshold of thresholds) {\n    if (value >= threshold) level += 1;\n  }\n  return level;\n}\n\nfunction buildGrid(\n  data: HeatmapDatum[],\n  range: ResolvedRange,\n  weekStart: WeekStart,\n  levels: number,\n  scale: HeatmapScale,\n  thresholds: number[] | undefined,\n): GridModel {\n  const firstDay = weekStart === \"monday\" ? 1 : 0;\n  const valueByDate = new Map<string, number>();\n  for (const datum of data) {\n    const ms = parseIsoDate(datum.date);\n    if (Number.isNaN(ms) || ms < range.start || ms > range.end) continue;\n    valueByDate.set(toIsoDate(ms), (valueByDate.get(toIsoDate(ms)) ?? 0) + datum.value);\n  }\n\n  const cuts =\n    thresholds && thresholds.length > 0\n      ? [...thresholds].sort((a, b) => a - b)\n      : computeThresholds([...valueByDate.values()], levels, scale);\n  const effectiveLevels = cuts.length + 1;\n\n  const startOffset =\n    (new Date(range.start).getUTCDay() - firstDay + DAYS_PER_WEEK) % DAYS_PER_WEEK;\n  const days = Math.round((range.end - range.start) / DAY_MS) + 1;\n  const weeks = Math.floor((days + startOffset + DAYS_PER_WEEK - 1) / DAYS_PER_WEEK);\n\n  const cells: GridCell[] = [];\n  for (let i = 0; i < days; i++) {\n    const date = toIsoDate(range.start + i * DAY_MS);\n    const value = valueByDate.get(date) ?? 0;\n    cells.push({\n      date,\n      value,\n      level: Math.min(effectiveLevels, levelOf(value, cuts)),\n      column: Math.floor((i + startOffset) / DAYS_PER_WEEK),\n    });\n  }\n\n  return { cells, weeks, levels: effectiveLevels, range };\n}\n\n// The ramp color for a level — spreads the levels across however many colors\n// the config provides, so a 3-color ramp still serves 5 levels sensibly.\nfunction rampColor(slots: string[], level: number, levels: number): string {\n  if (slots.length === 0) return GRAY;\n  if (levels <= 1 || slots.length === 1) return slots[slots.length - 1];\n  const index = Math.round(((level - 1) / (levels - 1)) * (slots.length - 1));\n  return slots[Math.max(0, Math.min(slots.length - 1, index))];\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Layout — the calendar rect is sized from the container, not the other way\n// round: square cells that fill the width (capped by the height), the grid\n// centred vertically in whatever is left.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype Layout = {\n  cellSize: number;\n  left: number;\n  top: number;\n};\n\nfunction computeLayout(\n  width: number,\n  height: number,\n  weeks: number,\n  fixedCellSize: number | undefined,\n  hasDayLabels: boolean,\n  hasMonthLabels: boolean,\n): Layout {\n  const left = EDGE_PADDING + (hasDayLabels ? DAY_LABEL_WIDTH : 0);\n  const labelTop = EDGE_PADDING + (hasMonthLabels ? MONTH_LABEL_HEIGHT : 0);\n\n  let cellSize = fixedCellSize ?? FALLBACK_CELL_SIZE;\n  if (fixedCellSize === undefined && width > 0 && weeks > 0) {\n    const fitWidth = Math.floor((width - left - EDGE_PADDING) / weeks);\n    const fitHeight =\n      height > 0\n        ? Math.floor((height - labelTop - EDGE_PADDING) / DAYS_PER_WEEK)\n        : Number.POSITIVE_INFINITY;\n    cellSize = Math.max(MIN_CELL_SIZE, Math.min(fitWidth, fitHeight));\n  }\n\n  const gridHeight = cellSize * DAYS_PER_WEEK;\n  const spare = height > 0 ? height - labelTop - EDGE_PADDING - gridHeight : 0;\n  const top = labelTop + Math.max(0, Math.floor(spare / 2));\n\n  return { cellSize, left, top };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Loading skeleton helper — a hard clip window swept across the grid. `floor`\n// keeps the grid faintly visible between sweeps; `peak` is the bright band.\n// `center` may run outside [0, 1] so the window fully enters and exits.\n// ─────────────────────────────────────────────────────────────────────────────\n\nfunction shimmerWindowStops(center: number, color: string, floor: number, 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 floor;\n    // Sine-eased falloff — a linear ramp still reads as a hard cut.\n    const eased = Math.sin(((1 - (dist - (half - feather)) / feather) * Math.PI) / 2);\n    return floor + (peak - floor) * eased;\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// Option builders — pure functions from a snapshot context to ECharts option\n// fragments. The component reads its refs ONCE per build into this context;\n// nothing below touches React state or the chart instance.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype OptionBuildContext = {\n  config: ChartConfig;\n  grid: GridModel;\n  cell: CellSlot;\n  monthLabel: MonthLabelSlot | null;\n  dayLabel: DayLabelSlot | null;\n  tooltipSlot: TooltipSlot;\n  weekStart: WeekStart;\n  layout: Layout;\n  selectedDate: string | null;\n  isLoading: boolean;\n  revealEnabled: boolean; // play the intro sweep on this push\n  resolved: ResolvedColors;\n};\n\nfunction buildCalendarOption(ctx: OptionBuildContext): CalendarComponentOption {\n  const { grid, monthLabel, dayLabel, weekStart, layout, resolved } = ctx;\n  const { tokens } = resolved;\n\n  return {\n    id: \"__calendar\",\n    left: layout.left,\n    top: layout.top,\n    cellSize: [layout.cellSize, layout.cellSize],\n    range: [toIsoDate(grid.range.start), toIsoDate(grid.range.end)],\n    orient: \"horizontal\",\n    // The calendar draws nothing of its own — no grid lines, no cell backdrops.\n    // Empty days are painted by the custom series so they share the cells'\n    // gap and radius.\n    splitLine: { show: false },\n    itemStyle: { color: \"transparent\", borderWidth: 0 },\n    yearLabel: { show: false },\n    monthLabel: {\n      show: monthLabel !== null,\n      nameMap: monthLabel?.labels ?? DEFAULT_MONTH_LABELS,\n      position: \"start\",\n      align: \"left\",\n      margin: LABEL_MARGIN,\n      color: tokens.mutedForeground,\n      fontSize: 11,\n    },\n    dayLabel: {\n      show: dayLabel !== null,\n      firstDay: weekStart === \"monday\" ? 1 : 0,\n      nameMap: dayLabel?.labels ?? DEFAULT_DAY_LABELS,\n      position: \"start\",\n      margin: LABEL_MARGIN,\n      color: tokens.mutedForeground,\n      fontSize: 10,\n    },\n  };\n}\n\nfunction buildCellSeries(ctx: OptionBuildContext): CustomSeriesOption {\n  const { grid, cell, selectedDate, resolved, revealEnabled } = ctx;\n  const { tokens } = resolved;\n  const slots = resolved.series[cell.dataKey] ?? [];\n  const emptyFill = withAlpha(tokens.foreground, EMPTY_CELL_OPACITY);\n  const hoverRing = withAlpha(tokens.foreground, HOVER_RING_OPACITY);\n  const selectedRing = withAlpha(tokens.foreground, SELECTED_RING_OPACITY);\n  const hasSelection = selectedDate !== null;\n\n  const renderItem = (params: RenderItemParams, api: RenderItemApi): RenderItemReturn => {\n    const item = grid.cells[params.dataIndex];\n    if (!item) return undefined;\n\n    const coordSys = params.coordSys as { cellWidth?: number; cellHeight?: number };\n    const cellWidth = coordSys.cellWidth ?? ctx.layout.cellSize;\n    const cellHeight = coordSys.cellHeight ?? ctx.layout.cellSize;\n    const [cx, cy] = api.coord([api.value(0)]);\n    // The gap is carved out of the cell on every side, so the pitch stays\n    // exactly one calendar cell and the cell never grows past its slot.\n    const gap = Math.min(cell.gap, Math.min(cellWidth, cellHeight) - 1);\n    const width = Math.max(1, cellWidth - gap);\n    const height = Math.max(1, cellHeight - gap);\n    const radius = Math.min(cell.radius, Math.min(width, height) / 2);\n\n    const selected = hasSelection && item.date === selectedDate;\n    const dimmed = hasSelection && !selected;\n    const fill = item.level === 0 ? emptyFill : rampColor(slots, item.level, grid.levels);\n\n    return {\n      type: \"rect\",\n      shape: { x: cx - width / 2, y: cy - height / 2, width, height, r: radius },\n      // The selected cell keeps full strength and wears a ring; the rest soften.\n      style: {\n        fill,\n        opacity: dimmed ? CELL_DIM_OPACITY : 1,\n        stroke: selected ? selectedRing : \"transparent\",\n        lineWidth: selected ? SELECTED_RING_WIDTH : 0,\n      },\n      // Draw the selected cell last so its ring is never covered by a neighbour.\n      z2: selected ? 2 : 1,\n      // Scale about the cell's own centre, so the intro pop and the hover ring\n      // both grow from the middle instead of the top-left corner.\n      originX: cx,\n      originY: cy,\n      // Selection/theme/resize changes glide rather than snap.\n      transition: [\"shape\", \"style\"],\n      // Intro: the cell opens from a dot and fades in. Only new elements play\n      // this — later pushes update the existing rects in place.\n      ...(revealEnabled\n        ? {\n            enterFrom: {\n              // `shape` is required by the type; empty means no shape prop tweens.\n              shape: {},\n              scaleX: INTRO_CELL_SCALE_FROM,\n              scaleY: INTRO_CELL_SCALE_FROM,\n              style: { opacity: 0 },\n            },\n          }\n        : {}),\n      emphasis: {\n        style: { stroke: hoverRing, lineWidth: HOVER_RING_WIDTH },\n      },\n    };\n  };\n\n  return {\n    id: \"__cells\",\n    type: \"custom\",\n    coordinateSystem: \"calendar\",\n    calendarId: \"__calendar\",\n    z: 3,\n    renderItem,\n    // ECharts needs the date in the data to place each item; the value rides\n    // along for the tooltip's `params.value`.\n    data: grid.cells.map((item) => [item.date, item.value]),\n    // Left → right sweep: each week column starts a beat after the previous.\n    animation: revealEnabled,\n    animationDuration: INTRO_CELL_GROW,\n    animationEasing: \"cubicOut\",\n    animationDelay: (dataIndex: number) =>\n      (grid.cells[dataIndex]?.column ?? 0) * INTRO_COLUMN_STAGGER,\n    animationDurationUpdate: UPDATE_DURATION,\n    animationEasingUpdate: \"cubicOut\",\n    animationDelayUpdate: 0,\n  };\n}\n\n// Tooltip HTML builder, closed over the build context. Each hovered cell shows\n// the day as the title and a single swatch + label + value row beneath it.\nfunction createTooltipFormatter(ctx: OptionBuildContext) {\n  const { config, grid, cell, tooltipSlot, resolved } = ctx;\n  const label = config[cell.dataKey]?.label;\n  const labelText = typeof label === \"string\" ? label : cell.dataKey;\n  const colorsCount = config[cell.dataKey] ? getColorsCount(config[cell.dataKey]) : 1;\n  const slots = resolved.series[cell.dataKey] ?? [];\n\n  // The swatch shows the hovered cell's OWN level color — a `--color-*` var\n  // for a ramp level, a foreground wash for an empty day — rather than the\n  // whole ramp, which is what the shared indicator would paint.\n  const swatchFor = (item: GridCell) => {\n    if (item.level === 0) {\n      return `<div class=\"bg-foreground/10 h-2.5 w-2.5 shrink-0 rounded-[2px]\"></div>`;\n    }\n    const slotIndex = Math.max(0, slots.indexOf(rampColor(slots, item.level, grid.levels)));\n    const index = Math.min(colorsCount - 1, slotIndex);\n    return `<div class=\"h-2.5 w-2.5 shrink-0 rounded-[2px]\" style=\"background:var(--color-${cell.dataKey}-${index})\"></div>`;\n  };\n\n  return (params: unknown): string => {\n    const p = params as { dataIndex?: number };\n    const item = grid.cells[p.dataIndex ?? -1];\n    if (!item) return \"\";\n\n    return tooltipShell({\n      label: tooltipSlot.dateFormatter(item.date),\n      body: tooltipRow({\n        indicatorHtml: swatchFor(item),\n        labelText,\n        valueText: tooltipSlot.valueFormatter(item.value),\n        dimmed: \"\",\n      }),\n      roundness: tooltipSlot.roundness,\n      variant: tooltipSlot.variant,\n    });\n  };\n}\n\nfunction buildTooltipOption(ctx: OptionBuildContext): TooltipComponentOption {\n  const { tooltipSlot, isLoading } = ctx;\n  return {\n    show: tooltipSlot.present && !isLoading,\n    trigger: \"item\",\n    confine: true,\n    backgroundColor: \"transparent\",\n    borderWidth: 0,\n    padding: 0,\n    extraCssText: \"box-shadow:none;\",\n    displayTransition: false,\n    // Item-triggered (cells, no axis), so the position wires straight through\n    // resolveTooltipPosition rather than tooltipBaseOption (trigger:\"axis\" only).\n    position: resolveTooltipPosition(tooltipSlot.position),\n    formatter: createTooltipFormatter(ctx),\n  };\n}\n\n// Loading skeleton — the same grid in transparent foreground, invisible until\n// the first shimmer tick tints it, so there is no flash before the rAF loop\n// positions the sweep.\nfunction buildLoadingOption(ctx: OptionBuildContext): EChartsOption {\n  const { grid, cell, resolved } = ctx;\n  const transparent = withAlpha(resolved.tokens.foreground, 0);\n\n  const renderItem = (params: RenderItemParams, api: RenderItemApi): RenderItemReturn => {\n    const coordSys = params.coordSys as { cellWidth?: number; cellHeight?: number };\n    const cellWidth = coordSys.cellWidth ?? ctx.layout.cellSize;\n    const cellHeight = coordSys.cellHeight ?? ctx.layout.cellSize;\n    const [cx, cy] = api.coord([api.value(0)]);\n    const gap = Math.min(cell.gap, Math.min(cellWidth, cellHeight) - 1);\n    const width = Math.max(1, cellWidth - gap);\n    const height = Math.max(1, cellHeight - gap);\n    const radius = Math.min(cell.radius, Math.min(width, height) / 2);\n    return {\n      type: \"rect\",\n      shape: { x: cx - width / 2, y: cy - height / 2, width, height, r: radius },\n      style: { fill: transparent },\n      silent: true,\n    };\n  };\n\n  return {\n    animation: false,\n    tooltip: { show: false },\n    calendar: buildCalendarOption(ctx),\n    series: [\n      {\n        id: \"__loading\",\n        type: \"custom\",\n        coordinateSystem: \"calendar\",\n        calendarId: \"__calendar\",\n        silent: true,\n        renderItem,\n        data: grid.cells.map((item) => [item.date, 0]),\n        animation: false,\n      },\n    ],\n  };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Live imperative state — everything the ECharts event handlers, the shimmer\n// rAF, and the theme repush read or write OUTSIDE the React render cycle,\n// grouped in one ref-stable object. None of it is render output, which is why\n// it is not React state.\n// ─────────────────────────────────────────────────────────────────────────────\n\ntype LiveState = {\n  resolved: ResolvedColors | null; // colors read off the live DOM — feeds builds and the shimmer\n  selectedDate: string | null; // current selection, kept in step with the React state\n  hasRevealed: boolean; // the intro sweep already played on this chart instance\n  revealEnabled: boolean; // the current push should create cells with the entrance\n  // Latest callbacks/flags for the imperative ECharts click handler.\n  handlers: {\n    onSelectionChange?: (selection: HeatmapSelection | null) => void;\n    isCellClickable: boolean;\n    cells: GridCell[];\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 * Calendar heatmap rendered with Apache ECharts — the GitHub contribution graph\n * shape: one square per day, weeks as columns, color intensity from the config's\n * color ramp. The root owns the date window, the value → level mapping, selection\n * state, the loading skeleton, and the intro sweep; the visual parts — `<Cell>`,\n * `<MonthLabel>`, `<DayLabel>`, `<Tooltip>`, `<Legend>` — are composed as\n * declarative children that render nothing. The root walks those children by\n * reference and drives a single imperative ECharts instance. Fully self-contained:\n * its only dependencies are `react`, `echarts`, and `motion`.\n */\nexport function EChartsHeatmapChart({\n  data,\n  config,\n  children,\n  className,\n  renderer = DEFAULT_ECHARTS_RENDERER,\n  range,\n  weekStart = \"sunday\",\n  cellSize,\n  scale = \"quantile\",\n  thresholds,\n  defaultSelectedDate = null,\n  onSelectionChange,\n  isLoading = false,\n  animation = true,\n  animationType = \"default\",\n  chartOptions,\n}: EChartsHeatmapChartProps) {\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). Object identity is stable\n  // for the component's lifetime.\n  const live = useRef<LiveState>({\n    resolved: null,\n    selectedDate: defaultSelectedDate,\n    hasRevealed: false,\n    revealEnabled: false,\n    handlers: {\n      onSelectionChange,\n      isCellClickable: false,\n      cells: [],\n    },\n    repush: () => {},\n  }).current;\n\n  const shouldReduceMotion = useReducedMotion();\n\n  const [selectedDate, setSelectedDate] = useState<string | null>(defaultSelectedDate);\n\n  // ── Declarative config, collected from children by reference ─────────────────\n  const collected = useMemo(() => collectConfig(children), [children]);\n  const { cell, monthLabel, dayLabel, tooltip: tooltipSlot, legend } = collected;\n\n  const css = useMemo(() => buildChartCss(chartId, config), [chartId, config]);\n\n  // The ramp has as many levels as the config has colors for the cell's key.\n  const seriesKeys = useMemo(() => (cell.dataKey ? [cell.dataKey] : []), [cell.dataKey]);\n  const colorsCount = config[cell.dataKey] ? getColorsCount(config[cell.dataKey]) : 1;\n\n  const grid = useMemo(\n    () => buildGrid(data, resolveRange(range, data), weekStart, colorsCount, scale, thresholds),\n    [data, range, weekStart, colorsCount, scale, thresholds],\n  );\n\n  // Refresh the click handler's snapshot of the latest callbacks/flags every render.\n  live.handlers = {\n    onSelectionChange,\n    isCellClickable: cell.isClickable,\n    cells: grid.cells,\n  };\n\n  // The next selection is derived from the ref, not inside the state updater:\n  // React may run an updater more than once, and the callback must fire once.\n  const toggleSelection = useCallback(\n    (date: string) => {\n      const next = live.selectedDate === date ? null : date;\n      live.selectedDate = next;\n      setSelectedDate(next);\n      const { onSelectionChange: cb, cells } = live.handlers;\n      if (next === null) {\n        cb?.(null);\n      } else {\n        const item = cells.find((candidate) => candidate.date === next);\n        cb?.({ date: next, value: item?.value ?? 0 });\n      }\n    },\n    [live],\n  );\n\n  // ── Option builder ───────────────────────────────────────────────────────────\n  // Thin orchestrator over the pure builders above: snapshot the imperative\n  // surface into an OptionBuildContext, then assemble.\n  const buildOption = useCallback((): EChartsOption => {\n    const resolved = live.resolved;\n    const chart = echartsRef.current;\n    if (!resolved || !chart) return {};\n\n    const layout = computeLayout(\n      chart.getWidth(),\n      chart.getHeight(),\n      grid.weeks,\n      cellSize,\n      dayLabel !== null,\n      monthLabel !== null,\n    );\n\n    const ctx: OptionBuildContext = {\n      config,\n      grid,\n      cell,\n      monthLabel,\n      dayLabel,\n      tooltipSlot,\n      weekStart,\n      layout,\n      selectedDate,\n      isLoading,\n      revealEnabled: live.revealEnabled,\n      resolved,\n    };\n\n    if (isLoading) return buildLoadingOption(ctx);\n\n    return {\n      animation: true,\n      calendar: buildCalendarOption(ctx),\n      tooltip: buildTooltipOption(ctx),\n      series: [buildCellSeries(ctx)],\n    };\n  }, [\n    live,\n    config,\n    grid,\n    cell,\n    monthLabel,\n    dayLabel,\n    tooltipSlot,\n    weekStart,\n    cellSize,\n    selectedDate,\n    isLoading,\n  ]);\n\n  // ── Init + resize + theme observer (per renderer instance) ───────────────────\n  useEffect(() => {\n    const mount = mountRef.current;\n    const container = containerRef.current;\n    if (!mount || !container) return;\n\n    const chart = echarts.init(mount, null, { renderer });\n    echartsRef.current = chart;\n\n    const resizeObserver = new ResizeObserver(() => {\n      // Observers always fire once right after observe(). Only react when the\n      // renderer size actually changed — the cells are sized from it.\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    // Clicking a cell toggles its selection, only when <Cell isClickable> is set.\n    chart.on(\"click\", (params) => {\n      const { isCellClickable, cells } = live.handlers;\n      if (!isCellClickable) return;\n      const p = params as { seriesId?: string; dataIndex?: number };\n      if (p.seriesId !== \"__cells\") return;\n      const item = cells[p.dataIndex ?? -1];\n      if (item) toggleSelection(item.date);\n    });\n\n    return () => {\n      resizeObserver.disconnect();\n      themeObserver.disconnect();\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  }, [renderer]);\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    // Intro sweep, played once per chart instance: ECharts' own enter animation\n    // runs on the cells the first time they are created, staggered by week\n    // column. Later pushes update those rects in place, so the entrance never\n    // replays. A loading cycle re-arms it — the skeleton is a different series,\n    // so leaving it creates the cells afresh and the sweep plays again.\n    if (isLoading) live.hasRevealed = false;\n    const shouldReveal = !live.hasRevealed && !isLoading;\n    if (shouldReveal) live.hasRevealed = true;\n    live.revealEnabled =\n      animation && shouldReveal && animationType !== \"none\" && !shouldReduceMotion;\n\n    const push = () => {\n      const option = buildOption();\n      const merged = chartOptions ? { ...option, ...chartOptions } : option;\n      // chartOptions is an untyped escape hatch — the spread erases the option's\n      // shape, so re-assert it. The only cast in the file.\n      chart.setOption(merged as EChartsOption, { notMerge: true });\n    };\n\n    push();\n    // The entrance belongs to the first push only; anything re-entering through\n    // repush (theme, resize) updates cells that already exist.\n    live.revealEnabled = false;\n\n    // Theme flips and resizes re-enter here without touching React: re-read the\n    // tokens (the .dark class changed, or the renderer resized) and push again.\n    live.repush = () => {\n      live.resolved = resolveColors(container, config, seriesKeys);\n      push();\n    };\n  }, [\n    live,\n    buildOption,\n    chartOptions,\n    isLoading,\n    animation,\n    animationType,\n    shouldReduceMotion,\n    config,\n    renderer,\n    seriesKeys,\n  ]);\n\n  // ── Loading shimmer — rAF sweeps a bright band across the skeleton grid ──────\n  useEffect(() => {\n    const chart = echartsRef.current;\n    if (!chart || !isLoading) return;\n\n    let raf = 0;\n    const start = performance.now();\n    const tick = (now: number) => {\n      const phase = ((((now - start) / LOADING_ANIMATION_DURATION) % 1) + 1) % 1;\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°. ABSOLUTE pixel coordinates (global gradient) are\n      // shared by every cell, so a column lights up together as the band\n      // passes its x-position.\n      const maxT = (w + h) / (2 * w);\n      const center = phase * (maxT + 2 * LOADING_SHIMMER_BAND) - LOADING_SHIMMER_BAND;\n      const fill = new echarts.graphic.LinearGradient(\n        0,\n        0,\n        w,\n        w,\n        shimmerWindowStops(center, foreground, LOADING_CELL_FLOOR, LOADING_CELL_PEAK),\n        true,\n      );\n      const cells = live.handlers.cells;\n      const cellSlot = collected.cell;\n      chart.setOption(\n        {\n          series: [\n            {\n              id: \"__loading\",\n              renderItem: (params: RenderItemParams, api: RenderItemApi): RenderItemReturn => {\n                const coordSys = params.coordSys as { cellWidth?: number; cellHeight?: number };\n                const cellWidth = coordSys.cellWidth ?? FALLBACK_CELL_SIZE;\n                const cellHeight = coordSys.cellHeight ?? FALLBACK_CELL_SIZE;\n                const [cx, cy] = api.coord([api.value(0)]);\n                const gap = Math.min(cellSlot.gap, Math.min(cellWidth, cellHeight) - 1);\n                const width = Math.max(1, cellWidth - gap);\n                const height = Math.max(1, cellHeight - gap);\n                const radius = Math.min(cellSlot.radius, Math.min(width, height) / 2);\n                return {\n                  type: \"rect\",\n                  shape: { x: cx - width / 2, y: cy - height / 2, width, height, r: radius },\n                  style: { fill },\n                  silent: true,\n                };\n              },\n              data: cells.map((item) => [item.date, 0]),\n            },\n          ],\n        },\n        { silent: true, lazyUpdate: true },\n      );\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [live, collected.cell, isLoading, renderer]);\n\n  const legendSwatchStyle = { borderRadius: Math.min(cell.radius, 5) };\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      {legend && !isLoading && (\n        <div\n          className={`text-muted-foreground flex shrink-0 items-center gap-1 pt-2 text-[11px] leading-none ${\n            legend.align === \"left\" ? \"justify-start\" : \"justify-end\"\n          }`}\n        >\n          <span className=\"mr-0.5\">{legend.lessLabel}</span>\n          <span className=\"bg-foreground/[0.08] h-2.5 w-2.5 shrink-0\" style={legendSwatchStyle} />\n          {Array.from({ length: colorsCount }, (_, index) => (\n            <span\n              key={index}\n              className=\"h-2.5 w-2.5 shrink-0\"\n              style={{ ...legendSwatchStyle, background: `var(--color-${cell.dataKey}-${index})` }}\n            />\n          ))}\n          <span className=\"ml-0.5\">{legend.moreLabel}</span>\n        </div>\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\nEChartsHeatmapChart.Cell = Cell;\nEChartsHeatmapChart.MonthLabel = MonthLabel;\nEChartsHeatmapChart.DayLabel = DayLabel;\nEChartsHeatmapChart.Tooltip = Tooltip;\nEChartsHeatmapChart.Legend = Legend;\n",
      "type": "registry:component",
      "target": "components/evilcharts/charts/echarts-heatmap-chart.tsx"
    }
  ],
  "type": "registry:component"
}