---
title: Heatmap Chart
description: A GitHub-style calendar heatmap — one cell per day, colored by intensity — powered by Apache ECharts
image: /og/heatmap-chart.png
links:
  github: https://github.com/legions-developer/evilcharts/blob/main/src/registry/charts/echarts-heatmap-chart.tsx
  doc: https://echarts.apache.org/en/index.html
  api: https://echarts.apache.org/en/option.html#calendar
---

### Basic Chart

```tsx
"use client";

import {
  EChartsHeatmapChart,
  type ChartConfig,
  type HeatmapDatum,
} from "@/components/evilcharts/charts/echarts-heatmap-chart";

// A year of daily contributions, seeded so the docs render the same picture every time.
function buildContributions(seed: number): HeatmapDatum[] {
  let state = seed;
  const random = () => {
    state = (state * 1664525 + 1013904223) % 4294967296;
    return state / 4294967296;
  };
  const end = Date.UTC(2025, 11, 31);
  const data: HeatmapDatum[] = [];
  for (let i = 364; i >= 0; i--) {
    const day = new Date(end - i * 86_400_000);
    const weekend = day.getUTCDay() === 0 || day.getUTCDay() === 6;
    const active = random() < (weekend ? 0.3 : 0.8);
    if (!active) continue;
    const value = Math.round(random() * (weekend ? 6 : 14)) + 1;
    data.push({ date: day.toISOString().slice(0, 10), value });
  }
  return data;
}

const data = buildContributions(7);

const chartConfig = {
  contributions: {
    label: "Contributions",
    colors: {
      light: ["#9be9a8", "#40c463", "#30a14e", "#216e39"],
      dark: ["#0e4429", "#006d32", "#26a641", "#39d353"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleHeatmapChart() {
  return (
    <EChartsHeatmapChart className="h-full w-full p-4" data={data} config={chartConfig}>
      <EChartsHeatmapChart.Cell dataKey="contributions" />
      <EChartsHeatmapChart.MonthLabel />
      <EChartsHeatmapChart.DayLabel />
      <EChartsHeatmapChart.Tooltip />
      <EChartsHeatmapChart.Legend />
    </EChartsHeatmapChart>
  );
}

```

## Installation


  
  
    ### npm

```bash
npx shadcn@latest add @evilcharts/echarts-heatmap-chart
```

### yarn

```bash
yarn shadcn@latest add @evilcharts/echarts-heatmap-chart
```

### bun

```bash
bunx --bun shadcn@latest add @evilcharts/echarts-heatmap-chart
```

### pnpm

```bash
pnpm dlx shadcn@latest add @evilcharts/echarts-heatmap-chart
```
  
  
    
      
        ### Install the following dependencies:
        
          ### npm

```bash
npm install echarts motion
```

### yarn

```bash
yarn add echarts motion
```

### bun

```bash
bun add echarts motion
```

### pnpm

```bash
pnpm add echarts motion
```
        
      
      
        ### Copy and paste the following code snippets into your project.
        
          First create the folder `evilcharts` and a subfolder `charts` in your `components` directory, then paste the heatmap-chart code into a new `echarts-heatmap-chart.tsx` file there.
        
        
          ### components/evilcharts/charts/echarts-heatmap-chart.tsx

```tsx
"use client";

import {
  DEFAULT_ECHARTS_RENDERER,
  buildChartCss,
  getColorsCount,
  resolveColors,
  withAlpha,
  type ChartConfig,
  type EChartsRenderer,
  type ResolvedColors,
} from "@/components/evilcharts/ui/echarts-chart";
import {
  resolveTooltipPosition,
  tooltipRow,
  tooltipShell,
  type TooltipPosition,
  type TooltipRoundness,
  type TooltipVariant,
} from "@/components/evilcharts/ui/echarts-tooltip";
import {
  Children,
  isValidElement,
  useCallback,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type FC,
  type ReactNode,
} from "react";
import {
  CalendarComponent,
  TooltipComponent,
  type CalendarComponentOption,
  type TooltipComponentOption,
} from "echarts/components";
import { CustomChart, type CustomSeriesOption } from "echarts/charts";
import { motion, useReducedMotion } from "motion/react";
import type { ComposeOption } from "echarts/core";
import * as echarts from "echarts/core";

// Re-export the shared types so consumers/examples import everything they need
// from the chart module, like every other EvilCharts chart.
export type { ChartConfig, EChartsRenderer, TooltipPosition, TooltipRoundness, TooltipVariant };

// Modular registration keeps the bundle lean — only the pieces this chart needs.
// The calendar coordinate system does the date → cell math (week columns, first
// day of week, month/day labels); the cells themselves are drawn by a custom
// series so they can carry a gap, rounded corners, and a per-cell color from
// the config ramp — none of which the built-in heatmap series can do on its
// own. The tooltip is the one extra component.
echarts.use([CustomChart, CalendarComponent, TooltipComponent]);

type EChartsInstance = ReturnType<typeof echarts.init>;

// The exact option surface this chart uses. Narrower than echarts' full
// EChartsOption, so a misspelled key fails the compile instead of silently
// reaching setOption.
type EChartsOption = ComposeOption<
  CustomSeriesOption | CalendarComponentOption | TooltipComponentOption
>;

// The modular entry points don't export the renderItem types directly — derive
// them from the series option so the cell painter stays fully type-checked.
type RenderItem = NonNullable<CustomSeriesOption["renderItem"]>;
type RenderItemParams = Parameters<RenderItem>[0];
type RenderItemApi = Parameters<RenderItem>[1];
type RenderItemReturn = ReturnType<RenderItem>;

// ─────────────────────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────────────────────

const DAY_MS = 86_400_000;
const DAYS_PER_WEEK = 7;
const TRAILING_YEAR_DAYS = 365; // default range — the trailing year, GitHub style

// Intro reveal — cells pop in column by column, left to right, so the eye
// reads the year in order. Times are in milliseconds.
const INTRO_COLUMN_STAGGER = 14; // delay between one week column and the next
const INTRO_CELL_GROW = 320; // a single cell scaling up + fading in
const INTRO_CELL_SCALE_FROM = 0.4; // a cell opens from this fraction of its size
const UPDATE_DURATION = 180; // selection/theme/resize transitions
const LOADING_ANIMATION_DURATION = 2000; // shimmer loop, in milliseconds

const DEFAULT_CELL_RADIUS = 2;
const DEFAULT_CELL_GAP = 3;
const MIN_CELL_SIZE = 4; // never fit cells smaller than this
const FALLBACK_CELL_SIZE = 12; // used before the container has a measured size

// Space reserved around the calendar rect for its labels, in pixels.
const DAY_LABEL_WIDTH = 32;
const MONTH_LABEL_HEIGHT = 22;
const LABEL_MARGIN = 8;
const EDGE_PADDING = 2; // keeps the outermost cells' hover ring inside the canvas

const GRAY = "rgba(120, 120, 120, 1)"; // fallback when the ramp has no resolved color

// ─────────────────────────────────────────────────────────────────────────────
// Theme knobs — every opacity in the chart draws from these. Base colors come
// from the consumer's CSS tokens (resolved from the live DOM), so only the
// opacity factors live here. `withAlpha` MULTIPLIES a token's own alpha.
// ─────────────────────────────────────────────────────────────────────────────
const EMPTY_CELL_OPACITY = 0.08; // a day with no value — foreground at a whisper (GitHub's #ebedf0 / #161b22)
const CELL_DIM_OPACITY = 0.55; // cells outside the current selection — soft, so the ramp still reads
const HOVER_RING_WIDTH = 1; // outline drawn around the hovered cell, in pixels
const HOVER_RING_OPACITY = 0.6; // × foreground alpha
const SELECTED_RING_WIDTH = 1.5; // outline drawn around the selected cell, in pixels
const SELECTED_RING_OPACITY = 0.9; // × foreground alpha

// The loading skeleton is the same grid, painted in foreground gray and swept by
// a shimmer band. A low BASE floor keeps the grid legible between sweeps.
const LOADING_CELL_FLOOR = 0.07; // cell fill outside the sweep, × foreground alpha
const LOADING_CELL_PEAK = 0.22; // cell fill inside the sweep, × foreground alpha
const LOADING_SHIMMER_BAND = 0.22; // sweep half-width, fraction of chart width
const LOADING_SHIMMER_FEATHER = 0.22; // eased edge softening of the sweep

const DEFAULT_DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""]; // Sunday first, like GitHub
const DEFAULT_MONTH_LABELS = [
  "Jan",
  "Feb",
  "Mar",
  "Apr",
  "May",
  "Jun",
  "Jul",
  "Aug",
  "Sep",
  "Oct",
  "Nov",
  "Dec",
];

// ─────────────────────────────────────────────────────────────────────────────
// Public types
// ─────────────────────────────────────────────────────────────────────────────

// One day of data. `date` is an ISO calendar date (`YYYY-MM-DD`); days absent
// from `data` render as empty cells.
export type HeatmapDatum = {
  date: string;
  value: number;
};

// The calendar window: a year (`2025`), a month (`"2025-03"`), or an inclusive
// `[start, end]` pair of ISO dates. Omit it for the trailing year that ends on
// the latest date in `data`.
export type HeatmapRange = number | string | [string, string];

export type WeekStart = "sunday" | "monday";
export type HeatmapScale = "quantile" | "linear";
// A heatmap's entrance sweeps the grid left to right: "default" plays it,
// "none" turns it off. Kept as a small union for parity with the other
// EvilCharts entrance switches.
export type HeatmapAnimationType = "none" | "default";

export type HeatmapSelection = { date: string; value: number };

export interface EChartsHeatmapChartProps {
  data: HeatmapDatum[]; // one entry per day that has a value
  config: ChartConfig; // the series key's label + color ramp (one color per intensity level)
  children: ReactNode; // composed parts — <Cell>, <MonthLabel>, <DayLabel>, <Tooltip>, <Legend>
  className?: string; // extra classes for the chart container
  renderer?: EChartsRenderer; // rendering engine — canvas by default, or SVG
  range?: HeatmapRange; // calendar window; defaults to the trailing year
  weekStart?: WeekStart; // which day starts each column
  cellSize?: number; // fixed cell size in pixels; omit to fit the container width
  scale?: HeatmapScale; // how values map onto the color ramp when no thresholds are given
  thresholds?: number[]; // explicit ascending level boundaries; values below the first are level 1
  defaultSelectedDate?: string | null; // cell selected on first render
  onSelectionChange?: (selection: HeatmapSelection | null) => void; // fires when the selected cell changes
  isLoading?: boolean; // shows the animated loading skeleton
  animation?: boolean; // master switch for the intro sweep — false renders instantly
  animationType?: HeatmapAnimationType; // "none" disables the intro sweep
  chartOptions?: Record<string, unknown>; // escape hatch merged over the built ECharts option
}

// ─────────────────────────────────────────────────────────────────────────────
// Composible parts — DECLARATIVE CONFIG. Every part renders `null`; the root
// walks `children` by reference (child.type === Cell, …) to collect its props.
// The cells are intrinsic to the data, so <Cell> always renders — it only
// CONFIGURES the grid and names the config key to color it with. The labels,
// tooltip, and legend follow presence semantics: omit one and it does not render.
// ─────────────────────────────────────────────────────────────────────────────

export interface CellProps {
  dataKey: string; // config key whose colors form the intensity ramp
  radius?: number; // corner radius of each cell in pixels
  gap?: number; // space between cells in pixels
  isClickable?: boolean; // lets cells be selected by clicking them
}

/**
 * Configures the heatmap cells. A configuration slot — the root reads its props
 * and wires them into the ECharts custom series, so it renders nothing itself.
 * `dataKey` picks the config entry whose color array becomes the intensity ramp:
 * the first color is the lightest level, the last the strongest.
 */
const Cell: FC<CellProps> = () => null;

export interface MonthLabelProps {
  labels?: string[]; // twelve month names, January first
}

/** Presence shows month names above the grid. Renders nothing. */
const MonthLabel: FC<MonthLabelProps> = () => null;

export interface DayLabelProps {
  labels?: string[]; // seven day names, Sunday first; an empty string hides that row's label
}

/** Presence shows weekday names beside the grid. Renders nothing. */
const DayLabel: FC<DayLabelProps> = () => null;

export interface TooltipProps {
  variant?: TooltipVariant; // visual style of the tooltip surface
  roundness?: TooltipRoundness; // border-radius of the tooltip
  position?: TooltipPosition; // "variable" follows the pointer (default); "fixed" pins the tooltip near the top and tracks the pointer's X
  dateFormatter?: (date: string) => string; // formats the hovered day for the tooltip title
  valueFormatter?: (value: number) => string; // formats the hovered day's value
}

/** Presence enables the hover tooltip. Renders nothing. */
const Tooltip: FC<TooltipProps> = () => null;

export interface LegendProps {
  align?: "left" | "right"; // which side of the chart the ramp sits on
  lessLabel?: string; // text before the ramp
  moreLabel?: string; // text after the ramp
}

/** Presence renders the "Less → More" ramp under the grid. Renders nothing itself. */
const Legend: FC<LegendProps> = () => null;

// ─────────────────────────────────────────────────────────────────────────────
// Children collection — walk the declarative config into plain objects the
// option builder consumes.
// ─────────────────────────────────────────────────────────────────────────────

type CellSlot = {
  dataKey: string;
  radius: number;
  gap: number;
  isClickable: boolean;
};
type MonthLabelSlot = { labels: string[] };
type DayLabelSlot = { labels: string[] };
type TooltipSlot = {
  present: boolean;
  variant: TooltipVariant;
  roundness: TooltipRoundness;
  position: TooltipPosition;
  dateFormatter: (date: string) => string;
  valueFormatter: (value: number) => string;
};
type LegendSlot = {
  align: "left" | "right";
  lessLabel: string;
  moreLabel: string;
};

type CollectedConfig = {
  cell: CellSlot;
  monthLabel: MonthLabelSlot | null;
  dayLabel: DayLabelSlot | null;
  tooltip: TooltipSlot;
  legend: LegendSlot | null;
};

// "Mar 3, 2026" — parsed as a calendar date, not a local instant, so the label
// never slips a day across time zones.
function defaultDateFormatter(date: string): string {
  const ms = parseIsoDate(date);
  if (Number.isNaN(ms)) return date;
  return new Date(ms).toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
    year: "numeric",
    timeZone: "UTC",
  });
}

function collectConfig(children: ReactNode): CollectedConfig {
  let cell: CellSlot = {
    dataKey: "",
    radius: DEFAULT_CELL_RADIUS,
    gap: DEFAULT_CELL_GAP,
    isClickable: false,
  };
  let monthLabel: MonthLabelSlot | null = null;
  let dayLabel: DayLabelSlot | null = null;
  let tooltip: TooltipSlot = {
    present: false,
    variant: "default",
    roundness: "lg",
    position: "variable",
    dateFormatter: defaultDateFormatter,
    valueFormatter: (value) => value.toLocaleString(),
  };
  let legend: LegendSlot | null = null;

  Children.forEach(children, (child) => {
    if (!isValidElement(child)) return;
    const type = child.type;

    if (type === Cell) {
      const props = child.props as CellProps;
      cell = {
        dataKey: props.dataKey,
        radius: props.radius ?? DEFAULT_CELL_RADIUS,
        gap: props.gap ?? DEFAULT_CELL_GAP,
        isClickable: props.isClickable ?? false,
      };
    } else if (type === MonthLabel) {
      const props = child.props as MonthLabelProps;
      monthLabel = { labels: props.labels ?? DEFAULT_MONTH_LABELS };
    } else if (type === DayLabel) {
      const props = child.props as DayLabelProps;
      dayLabel = { labels: props.labels ?? DEFAULT_DAY_LABELS };
    } else if (type === Tooltip) {
      const props = child.props as TooltipProps;
      tooltip = {
        present: true,
        variant: props.variant ?? "default",
        roundness: props.roundness ?? "lg",
        position: props.position ?? "variable",
        dateFormatter: props.dateFormatter ?? defaultDateFormatter,
        valueFormatter: props.valueFormatter ?? ((value) => value.toLocaleString()),
      };
    } else if (type === Legend) {
      const props = child.props as LegendProps;
      legend = {
        align: props.align ?? "right",
        lessLabel: props.lessLabel ?? "Less",
        moreLabel: props.moreLabel ?? "More",
      };
    }
  });

  return { cell, monthLabel, dayLabel, tooltip, legend };
}

// ─────────────────────────────────────────────────────────────────────────────
// Date helpers — all arithmetic runs on UTC midnights so a DST change never
// adds or drops a day. ECharts parses the same ISO strings on its side, and a
// weekday is a property of the calendar date, so both agree on every cell.
// ─────────────────────────────────────────────────────────────────────────────

function parseIsoDate(iso: string): number {
  const [y, m, d] = iso.split("-").map(Number);
  if (!y || !m || !d) return Number.NaN;
  return Date.UTC(y, m - 1, d);
}

function toIsoDate(ms: number): string {
  return new Date(ms).toISOString().slice(0, 10);
}

function todayUtc(): number {
  const now = new Date();
  return Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
}

type ResolvedRange = { start: number; end: number }; // inclusive UTC midnights

// The calendar window as a pair of days. A bare year or `YYYY-MM` string
// expands to that period; a tuple is used as given; nothing falls back to the
// trailing year ending on the latest day in the data (or today, with no data).
function resolveRange(range: HeatmapRange | undefined, data: HeatmapDatum[]): ResolvedRange {
  if (typeof range === "number") {
    return { start: Date.UTC(range, 0, 1), end: Date.UTC(range, 11, 31) };
  }
  if (typeof range === "string") {
    const [y, m] = range.split("-").map(Number);
    if (y && m) return { start: Date.UTC(y, m - 1, 1), end: Date.UTC(y, m, 0) };
    if (y) return { start: Date.UTC(y, 0, 1), end: Date.UTC(y, 11, 31) };
  }
  if (Array.isArray(range)) {
    const start = parseIsoDate(range[0]);
    const end = parseIsoDate(range[1]);
    if (!Number.isNaN(start) && !Number.isNaN(end) && start <= end) return { start, end };
  }

  let latest = Number.NEGATIVE_INFINITY;
  for (const datum of data) {
    const ms = parseIsoDate(datum.date);
    if (!Number.isNaN(ms) && ms > latest) latest = ms;
  }
  const end = Number.isFinite(latest) ? latest : todayUtc();
  return { start: end - (TRAILING_YEAR_DAYS - 1) * DAY_MS, end };
}

// ─────────────────────────────────────────────────────────────────────────────
// Grid model — every day in the range, in order, with its value, its intensity
// level, and its week column. Built once per data/range change and shared by
// the option builder, the tooltip, the click handler, and the intro stagger.
// ─────────────────────────────────────────────────────────────────────────────

type GridCell = {
  date: string; // ISO calendar date
  value: number; // 0 for days absent from the data
  level: number; // 0 = empty, 1..levels = ramp intensity
  column: number; // week column, 0-based from the range start
};

type GridModel = {
  cells: GridCell[];
  weeks: number; // number of week columns — matches ECharts' own calendar math
  levels: number; // number of non-empty intensity levels
  range: ResolvedRange;
};

// Ascending level boundaries for `levels` buckets above empty. Quantile cuts
// share the non-zero values evenly across the ramp (GitHub's approach), so a
// few huge days don't wash out the rest; linear cuts divide min→max evenly.
function computeThresholds(values: number[], levels: number, scale: HeatmapScale): number[] {
  const cuts = levels - 1;
  if (cuts <= 0) return [];
  const nonZero = values.filter((value) => value > 0).sort((a, b) => a - b);
  if (nonZero.length === 0) return Array.from({ length: cuts }, (_, i) => i + 1);

  if (scale === "linear") {
    const min = nonZero[0];
    const max = nonZero[nonZero.length - 1];
    if (max === min) return Array.from({ length: cuts }, () => max + 1);
    return Array.from({ length: cuts }, (_, i) => min + ((max - min) * (i + 1)) / levels);
  }

  return Array.from({ length: cuts }, (_, i) => {
    const position = ((i + 1) / levels) * (nonZero.length - 1);
    const lower = Math.floor(position);
    const upper = Math.min(nonZero.length - 1, lower + 1);
    const fraction = position - lower;
    return nonZero[lower] + (nonZero[upper] - nonZero[lower]) * fraction;
  });
}

function levelOf(value: number, thresholds: number[]): number {
  if (value <= 0) return 0;
  let level = 1;
  for (const threshold of thresholds) {
    if (value >= threshold) level += 1;
  }
  return level;
}

function buildGrid(
  data: HeatmapDatum[],
  range: ResolvedRange,
  weekStart: WeekStart,
  levels: number,
  scale: HeatmapScale,
  thresholds: number[] | undefined,
): GridModel {
  const firstDay = weekStart === "monday" ? 1 : 0;
  const valueByDate = new Map<string, number>();
  for (const datum of data) {
    const ms = parseIsoDate(datum.date);
    if (Number.isNaN(ms) || ms < range.start || ms > range.end) continue;
    valueByDate.set(toIsoDate(ms), (valueByDate.get(toIsoDate(ms)) ?? 0) + datum.value);
  }

  const cuts =
    thresholds && thresholds.length > 0
      ? [...thresholds].sort((a, b) => a - b)
      : computeThresholds([...valueByDate.values()], levels, scale);
  const effectiveLevels = cuts.length + 1;

  const startOffset =
    (new Date(range.start).getUTCDay() - firstDay + DAYS_PER_WEEK) % DAYS_PER_WEEK;
  const days = Math.round((range.end - range.start) / DAY_MS) + 1;
  const weeks = Math.floor((days + startOffset + DAYS_PER_WEEK - 1) / DAYS_PER_WEEK);

  const cells: GridCell[] = [];
  for (let i = 0; i < days; i++) {
    const date = toIsoDate(range.start + i * DAY_MS);
    const value = valueByDate.get(date) ?? 0;
    cells.push({
      date,
      value,
      level: Math.min(effectiveLevels, levelOf(value, cuts)),
      column: Math.floor((i + startOffset) / DAYS_PER_WEEK),
    });
  }

  return { cells, weeks, levels: effectiveLevels, range };
}

// The ramp color for a level — spreads the levels across however many colors
// the config provides, so a 3-color ramp still serves 5 levels sensibly.
function rampColor(slots: string[], level: number, levels: number): string {
  if (slots.length === 0) return GRAY;
  if (levels <= 1 || slots.length === 1) return slots[slots.length - 1];
  const index = Math.round(((level - 1) / (levels - 1)) * (slots.length - 1));
  return slots[Math.max(0, Math.min(slots.length - 1, index))];
}

// ─────────────────────────────────────────────────────────────────────────────
// Layout — the calendar rect is sized from the container, not the other way
// round: square cells that fill the width (capped by the height), the grid
// centred vertically in whatever is left.
// ─────────────────────────────────────────────────────────────────────────────

type Layout = {
  cellSize: number;
  left: number;
  top: number;
};

function computeLayout(
  width: number,
  height: number,
  weeks: number,
  fixedCellSize: number | undefined,
  hasDayLabels: boolean,
  hasMonthLabels: boolean,
): Layout {
  const left = EDGE_PADDING + (hasDayLabels ? DAY_LABEL_WIDTH : 0);
  const labelTop = EDGE_PADDING + (hasMonthLabels ? MONTH_LABEL_HEIGHT : 0);

  let cellSize = fixedCellSize ?? FALLBACK_CELL_SIZE;
  if (fixedCellSize === undefined && width > 0 && weeks > 0) {
    const fitWidth = Math.floor((width - left - EDGE_PADDING) / weeks);
    const fitHeight =
      height > 0
        ? Math.floor((height - labelTop - EDGE_PADDING) / DAYS_PER_WEEK)
        : Number.POSITIVE_INFINITY;
    cellSize = Math.max(MIN_CELL_SIZE, Math.min(fitWidth, fitHeight));
  }

  const gridHeight = cellSize * DAYS_PER_WEEK;
  const spare = height > 0 ? height - labelTop - EDGE_PADDING - gridHeight : 0;
  const top = labelTop + Math.max(0, Math.floor(spare / 2));

  return { cellSize, left, top };
}

// ─────────────────────────────────────────────────────────────────────────────
// Loading skeleton helper — a hard clip window swept across the grid. `floor`
// keeps the grid faintly visible between sweeps; `peak` is the bright band.
// `center` may run outside [0, 1] so the window fully enters and exits.
// ─────────────────────────────────────────────────────────────────────────────

function shimmerWindowStops(center: number, color: string, floor: number, peak: number) {
  const half = LOADING_SHIMMER_BAND;
  const feather = LOADING_SHIMMER_FEATHER;

  const alphaAt = (x: number) => {
    const dist = Math.abs(x - center);
    if (dist <= half - feather) return peak;
    if (dist >= half) return floor;
    // Sine-eased falloff — a linear ramp still reads as a hard cut.
    const eased = Math.sin(((1 - (dist - (half - feather)) / feather) * Math.PI) / 2);
    return floor + (peak - floor) * eased;
  };

  const offsets = [
    0,
    center - half,
    center - half + feather,
    center,
    center + half - feather,
    center + half,
    1,
  ]
    .filter((x) => x >= 0 && x <= 1)
    .sort((a, b) => a - b);

  const stops: { offset: number; color: string }[] = [];
  for (const offset of offsets) {
    if (stops.length === 0 || offset - stops[stops.length - 1].offset > 1e-4) {
      stops.push({ offset, color: withAlpha(color, alphaAt(offset)) });
    }
  }
  return stops;
}

// ─────────────────────────────────────────────────────────────────────────────
// Option builders — pure functions from a snapshot context to ECharts option
// fragments. The component reads its refs ONCE per build into this context;
// nothing below touches React state or the chart instance.
// ─────────────────────────────────────────────────────────────────────────────

type OptionBuildContext = {
  config: ChartConfig;
  grid: GridModel;
  cell: CellSlot;
  monthLabel: MonthLabelSlot | null;
  dayLabel: DayLabelSlot | null;
  tooltipSlot: TooltipSlot;
  weekStart: WeekStart;
  layout: Layout;
  selectedDate: string | null;
  isLoading: boolean;
  revealEnabled: boolean; // play the intro sweep on this push
  resolved: ResolvedColors;
};

function buildCalendarOption(ctx: OptionBuildContext): CalendarComponentOption {
  const { grid, monthLabel, dayLabel, weekStart, layout, resolved } = ctx;
  const { tokens } = resolved;

  return {
    id: "__calendar",
    left: layout.left,
    top: layout.top,
    cellSize: [layout.cellSize, layout.cellSize],
    range: [toIsoDate(grid.range.start), toIsoDate(grid.range.end)],
    orient: "horizontal",
    // The calendar draws nothing of its own — no grid lines, no cell backdrops.
    // Empty days are painted by the custom series so they share the cells'
    // gap and radius.
    splitLine: { show: false },
    itemStyle: { color: "transparent", borderWidth: 0 },
    yearLabel: { show: false },
    monthLabel: {
      show: monthLabel !== null,
      nameMap: monthLabel?.labels ?? DEFAULT_MONTH_LABELS,
      position: "start",
      align: "left",
      margin: LABEL_MARGIN,
      color: tokens.mutedForeground,
      fontSize: 11,
    },
    dayLabel: {
      show: dayLabel !== null,
      firstDay: weekStart === "monday" ? 1 : 0,
      nameMap: dayLabel?.labels ?? DEFAULT_DAY_LABELS,
      position: "start",
      margin: LABEL_MARGIN,
      color: tokens.mutedForeground,
      fontSize: 10,
    },
  };
}

function buildCellSeries(ctx: OptionBuildContext): CustomSeriesOption {
  const { grid, cell, selectedDate, resolved, revealEnabled } = ctx;
  const { tokens } = resolved;
  const slots = resolved.series[cell.dataKey] ?? [];
  const emptyFill = withAlpha(tokens.foreground, EMPTY_CELL_OPACITY);
  const hoverRing = withAlpha(tokens.foreground, HOVER_RING_OPACITY);
  const selectedRing = withAlpha(tokens.foreground, SELECTED_RING_OPACITY);
  const hasSelection = selectedDate !== null;

  const renderItem = (params: RenderItemParams, api: RenderItemApi): RenderItemReturn => {
    const item = grid.cells[params.dataIndex];
    if (!item) return undefined;

    const coordSys = params.coordSys as { cellWidth?: number; cellHeight?: number };
    const cellWidth = coordSys.cellWidth ?? ctx.layout.cellSize;
    const cellHeight = coordSys.cellHeight ?? ctx.layout.cellSize;
    const [cx, cy] = api.coord([api.value(0)]);
    // The gap is carved out of the cell on every side, so the pitch stays
    // exactly one calendar cell and the cell never grows past its slot.
    const gap = Math.min(cell.gap, Math.min(cellWidth, cellHeight) - 1);
    const width = Math.max(1, cellWidth - gap);
    const height = Math.max(1, cellHeight - gap);
    const radius = Math.min(cell.radius, Math.min(width, height) / 2);

    const selected = hasSelection && item.date === selectedDate;
    const dimmed = hasSelection && !selected;
    const fill = item.level === 0 ? emptyFill : rampColor(slots, item.level, grid.levels);

    return {
      type: "rect",
      shape: { x: cx - width / 2, y: cy - height / 2, width, height, r: radius },
      // The selected cell keeps full strength and wears a ring; the rest soften.
      style: {
        fill,
        opacity: dimmed ? CELL_DIM_OPACITY : 1,
        stroke: selected ? selectedRing : "transparent",
        lineWidth: selected ? SELECTED_RING_WIDTH : 0,
      },
      // Draw the selected cell last so its ring is never covered by a neighbour.
      z2: selected ? 2 : 1,
      // Scale about the cell's own centre, so the intro pop and the hover ring
      // both grow from the middle instead of the top-left corner.
      originX: cx,
      originY: cy,
      // Selection/theme/resize changes glide rather than snap.
      transition: ["shape", "style"],
      // Intro: the cell opens from a dot and fades in. Only new elements play
      // this — later pushes update the existing rects in place.
      ...(revealEnabled
        ? {
            enterFrom: {
              // `shape` is required by the type; empty means no shape prop tweens.
              shape: {},
              scaleX: INTRO_CELL_SCALE_FROM,
              scaleY: INTRO_CELL_SCALE_FROM,
              style: { opacity: 0 },
            },
          }
        : {}),
      emphasis: {
        style: { stroke: hoverRing, lineWidth: HOVER_RING_WIDTH },
      },
    };
  };

  return {
    id: "__cells",
    type: "custom",
    coordinateSystem: "calendar",
    calendarId: "__calendar",
    z: 3,
    renderItem,
    // ECharts needs the date in the data to place each item; the value rides
    // along for the tooltip's `params.value`.
    data: grid.cells.map((item) => [item.date, item.value]),
    // Left → right sweep: each week column starts a beat after the previous.
    animation: revealEnabled,
    animationDuration: INTRO_CELL_GROW,
    animationEasing: "cubicOut",
    animationDelay: (dataIndex: number) =>
      (grid.cells[dataIndex]?.column ?? 0) * INTRO_COLUMN_STAGGER,
    animationDurationUpdate: UPDATE_DURATION,
    animationEasingUpdate: "cubicOut",
    animationDelayUpdate: 0,
  };
}

// Tooltip HTML builder, closed over the build context. Each hovered cell shows
// the day as the title and a single swatch + label + value row beneath it.
function createTooltipFormatter(ctx: OptionBuildContext) {
  const { config, grid, cell, tooltipSlot, resolved } = ctx;
  const label = config[cell.dataKey]?.label;
  const labelText = typeof label === "string" ? label : cell.dataKey;
  const colorsCount = config[cell.dataKey] ? getColorsCount(config[cell.dataKey]) : 1;
  const slots = resolved.series[cell.dataKey] ?? [];

  // The swatch shows the hovered cell's OWN level color — a `--color-*` var
  // for a ramp level, a foreground wash for an empty day — rather than the
  // whole ramp, which is what the shared indicator would paint.
  const swatchFor = (item: GridCell) => {
    if (item.level === 0) {
      return `<div class="bg-foreground/10 h-2.5 w-2.5 shrink-0 rounded-[2px]"></div>`;
    }
    const slotIndex = Math.max(0, slots.indexOf(rampColor(slots, item.level, grid.levels)));
    const index = Math.min(colorsCount - 1, slotIndex);
    return `<div class="h-2.5 w-2.5 shrink-0 rounded-[2px]" style="background:var(--color-${cell.dataKey}-${index})"></div>`;
  };

  return (params: unknown): string => {
    const p = params as { dataIndex?: number };
    const item = grid.cells[p.dataIndex ?? -1];
    if (!item) return "";

    return tooltipShell({
      label: tooltipSlot.dateFormatter(item.date),
      body: tooltipRow({
        indicatorHtml: swatchFor(item),
        labelText,
        valueText: tooltipSlot.valueFormatter(item.value),
        dimmed: "",
      }),
      roundness: tooltipSlot.roundness,
      variant: tooltipSlot.variant,
    });
  };
}

function buildTooltipOption(ctx: OptionBuildContext): TooltipComponentOption {
  const { tooltipSlot, isLoading } = ctx;
  return {
    show: tooltipSlot.present && !isLoading,
    trigger: "item",
    confine: true,
    backgroundColor: "transparent",
    borderWidth: 0,
    padding: 0,
    extraCssText: "box-shadow:none;",
    displayTransition: false,
    // Item-triggered (cells, no axis), so the position wires straight through
    // resolveTooltipPosition rather than tooltipBaseOption (trigger:"axis" only).
    position: resolveTooltipPosition(tooltipSlot.position),
    formatter: createTooltipFormatter(ctx),
  };
}

// Loading skeleton — the same grid in transparent foreground, invisible until
// the first shimmer tick tints it, so there is no flash before the rAF loop
// positions the sweep.
function buildLoadingOption(ctx: OptionBuildContext): EChartsOption {
  const { grid, cell, resolved } = ctx;
  const transparent = withAlpha(resolved.tokens.foreground, 0);

  const renderItem = (params: RenderItemParams, api: RenderItemApi): RenderItemReturn => {
    const coordSys = params.coordSys as { cellWidth?: number; cellHeight?: number };
    const cellWidth = coordSys.cellWidth ?? ctx.layout.cellSize;
    const cellHeight = coordSys.cellHeight ?? ctx.layout.cellSize;
    const [cx, cy] = api.coord([api.value(0)]);
    const gap = Math.min(cell.gap, Math.min(cellWidth, cellHeight) - 1);
    const width = Math.max(1, cellWidth - gap);
    const height = Math.max(1, cellHeight - gap);
    const radius = Math.min(cell.radius, Math.min(width, height) / 2);
    return {
      type: "rect",
      shape: { x: cx - width / 2, y: cy - height / 2, width, height, r: radius },
      style: { fill: transparent },
      silent: true,
    };
  };

  return {
    animation: false,
    tooltip: { show: false },
    calendar: buildCalendarOption(ctx),
    series: [
      {
        id: "__loading",
        type: "custom",
        coordinateSystem: "calendar",
        calendarId: "__calendar",
        silent: true,
        renderItem,
        data: grid.cells.map((item) => [item.date, 0]),
        animation: false,
      },
    ],
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// Live imperative state — everything the ECharts event handlers, the shimmer
// rAF, and the theme repush read or write OUTSIDE the React render cycle,
// grouped in one ref-stable object. None of it is render output, which is why
// it is not React state.
// ─────────────────────────────────────────────────────────────────────────────

type LiveState = {
  resolved: ResolvedColors | null; // colors read off the live DOM — feeds builds and the shimmer
  selectedDate: string | null; // current selection, kept in step with the React state
  hasRevealed: boolean; // the intro sweep already played on this chart instance
  revealEnabled: boolean; // the current push should create cells with the entrance
  // Latest callbacks/flags for the imperative ECharts click handler.
  handlers: {
    onSelectionChange?: (selection: HeatmapSelection | null) => void;
    isCellClickable: boolean;
    cells: GridCell[];
  };
  // Update-style re-push for paths that bypass React entirely (theme flips,
  // resizes) — set by the sync effect.
  repush: () => void;
};

// ─────────────────────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Calendar heatmap rendered with Apache ECharts — the GitHub contribution graph
 * shape: one square per day, weeks as columns, color intensity from the config's
 * color ramp. The root owns the date window, the value → level mapping, selection
 * state, the loading skeleton, and the intro sweep; the visual parts — `<Cell>`,
 * `<MonthLabel>`, `<DayLabel>`, `<Tooltip>`, `<Legend>` — are composed as
 * declarative children that render nothing. The root walks those children by
 * reference and drives a single imperative ECharts instance. Fully self-contained:
 * its only dependencies are `react`, `echarts`, and `motion`.
 */
export function EChartsHeatmapChart({
  data,
  config,
  children,
  className,
  renderer = DEFAULT_ECHARTS_RENDERER,
  range,
  weekStart = "sunday",
  cellSize,
  scale = "quantile",
  thresholds,
  defaultSelectedDate = null,
  onSelectionChange,
  isLoading = false,
  animation = true,
  animationType = "default",
  chartOptions,
}: EChartsHeatmapChartProps) {
  const rawId = useId();
  const chartId = `chart-${rawId.replace(/:/g, "")}`;

  const containerRef = useRef<HTMLDivElement>(null);
  const mountRef = useRef<HTMLDivElement>(null);
  const echartsRef = useRef<EChartsInstance | null>(null);

  // The single imperative surface (see LiveState). Object identity is stable
  // for the component's lifetime.
  const live = useRef<LiveState>({
    resolved: null,
    selectedDate: defaultSelectedDate,
    hasRevealed: false,
    revealEnabled: false,
    handlers: {
      onSelectionChange,
      isCellClickable: false,
      cells: [],
    },
    repush: () => {},
  }).current;

  const shouldReduceMotion = useReducedMotion();

  const [selectedDate, setSelectedDate] = useState<string | null>(defaultSelectedDate);

  // ── Declarative config, collected from children by reference ─────────────────
  const collected = useMemo(() => collectConfig(children), [children]);
  const { cell, monthLabel, dayLabel, tooltip: tooltipSlot, legend } = collected;

  const css = useMemo(() => buildChartCss(chartId, config), [chartId, config]);

  // The ramp has as many levels as the config has colors for the cell's key.
  const seriesKeys = useMemo(() => (cell.dataKey ? [cell.dataKey] : []), [cell.dataKey]);
  const colorsCount = config[cell.dataKey] ? getColorsCount(config[cell.dataKey]) : 1;

  const grid = useMemo(
    () => buildGrid(data, resolveRange(range, data), weekStart, colorsCount, scale, thresholds),
    [data, range, weekStart, colorsCount, scale, thresholds],
  );

  // Refresh the click handler's snapshot of the latest callbacks/flags every render.
  live.handlers = {
    onSelectionChange,
    isCellClickable: cell.isClickable,
    cells: grid.cells,
  };

  // The next selection is derived from the ref, not inside the state updater:
  // React may run an updater more than once, and the callback must fire once.
  const toggleSelection = useCallback(
    (date: string) => {
      const next = live.selectedDate === date ? null : date;
      live.selectedDate = next;
      setSelectedDate(next);
      const { onSelectionChange: cb, cells } = live.handlers;
      if (next === null) {
        cb?.(null);
      } else {
        const item = cells.find((candidate) => candidate.date === next);
        cb?.({ date: next, value: item?.value ?? 0 });
      }
    },
    [live],
  );

  // ── Option builder ───────────────────────────────────────────────────────────
  // Thin orchestrator over the pure builders above: snapshot the imperative
  // surface into an OptionBuildContext, then assemble.
  const buildOption = useCallback((): EChartsOption => {
    const resolved = live.resolved;
    const chart = echartsRef.current;
    if (!resolved || !chart) return {};

    const layout = computeLayout(
      chart.getWidth(),
      chart.getHeight(),
      grid.weeks,
      cellSize,
      dayLabel !== null,
      monthLabel !== null,
    );

    const ctx: OptionBuildContext = {
      config,
      grid,
      cell,
      monthLabel,
      dayLabel,
      tooltipSlot,
      weekStart,
      layout,
      selectedDate,
      isLoading,
      revealEnabled: live.revealEnabled,
      resolved,
    };

    if (isLoading) return buildLoadingOption(ctx);

    return {
      animation: true,
      calendar: buildCalendarOption(ctx),
      tooltip: buildTooltipOption(ctx),
      series: [buildCellSeries(ctx)],
    };
  }, [
    live,
    config,
    grid,
    cell,
    monthLabel,
    dayLabel,
    tooltipSlot,
    weekStart,
    cellSize,
    selectedDate,
    isLoading,
  ]);

  // ── Init + resize + theme observer (per renderer instance) ───────────────────
  useEffect(() => {
    const mount = mountRef.current;
    const container = containerRef.current;
    if (!mount || !container) return;

    const chart = echarts.init(mount, null, { renderer });
    echartsRef.current = chart;

    const resizeObserver = new ResizeObserver(() => {
      // Observers always fire once right after observe(). Only react when the
      // renderer size actually changed — the cells are sized from it.
      if (mount.clientWidth === chart.getWidth() && mount.clientHeight === chart.getHeight()) {
        return;
      }
      chart.resize();
      live.repush();
    });
    resizeObserver.observe(mount);

    // Light/dark flips change no React state — re-resolve and push directly.
    const themeObserver = new MutationObserver(() => {
      live.repush();
    });
    themeObserver.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    // Clicking a cell toggles its selection, only when <Cell isClickable> is set.
    chart.on("click", (params) => {
      const { isCellClickable, cells } = live.handlers;
      if (!isCellClickable) return;
      const p = params as { seriesId?: string; dataIndex?: number };
      if (p.seriesId !== "__cells") return;
      const item = cells[p.dataIndex ?? -1];
      if (item) toggleSelection(item.date);
    });

    return () => {
      resizeObserver.disconnect();
      themeObserver.disconnect();
      chart.dispose();
      echartsRef.current = null;
      // The reveal guard belongs to the chart instance it guarded. Without this
      // reset, StrictMode's dev-only mount→unmount→remount plays the entrance on
      // the throwaway instance and the surviving one renders without it.
      live.hasRevealed = false;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [renderer]);

  // ── Sync ECharts with props/theme/selection — resolve, build, push ────────────
  useEffect(() => {
    const chart = echartsRef.current;
    const container = containerRef.current;
    if (!chart || !container) return;

    // Colors come from the <style> committed just before this effect ran — read
    // them here, right before the push, rather than round-tripping through state.
    live.resolved = resolveColors(container, config, seriesKeys);

    // Intro sweep, played once per chart instance: ECharts' own enter animation
    // runs on the cells the first time they are created, staggered by week
    // column. Later pushes update those rects in place, so the entrance never
    // replays. A loading cycle re-arms it — the skeleton is a different series,
    // so leaving it creates the cells afresh and the sweep plays again.
    if (isLoading) live.hasRevealed = false;
    const shouldReveal = !live.hasRevealed && !isLoading;
    if (shouldReveal) live.hasRevealed = true;
    live.revealEnabled =
      animation && shouldReveal && animationType !== "none" && !shouldReduceMotion;

    const push = () => {
      const option = buildOption();
      const merged = chartOptions ? { ...option, ...chartOptions } : option;
      // chartOptions is an untyped escape hatch — the spread erases the option's
      // shape, so re-assert it. The only cast in the file.
      chart.setOption(merged as EChartsOption, { notMerge: true });
    };

    push();
    // The entrance belongs to the first push only; anything re-entering through
    // repush (theme, resize) updates cells that already exist.
    live.revealEnabled = false;

    // Theme flips and resizes re-enter here without touching React: re-read the
    // tokens (the .dark class changed, or the renderer resized) and push again.
    live.repush = () => {
      live.resolved = resolveColors(container, config, seriesKeys);
      push();
    };
  }, [
    live,
    buildOption,
    chartOptions,
    isLoading,
    animation,
    animationType,
    shouldReduceMotion,
    config,
    renderer,
    seriesKeys,
  ]);

  // ── Loading shimmer — rAF sweeps a bright band across the skeleton grid ──────
  useEffect(() => {
    const chart = echartsRef.current;
    if (!chart || !isLoading) return;

    let raf = 0;
    const start = performance.now();
    const tick = (now: number) => {
      const phase = ((((now - start) / LOADING_ANIMATION_DURATION) % 1) + 1) % 1;

      // Read tokens per frame, so a theme flip mid-loading retints the shimmer.
      const foreground = live.resolved?.tokens.foreground ?? GRAY;
      const w = chart.getWidth();
      const h = chart.getHeight();
      if (!w || !h) {
        raf = requestAnimationFrame(tick);
        return;
      }
      // Sweep the clip window from fully off-screen left to fully off-screen
      // right, leaned 45°. ABSOLUTE pixel coordinates (global gradient) are
      // shared by every cell, so a column lights up together as the band
      // passes its x-position.
      const maxT = (w + h) / (2 * w);
      const center = phase * (maxT + 2 * LOADING_SHIMMER_BAND) - LOADING_SHIMMER_BAND;
      const fill = new echarts.graphic.LinearGradient(
        0,
        0,
        w,
        w,
        shimmerWindowStops(center, foreground, LOADING_CELL_FLOOR, LOADING_CELL_PEAK),
        true,
      );
      const cells = live.handlers.cells;
      const cellSlot = collected.cell;
      chart.setOption(
        {
          series: [
            {
              id: "__loading",
              renderItem: (params: RenderItemParams, api: RenderItemApi): RenderItemReturn => {
                const coordSys = params.coordSys as { cellWidth?: number; cellHeight?: number };
                const cellWidth = coordSys.cellWidth ?? FALLBACK_CELL_SIZE;
                const cellHeight = coordSys.cellHeight ?? FALLBACK_CELL_SIZE;
                const [cx, cy] = api.coord([api.value(0)]);
                const gap = Math.min(cellSlot.gap, Math.min(cellWidth, cellHeight) - 1);
                const width = Math.max(1, cellWidth - gap);
                const height = Math.max(1, cellHeight - gap);
                const radius = Math.min(cellSlot.radius, Math.min(width, height) / 2);
                return {
                  type: "rect",
                  shape: { x: cx - width / 2, y: cy - height / 2, width, height, r: radius },
                  style: { fill },
                  silent: true,
                };
              },
              data: cells.map((item) => [item.date, 0]),
            },
          ],
        },
        { silent: true, lazyUpdate: true },
      );
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [live, collected.cell, isLoading, renderer]);

  const legendSwatchStyle = { borderRadius: Math.min(cell.radius, 5) };

  return (
    <div
      ref={containerRef}
      data-chart={chartId}
      className={`relative flex flex-col text-xs ${className ?? ""}`}
    >
      <style dangerouslySetInnerHTML={{ __html: css }} />

      <div className="relative min-h-0 w-full flex-1">
        <div ref={mountRef} className="h-full min-h-0 w-full" />
      </div>

      {legend && !isLoading && (
        <div
          className={`text-muted-foreground flex shrink-0 items-center gap-1 pt-2 text-[11px] leading-none ${
            legend.align === "left" ? "justify-start" : "justify-end"
          }`}
        >
          <span className="mr-0.5">{legend.lessLabel}</span>
          <span className="bg-foreground/[0.08] h-2.5 w-2.5 shrink-0" style={legendSwatchStyle} />
          {Array.from({ length: colorsCount }, (_, index) => (
            <span
              key={index}
              className="h-2.5 w-2.5 shrink-0"
              style={{ ...legendSwatchStyle, background: `var(--color-${cell.dataKey}-${index})` }}
            />
          ))}
          <span className="ml-0.5">{legend.moreLabel}</span>
        </div>
      )}

      {isLoading && (
        <div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
          <motion.div
            initial={shouldReduceMotion ? false : { opacity: 0, scale: 0.92 }}
            animate={{ opacity: 1, scale: 1 }}
            transition={{ duration: 0.25, ease: "easeOut" }}
            className="text-primary bg-background flex items-center justify-center gap-2 rounded-md border px-2 py-0.5 text-sm"
          >
            <div className="border-border border-t-primary h-3 w-3 animate-spin rounded-full border" />
            <span>Loading</span>
          </motion.div>
        </div>
      )}
    </div>
  );
}

EChartsHeatmapChart.Cell = Cell;
EChartsHeatmapChart.MonthLabel = MonthLabel;
EChartsHeatmapChart.DayLabel = DayLabel;
EChartsHeatmapChart.Tooltip = Tooltip;
EChartsHeatmapChart.Legend = Legend;

```
        
      
      
        ### Add the shared chart module.
        
          Create a `ui` folder inside `evilcharts` and paste this one in first — it resolves your config's colors from the page's CSS variables, and every sub-component below imports from it.
        
        
          ### components/evilcharts/ui/echarts-chart.tsx

```tsx
import { CanvasRenderer, SVGRenderer } from "echarts/renderers";
import type { ComponentType, ReactNode } from "react";
import * as echarts from "echarts/core";

export const ECHARTS_RENDERERS = {
  canvas: "canvas",
  svg: "svg",
} as const;

export type EChartsRenderer = (typeof ECHARTS_RENDERERS)[keyof typeof ECHARTS_RENDERERS];

export const DEFAULT_ECHARTS_RENDERER = ECHARTS_RENDERERS.canvas;

// Renderer registration is shared by every modular ECharts chart. Individual
// chart modules only register the series and components they use.
echarts.use([CanvasRenderer, SVGRenderer]);

// ─────────────────────────────────────────────────────────────────────────────
// Theme keys + config — replicated from the repo's <ChartStyle> so the ECharts
// charts stay self-contained (no recharts ui imports). Shared by every ECharts
// chart and the tooltip/legend/brush ui modules.
// ─────────────────────────────────────────────────────────────────────────────

// Theme selectors mirror the repo's <ChartStyle>: light is the bare root, dark is `.dark`.
export const THEMES = { light: "", dark: ".dark" } as const;
export type ThemeKey = keyof typeof THEMES;
export const THEME_KEYS = Object.keys(THEMES) as ThemeKey[];

// Require at least one theme key — identical constraint to the repo's ChartConfig.
export type AtLeastOneThemeColor =
  | { light: string[]; dark?: string[] }
  | { light?: string[]; dark: string[] };

export type ChartConfig = Record<
  string,
  {
    label?: ReactNode;
    icon?: ComponentType;
    colors?: AtLeastOneThemeColor;
  }
>;

// ─────────────────────────────────────────────────────────────────────────────
// Color plumbing — replicated from the repo's <ChartStyle> so the charts stay
// self-contained (no @/components/evilcharts/ui/recharts imports).
// ─────────────────────────────────────────────────────────────────────────────

// Max slots a key needs = longest color array across themes (min 1). Both themes
// always emit the same number of `--color-{key}-{n}` vars.
export function getColorsCount(item: ChartConfig[string]): number {
  if (!item.colors) return 1;
  const counts = THEME_KEYS.map((theme) => item.colors?.[theme]?.length ?? 0);
  return Math.max(...counts, 1);
}

// Distribute colors evenly across slots; extra slots go to the LAST color(s).
// 2 colors / 4 slots → [c0, c0, c1, c1]; 3 colors / 4 slots → [c0, c1, c2, c2].
export function distributeColors(colors: string[], maxCount: number): string[] {
  const available = colors.length;
  if (available >= maxCount) return colors.slice(0, maxCount);

  const result: string[] = [];
  const baseSlots = Math.floor(maxCount / available);
  const extraSlots = maxCount % available;

  for (let i = 0; i < available; i++) {
    const isExtra = i >= available - extraSlots;
    const slots = baseSlots + (isExtra ? 1 : 0);
    for (let j = 0; j < slots; j++) result.push(colors[i]);
  }

  return result;
}

// Emits the same CSS <ChartStyle> would: `--color-{key}-{n}` scoped to
// `[data-chart={id}]` (light) and `.dark [data-chart={id}]` (dark).
export function buildChartCss(id: string, config: ChartConfig): string {
  const colorConfig = Object.entries(config).filter(([, item]) => item.colors);
  if (!colorConfig.length) return "";

  const varsFor = (theme: ThemeKey) =>
    colorConfig
      .flatMap(([key, item]) => {
        const authored = item.colors?.[theme];
        if (!authored || authored.length === 0) return [];
        return distributeColors(authored, getColorsCount(item)).map(
          (color, index) => `  --color-${key}-${index}: ${color};`,
        );
      })
      .join("\n");

  return Object.entries(THEMES)
    .map(([theme, prefix]) => `${prefix} [data-chart=${id}] {\n${varsFor(theme as ThemeKey)}\n}`)
    .join("\n");
}

// A single reusable 1×1 canvas normalizes ANY CSS color (hex, named, oklch, …)
// to a concrete rgba string by painting it and reading the pixel back.
let normalizerCtx: CanvasRenderingContext2D | null = null;
export function normalizeColor(value: string): string {
  const raw = value.trim();
  if (!raw || typeof document === "undefined") return raw;

  if (!normalizerCtx) {
    const canvas = document.createElement("canvas");
    canvas.width = 1;
    canvas.height = 1;
    normalizerCtx = canvas.getContext("2d", { willReadFrequently: true });
  }
  if (!normalizerCtx) return raw;

  normalizerCtx.clearRect(0, 0, 1, 1);
  normalizerCtx.fillStyle = "#000";
  normalizerCtx.fillStyle = raw; // invalid values leave the sentinel in place
  normalizerCtx.fillRect(0, 0, 1, 1);
  const [r, g, b, a] = normalizerCtx.getImageData(0, 0, 1, 1).data;
  return `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
}

// Scales the alpha of a normalized `rgba(r, g, b, a)` string. Multiplying (not
// replacing) keeps translucent theme tokens honest: a border that is 10%-white
// at `withAlpha(border, 0.5)` lands at 5%, matching Tailwind's `border/50`.
export function withAlpha(color: string, alpha: number): string {
  const match = color.match(/rgba?\(([^)]+)\)/);
  if (!match) return color;
  const [r, g, b, a] = match[1].split(",").map((p) => p.trim());
  const base = a === undefined ? 1 : Number.parseFloat(a) || 0;
  return `rgba(${r}, ${g}, ${b}, ${(base * alpha).toFixed(3)})`;
}

export type ResolvedColors = {
  series: Record<string, string[]>; // normalized `--color-{key}-{n}` slots per key
  tokens: {
    mutedForeground: string;
    border: string;
    foreground: string;
    background: string;
  };
};

// Reads the injected CSS vars + theme tokens from the live DOM. Series slots come
// from `getComputedStyle` on the container; tokens are read off a throwaway probe
// carrying the matching Tailwind class (robust to the var naming a theme uses).
export function resolveColors(
  container: HTMLElement,
  config: ChartConfig,
  seriesKeys: string[],
): ResolvedColors {
  const computed = getComputedStyle(container);
  const series: Record<string, string[]> = {};

  for (const key of seriesKeys) {
    const count = getColorsCount(config[key] ?? {});
    const slots: string[] = [];
    for (let n = 0; n < count; n++) {
      const raw = computed.getPropertyValue(`--color-${key}-${n}`).trim();
      slots.push(raw ? normalizeColor(raw) : "rgba(120, 120, 120, 1)");
    }
    series[key] = slots;
  }

  const probe = document.createElement("span");
  probe.style.cssText = "position:absolute;width:0;height:0;visibility:hidden;pointer-events:none;";
  container.appendChild(probe);
  const readToken = (className: string) => {
    probe.className = className;
    return normalizeColor(getComputedStyle(probe).color);
  };
  const tokens = {
    mutedForeground: readToken("text-muted-foreground"),
    border: readToken("text-border"),
    foreground: readToken("text-foreground"),
    background: readToken("text-background"),
  };
  container.removeChild(probe);

  return { series, tokens };
}

// Horizontal multi-stop color for a series — a solid string when there is only
// one color, else an evenly-distributed left→right LinearGradient. Reused for the
// stroke, symbol fills, and as the base tint for the area fill.
export function seriesPaint(slots: string[]): string | echarts.graphic.LinearGradient {
  if (slots.length <= 1) return slots[0] ?? "rgba(120, 120, 120, 1)";
  const stops = slots.map((color, i) => ({ offset: i / (slots.length - 1), color }));
  return new echarts.graphic.LinearGradient(0, 0, 1, 0, stops);
}

// Solid var / gradient of vars for a series indicator — mirrors getIndicatorColorStyle.
// Used by BOTH the tooltip rows and the legend indicators.
export function indicatorBackground(key: string, colorsCount: number): string {
  if (colorsCount <= 1) return `var(--color-${key}-0)`;
  const stops = Array.from({ length: colorsCount }, (_, i) => {
    const offset = (i / (colorsCount - 1)) * 100;
    return `var(--color-${key}-${i}) ${offset}%`;
  }).join(", ");
  return `linear-gradient(to right, ${stops})`;
}

// Composites a translucent color over an opaque base into a FLAT color. The
// tick dots need this: a translucent stroke double-paints where its round caps
// overlap the line body, which reads as two stacked colors.
export function flattenColor(color: string, base: string): string {
  const parse = (value: string) =>
    value
      .match(/rgba?\(([^)]+)\)/)?.[1]
      .split(",")
      .map((part) => Number.parseFloat(part)) ?? [0, 0, 0, 1];
  const [r, g, b, a = 1] = parse(color);
  const [baseR, baseG, baseB] = parse(base);
  const mix = (channel: number, baseChannel: number) =>
    Math.round(channel * a + baseChannel * (1 - a));
  return `rgb(${mix(r, baseR)}, ${mix(g, baseG)}, ${mix(b, baseB)})`;
}

```
        
      
      
        ### Add the sub-component.
        
          Create `echarts-tooltip.tsx` in the same `ui` folder and paste the tooltip surface and its variants there.
        
        
          ### components/evilcharts/ui/echarts-tooltip.tsx

```tsx
import { indicatorBackground, type ResolvedColors } from "@/components/evilcharts/ui/echarts-chart";
import type { TooltipComponentOption } from "echarts/components";

// ─────────────────────────────────────────────────────────────────────────────
// Tooltip — the shared HTML shell/row primitives and the chart-agnostic option
// fields. The tooltip DOM lives inside `[data-chart={id}]`, so the injected
// `--color-*` vars and Tailwind classes resolve directly (no color read). Each
// chart composes its own rows but shares the shell/styling and base option.
// ─────────────────────────────────────────────────────────────────────────────

export type TooltipVariant = "default" | "frosted-glass";
export type TooltipRoundness = "sm" | "md" | "lg" | "xl";
// Tooltip anchoring: "variable" follows both axes (ECharts default, current
// behavior); "fixed" tracks the pointer's X (centered) but stays pinned near
// the top (fixed Y).
export type TooltipPosition = "fixed" | "variable";

export const roundnessClass: Record<TooltipRoundness, string> = {
  sm: "rounded-sm",
  md: "rounded-md",
  lg: "rounded-lg",
  xl: "rounded-xl",
};

export const tooltipVariantClass: Record<TooltipVariant, string> = {
  default: "bg-background",
  "frosted-glass": "bg-background/50 backdrop-blur-md",
};

// ECharts inserts formatter strings as HTML. Encode plain text and quoted
// attribute values at their interpolation sites, while keeping the chart's
// own shell/row markup intact.
export function escapeTooltipHtml(value: string): string {
  return value
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

// The standard series indicator swatch — a rounded square filled with the
// series' solid var or multi-stop gradient (indicatorBackground). A chart drops
// this into a tooltipRow's `indicatorHtml`.
export function tooltipIndicatorHtml(key: string, colorsCount: number): string {
  const background = escapeTooltipHtml(indicatorBackground(key, colorsCount));
  return `<div class="h-2.5 w-2.5 shrink-0 rounded-[2px]" style="background:${background}"></div>`;
}

// One tooltip row: indicator swatch + label/value pair. `dimmed` is a class
// fragment (e.g. " opacity-30") appended to the row so the selection/hover dim
// stays byte-identical to the inlined markup. `indicatorHtml` must be trusted
// chart-generated markup; labelText and valueText are always treated as text.
export function tooltipRow({
  indicatorHtml,
  labelText,
  valueText,
  dimmed,
}: {
  indicatorHtml: string;
  labelText: string;
  valueText: string;
  dimmed: string;
}): string {
  return `<div class="flex w-full flex-wrap items-center gap-2${escapeTooltipHtml(dimmed)}">
          ${indicatorHtml}
          <div class="flex flex-1 items-center justify-between gap-4 leading-none">
            <span class="text-muted-foreground">${escapeTooltipHtml(labelText)}</span>
            <span class="text-foreground font-mono font-medium tabular-nums">${escapeTooltipHtml(valueText)}</span>
          </div>
        </div>`;
}

// The outer tooltip surface — border, padding, shadow, roundness + variant
// classes — wrapping the axis label and the composed rows. `body` must be
// trusted chart-generated markup (for example, composed tooltipRow results).
export function tooltipShell({
  label,
  body,
  roundness,
  variant,
}: {
  label: string;
  body: string;
  roundness: TooltipRoundness;
  variant: TooltipVariant;
}): string {
  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 ${roundnessClass[roundness]} ${tooltipVariantClass[variant]}">
      <div class="font-medium text-primary">${escapeTooltipHtml(label)}</div>
      <div class="grid gap-1.5">${body}</div>
    </div>`;
}

// Maps the TooltipPosition prop onto the ECharts tooltip `position` field.
// "variable" → undefined (default follow-both-axes, current behavior); "fixed" →
// a callback that centers the tooltip on the pointer's X but pins it near the
// top (fixed Y).
export function resolveTooltipPosition(
  position: TooltipPosition,
): TooltipComponentOption["position"] {
  if (position === "variable") return undefined;
  return (point, _params, _dom, _rect, size) => [point[0] - size.contentSize[0] / 2, 8];
}

// The chart-agnostic tooltip option fields (show, trigger, confine,
// background/border/padding/extraCssText, axisPointer, position). The chart
// supplies only `formatter` and spreads this in. `axisPointerColor` is the
// pre-resolved cursor-line color, so this helper needs no live token read.
export function tooltipBaseOption(params: {
  present: boolean;
  cursor: boolean;
  tokens: ResolvedColors["tokens"];
  position: TooltipPosition;
  axisPointerColor: string;
  strokeWidth: number;
}): TooltipComponentOption {
  const { present, cursor, position, axisPointerColor, strokeWidth } = params;

  return {
    show: present,
    trigger: "axis",
    confine: true,
    displayTransition: false,
    backgroundColor: "transparent",
    borderWidth: 0,
    padding: 0,
    extraCssText: "box-shadow:none;",
    axisPointer: cursor
      ? {
          type: "line",
          lineStyle: {
            color: axisPointerColor,
            width: strokeWidth,
            type: [3, 3] as [number, number],
          },
        }
      : { type: "none" },
    position: resolveTooltipPosition(position),
  };
}

```
        
      
    
  


## Usage

The heatmap is the GitHub contribution graph shape: a calendar year laid out as week columns, one square per day, with color intensity standing in for the value. `<EChartsHeatmapChart>` is the container, and every part hangs off it as a compound member — `<EChartsHeatmapChart.Cell>`, `<EChartsHeatmapChart.MonthLabel>`, `<EChartsHeatmapChart.DayLabel>`, `<EChartsHeatmapChart.Tooltip>`, and `<EChartsHeatmapChart.Legend>` — so a single import gives you the whole chart. The cells are intrinsic to the data, so `<Cell>` always renders and just configures the grid; the labels, tooltip, and legend follow presence semantics — omit one and it does not render.

```tsx
import { EChartsHeatmapChart, type ChartConfig, type HeatmapDatum } from "@/components/evilcharts/charts/echarts-heatmap-chart";
```

```tsx
// One entry per day that has a value. Days you leave out render as empty cells.
const data: HeatmapDatum[] = [
  { date: "2025-01-06", value: 3 },
  { date: "2025-01-07", value: 11 },
  { date: "2025-01-09", value: 1 },
  // ...
];

// The color array IS the intensity ramp — lightest first, strongest last.
const chartConfig = {
  contributions: {
    label: "Contributions",
    colors: {
      light: ["#9be9a8", "#40c463", "#30a14e", "#216e39"],
      dark: ["#0e4429", "#006d32", "#26a641", "#39d353"],
    },
  },
} satisfies ChartConfig;

<EChartsHeatmapChart className="h-48" data={data} config={chartConfig}>
  <EChartsHeatmapChart.Cell dataKey="contributions" />
  <EChartsHeatmapChart.MonthLabel />
  <EChartsHeatmapChart.DayLabel />
  <EChartsHeatmapChart.Tooltip />
  <EChartsHeatmapChart.Legend />
</EChartsHeatmapChart>
```

Under the hood these compound children are declarative configuration slots rather than visual React DOM nodes. The root reads their props and compiles an ECharts option — a `calendar` coordinate system for the date math, and a custom series for the cells so they can carry a gap, rounded corners, and a per-day color — which ECharts paints with Canvas by default or SVG when `renderer="svg"`.

`config` is the same contract as every EvilCharts chart — a key maps to a `label` and a per-theme `colors` array. See [Chart Config](/docs/chart-config) for the full shape. Here the array does double duty: its length is the number of intensity levels, and each entry is that level's color. Colors resolve from CSS variables at runtime, so dark mode just works.

> 
  
    With no `range`, the chart shows the trailing year ending on the latest date in `data`, so a
    feed of recent activity lays out the way GitHub does without any date math on your side.
  


### SVG Renderer

Pass `renderer="svg"` to the chart root to opt into ECharts' SVG renderer. Omit it to use the default Canvas renderer.

```tsx
"use client";

import {
  EChartsHeatmapChart,
  type ChartConfig,
  type HeatmapDatum,
} from "@/components/evilcharts/charts/echarts-heatmap-chart";

function buildContributions(seed: number): HeatmapDatum[] {
  let state = seed;
  const random = () => {
    state = (state * 1664525 + 1013904223) % 4294967296;
    return state / 4294967296;
  };
  const end = Date.UTC(2025, 11, 31);
  const data: HeatmapDatum[] = [];
  for (let i = 364; i >= 0; i--) {
    const day = new Date(end - i * 86_400_000);
    const weekend = day.getUTCDay() === 0 || day.getUTCDay() === 6;
    if (random() >= (weekend ? 0.3 : 0.8)) continue;
    data.push({
      date: day.toISOString().slice(0, 10),
      value: Math.round(random() * (weekend ? 6 : 14)) + 1,
    });
  }
  return data;
}

const data = buildContributions(11);

const chartConfig = {
  contributions: {
    label: "Contributions",
    colors: {
      light: ["#9be9a8", "#40c463", "#30a14e", "#216e39"],
      dark: ["#0e4429", "#006d32", "#26a641", "#39d353"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleHeatmapChart() {
  return (
    <EChartsHeatmapChart
      className="h-full w-full p-4"
      data={data}
      config={chartConfig}
      renderer="svg" // [!code highlight]
    >
      <EChartsHeatmapChart.Cell dataKey="contributions" />
      <EChartsHeatmapChart.MonthLabel />
      <EChartsHeatmapChart.DayLabel />
      <EChartsHeatmapChart.Tooltip />
      <EChartsHeatmapChart.Legend />
    </EChartsHeatmapChart>
  );
}

```

### Interactive Selection

Set `isClickable` on `<Cell>` to make days selectable. The selected day keeps full strength and wears a ring while the rest soften. Handle selection events with the root's `onSelectionChange` callback:

### isClickable

```tsx
"use client";

import {
  EChartsHeatmapChart,
  type ChartConfig,
  type HeatmapDatum,
  type HeatmapSelection,
} from "@/components/evilcharts/charts/echarts-heatmap-chart";
import { useState } from "react";

function buildContributions(seed: number): HeatmapDatum[] {
  let state = seed;
  const random = () => {
    state = (state * 1664525 + 1013904223) % 4294967296;
    return state / 4294967296;
  };
  const end = Date.UTC(2025, 11, 31);
  const data: HeatmapDatum[] = [];
  for (let i = 364; i >= 0; i--) {
    const day = new Date(end - i * 86_400_000);
    const weekend = day.getUTCDay() === 0 || day.getUTCDay() === 6;
    if (random() >= (weekend ? 0.3 : 0.8)) continue;
    data.push({
      date: day.toISOString().slice(0, 10),
      value: Math.round(random() * (weekend ? 6 : 14)) + 1,
    });
  }
  return data;
}

const data = buildContributions(3);

const chartConfig = {
  contributions: {
    label: "Contributions",
    colors: {
      light: ["#9be9a8", "#40c463", "#30a14e", "#216e39"],
      dark: ["#0e4429", "#006d32", "#26a641", "#39d353"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleHeatmapChart() {
  const [selection, setSelection] = useState<HeatmapSelection | null>(null);

  return (
    <div className="flex h-full w-full flex-col p-4">
      <EChartsHeatmapChart
        className="min-h-0 w-full flex-1"
        data={data}
        config={chartConfig}
        onSelectionChange={setSelection} // [!code highlight]
      >
        <EChartsHeatmapChart.Cell dataKey="contributions" isClickable /> {/* [!code highlight] */}
        <EChartsHeatmapChart.MonthLabel />
        <EChartsHeatmapChart.DayLabel />
        <EChartsHeatmapChart.Tooltip />
      </EChartsHeatmapChart>
      <p className="text-muted-foreground mt-3 text-xs">
        {selection
          ? `${selection.value} contributions on ${selection.date}`
          : "Click a day to select it."}
      </p>
    </div>
  );
}

```

```tsx
<EChartsHeatmapChart
  data={data}
  config={chartConfig}
  onSelectionChange={(selection) => {
    if (selection) {
      console.log("Selected:", selection.date, "Value:", selection.value);
    } else {
      console.log("Deselected");
    }
  }}
>
  <EChartsHeatmapChart.Cell dataKey="contributions" isClickable />
  <EChartsHeatmapChart.Tooltip />
</EChartsHeatmapChart>
```

### Loading State

### isLoading='true'

```tsx
"use client";

import { EChartsHeatmapChart, type ChartConfig } from "@/components/evilcharts/charts/echarts-heatmap-chart";

const chartConfig = {
  contributions: {
    label: "Contributions",
    colors: {
      light: ["#9be9a8", "#40c463", "#30a14e", "#216e39"],
      dark: ["#0e4429", "#006d32", "#26a641", "#39d353"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleHeatmapChart() {
  return (
    <EChartsHeatmapChart
      className="h-full w-full p-4"
      data={[]}
      range={2025}
      config={chartConfig}
      isLoading // [!code highlight]
    >
      <EChartsHeatmapChart.Cell dataKey="contributions" />
      <EChartsHeatmapChart.MonthLabel />
      <EChartsHeatmapChart.DayLabel />
      <EChartsHeatmapChart.Tooltip />
      <EChartsHeatmapChart.Legend />
    </EChartsHeatmapChart>
  );
}

```
> 
  
    Pass `isLoading` to `<EChartsHeatmapChart>` to show an animated gray skeleton of the grid
    while data loads. The intro sweep replays once the data lands.
  


## Examples

Examples of the heatmap in different configurations. Customize the root `range`, `weekStart`, and `scale`, the `<Cell>` `radius` and `gap`, and more.

### Calendar Year

### range={2025} scale='linear'

```tsx
"use client";

import {
  EChartsHeatmapChart,
  type ChartConfig,
  type HeatmapDatum,
} from "@/components/evilcharts/charts/echarts-heatmap-chart";

// Deploys per day across a calendar year, busier in the second half.
function buildDeploys(seed: number): HeatmapDatum[] {
  let state = seed;
  const random = () => {
    state = (state * 1664525 + 1013904223) % 4294967296;
    return state / 4294967296;
  };
  const data: HeatmapDatum[] = [];
  for (let month = 0; month < 12; month++) {
    const days = new Date(Date.UTC(2025, month + 1, 0)).getUTCDate();
    for (let day = 1; day <= days; day++) {
      const date = new Date(Date.UTC(2025, month, day));
      if (date.getUTCDay() === 0 || date.getUTCDay() === 6) continue;
      if (random() >= 0.4 + month * 0.04) continue;
      data.push({
        date: date.toISOString().slice(0, 10),
        value: Math.ceil(random() * (2 + month)),
      });
    }
  }
  return data;
}

const data = buildDeploys(41);

const chartConfig = {
  deploys: {
    label: "Deploys",
    colors: {
      light: ["#e9d5ff", "#c084fc", "#9333ea", "#581c87"],
      dark: ["#3b0764", "#6b21a8", "#a855f7", "#e9d5ff"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleHeatmapChart() {
  return (
    <EChartsHeatmapChart
      className="h-full w-full p-4"
      data={data}
      config={chartConfig}
      range={2025} // [!code highlight]
      scale="linear" // [!code highlight]
    >
      <EChartsHeatmapChart.Cell dataKey="deploys" />
      <EChartsHeatmapChart.MonthLabel />
      <EChartsHeatmapChart.DayLabel />
      <EChartsHeatmapChart.Tooltip />
      <EChartsHeatmapChart.Legend align="left" />
    </EChartsHeatmapChart>
  );
}

```
> 
  
    Pass a year to `range` for January through December. `scale="linear"` spreads the levels
    evenly between the smallest and largest value instead of by quantile.
  


### Monday Start

### weekStart='monday'

```tsx
"use client";

import {
  EChartsHeatmapChart,
  type ChartConfig,
  type HeatmapDatum,
} from "@/components/evilcharts/charts/echarts-heatmap-chart";

// Workouts logged per day — weekday-heavy, with rest days scattered through.
function buildWorkouts(seed: number): HeatmapDatum[] {
  let state = seed;
  const random = () => {
    state = (state * 1664525 + 1013904223) % 4294967296;
    return state / 4294967296;
  };
  const end = Date.UTC(2025, 11, 31);
  const data: HeatmapDatum[] = [];
  for (let i = 364; i >= 0; i--) {
    const day = new Date(end - i * 86_400_000);
    if (random() >= 0.55) continue;
    data.push({ date: day.toISOString().slice(0, 10), value: Math.ceil(random() * 90) });
  }
  return data;
}

const data = buildWorkouts(23);

const chartConfig = {
  minutes: {
    label: "Minutes",
    colors: {
      light: ["#bfdbfe", "#60a5fa", "#2563eb", "#1e3a8a"],
      dark: ["#1e3a8a", "#1d4ed8", "#3b82f6", "#93c5fd"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleHeatmapChart() {
  return (
    <EChartsHeatmapChart
      className="h-full w-full p-4"
      data={data}
      config={chartConfig}
      weekStart="monday" // [!code highlight]
    >
      <EChartsHeatmapChart.Cell dataKey="minutes" />
      <EChartsHeatmapChart.MonthLabel />
      <EChartsHeatmapChart.DayLabel labels={["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]} />
      <EChartsHeatmapChart.Tooltip valueFormatter={(value) => `${value} min`} />
      <EChartsHeatmapChart.Legend />
    </EChartsHeatmapChart>
  );
}

```
> 
  
    `weekStart="monday"` puts Monday on the top row. `<DayLabel labels>` takes seven names,
    Sunday first — pass an empty string to leave a row unlabeled.
  


### Rounded Cells

### radius={6} gap={5}

```tsx
"use client";

import {
  EChartsHeatmapChart,
  type ChartConfig,
  type HeatmapDatum,
} from "@/components/evilcharts/charts/echarts-heatmap-chart";

// One quarter of daily sales, so the larger cells have room to breathe.
function buildSales(seed: number): HeatmapDatum[] {
  let state = seed;
  const random = () => {
    state = (state * 1664525 + 1013904223) % 4294967296;
    return state / 4294967296;
  };
  const start = Date.UTC(2025, 9, 1);
  const data: HeatmapDatum[] = [];
  for (let i = 0; i < 92; i++) {
    if (random() < 0.12) continue;
    data.push({
      date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
      value: Math.round(random() * 4200) + 300,
    });
  }
  return data;
}

const data = buildSales(5);

const chartConfig = {
  sales: {
    label: "Sales",
    colors: {
      light: ["#fed7aa", "#fb923c", "#ea580c", "#9a3412"],
      dark: ["#7c2d12", "#c2410c", "#f97316", "#fdba74"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleHeatmapChart() {
  return (
    <EChartsHeatmapChart
      className="h-full w-full p-4"
      data={data}
      config={chartConfig}
      range={["2025-10-01", "2025-12-31"]}
    >
      <EChartsHeatmapChart.Cell dataKey="sales" radius={6} gap={5} /> {/* [!code highlight] */}
      <EChartsHeatmapChart.MonthLabel />
      <EChartsHeatmapChart.DayLabel />
      <EChartsHeatmapChart.Tooltip valueFormatter={(value) => `$${value.toLocaleString()}`} />
      <EChartsHeatmapChart.Legend />
    </EChartsHeatmapChart>
  );
}

```
> 
  
    A shorter `range` — here one quarter — gives the cells room, so a larger `radius` and `gap`
    read as tiles rather than pixels.
  


### Explicit Thresholds

### thresholds={[3, 10]}

```tsx
"use client";

import {
  EChartsHeatmapChart,
  type ChartConfig,
  type HeatmapDatum,
} from "@/components/evilcharts/charts/echarts-heatmap-chart";

// Error-budget burn per day — mostly quiet, with the odd incident spike.
function buildIncidents(seed: number): HeatmapDatum[] {
  let state = seed;
  const random = () => {
    state = (state * 1664525 + 1013904223) % 4294967296;
    return state / 4294967296;
  };
  const end = Date.UTC(2025, 11, 31);
  const data: HeatmapDatum[] = [];
  for (let i = 364; i >= 0; i--) {
    const roll = random();
    if (roll < 0.45) continue;
    const value = roll > 0.96 ? Math.ceil(random() * 40) + 10 : Math.ceil(random() * 5);
    data.push({ date: new Date(end - i * 86_400_000).toISOString().slice(0, 10), value });
  }
  return data;
}

const data = buildIncidents(19);

const chartConfig = {
  errors: {
    label: "Errors",
    colors: {
      light: ["#fecaca", "#ef4444", "#7f1d1d"],
      dark: ["#7f1d1d", "#ef4444", "#fecaca"],
    },
  },
} satisfies ChartConfig;

export function EChartsExampleHeatmapChart() {
  return (
    <EChartsHeatmapChart
      className="h-full w-full p-4"
      data={data}
      config={chartConfig}
      thresholds={[3, 10]} // [!code highlight]
    >
      <EChartsHeatmapChart.Cell dataKey="errors" />
      <EChartsHeatmapChart.MonthLabel />
      <EChartsHeatmapChart.DayLabel />
      <EChartsHeatmapChart.Tooltip />
      <EChartsHeatmapChart.Legend lessLabel="Quiet" moreLabel="Incident" />
    </EChartsHeatmapChart>
  );
}

```
> 
  
    `thresholds` pins the level boundaries. Zero stays empty; positive values below `3` are the
    first level, `3` up to `10` the second, `10` and up the third. Two cuts make three levels, so
    the config needs three colors — one per level.
  


## API Reference

A root container plus a small set of composible parts. Render the root, then compose the parts you need as children. Each part is declarative config the root compiles into the ECharts option.

<ApiHeading>EChartsHeatmapChart</ApiHeading>

The root container. It owns the date window, the value → level mapping, shared selection state, the loading skeleton, and the intro sweep. Everything visual is composed as children and compiled into the ECharts option.


  ### `data` (required)

type: `HeatmapDatum[]`

One entry per day that has a value. `HeatmapDatum` is `{ date: string; value: number }`, where `date` is an ISO calendar date (`YYYY-MM-DD`). Days absent from the array render as empty cells; duplicate dates are summed.
  ### `config` (required)

type: `ChartConfig`

Defines the series. The key named by `<Cell dataKey>` supplies a `label` and a per-theme `colors` array that becomes the intensity ramp — lightest first, strongest last. Same contract as every EvilCharts chart — see [Chart Config](/docs/chart-config).
  ### `children` (required)

type: `ReactNode`

The composed parts — `<Cell />`, `<MonthLabel />`, `<DayLabel />`, `<Tooltip />`, and `<Legend />`.
  ### `className`

type: `string`

Additional CSS classes for the chart container.
  ### `renderer`

type: `"canvas" | "svg"` · default: `"canvas"`

Rendering engine used by ECharts. Use `"svg"` for an SVG-backed chart surface; omit the prop to keep the Canvas default.
  ### `range`

type: `number | string | [string, string]`

The calendar window. A year (`2025`) shows January through December, a month string (`"2025-03"`) shows that month, and an `[start, end]` pair of ISO dates shows that inclusive span. Omit it for the trailing year ending on the latest date in `data`.
  ### `weekStart`

type: `"sunday" | "monday"` · default: `"sunday"`

Which day sits on the top row of each week column.
  ### `cellSize`

type: `number`

A fixed cell size in pixels. Omit it to fit square cells to the container's width (capped by its height) and re-fit on resize.
  ### `scale`

type: `"quantile" | "linear"` · default: `"quantile"`

How values map onto the ramp when no `thresholds` are given. `"quantile"` shares the non-zero days evenly across the levels, like GitHub, so a few huge days don't wash out the rest; `"linear"` divides the min→max span evenly.
  ### `thresholds`

type: `number[]`

Explicit ascending level boundaries. Values below the first threshold are level one; each threshold a value reaches moves it up a level. `n` thresholds make `n + 1` levels, spread across however many colors the config provides.
  ### `defaultSelectedDate`

type: `string | null` · default: `null`

The ISO date selected on first render.
  ### `onSelectionChange`



void">
    Called when a day is selected or deselected. Receives the day's `date` and `value`, or `null` on deselect. Fires on click while `<Cell />` has `isClickable` set.
  ### `isLoading`

type: `boolean` · default: `false`

Shows the animated loading skeleton.
  ### `animation`

type: `boolean` · default: `true`

Master switch for the intro sweep. Pass `false` to render the chart instantly.
  ### `animationType`

type: `"none" | "default"` · default: `"default"`

`"default"` pops the cells in column by column, left to right, on first render; `"none"` disables it. The OS reduce-motion preference falls back to `"none"` automatically.
  ### `chartOptions`



">
    Escape hatch merged over the built ECharts option object. See the [ECharts calendar option documentation](https://echarts.apache.org/en/option.html#calendar).


<ApiHeading>Cell</ApiHeading>

Configures the day cells. Always renders — it names the config key to color the grid with and shapes each cell.


  ### `dataKey` (required)

type: `string`

The `config` key whose `colors` array forms the intensity ramp and whose `label` names the value in the tooltip.
  ### `radius`

type: `number` · default: `2`

The corner radius of each cell in pixels. Set to 0 for square cells.
  ### `gap`

type: `number` · default: `3`

The space between neighboring cells in pixels.
  ### `isClickable`

type: `boolean` · default: `false`

Lets days be clicked to select/deselect them. The selected day keeps full strength and wears a ring while the rest soften.


<ApiHeading>MonthLabel</ApiHeading>

Shows month names above the grid, each over the column where the month begins. Its presence enables it; omit it and none show.


  ### `labels`

type: `string[]` · default: `["Jan", …, "Dec"]`

Twelve month names, January first.


<ApiHeading>DayLabel</ApiHeading>

Shows weekday names beside the grid. Its presence enables it; omit it and none show.


  ### `labels`

type: `string[]` · default: `["", "Mon", "", "Wed", "", "Fri", ""]`

Seven day names, Sunday first, regardless of `weekStart`. An empty string leaves that row unlabeled — the default labels every other row, like GitHub.


<ApiHeading>Tooltip</ApiHeading>

The hover tooltip. Its presence enables it; omit it and none shows. Hovering a day shows the formatted date as the title and the series label with that day's value beneath it. Hidden automatically while loading.


  ### `variant`

type: `"default" | "frosted-glass"` · default: `"default"`

Controls the visual style of the tooltip surface.
  ### `roundness`

type: `"sm" | "md" | "lg" | "xl"` · default: `"lg"`

Controls the border-radius of the tooltip.
  ### `position`

type: `"fixed" | "variable"` · default: `"variable"`

Controls how the tooltip is anchored. `"variable"` follows the pointer (ECharts' default); `"fixed"` pins the tooltip near the top and only tracks the pointer's X.
  ### `dateFormatter`



string">
    Formats the hovered day's ISO date for the tooltip title. Defaults to `"Mar 3, 2026"` style English.
  ### `valueFormatter`



string" default="(value) => value.toLocaleString()">
    Formats the hovered day's value.


<ApiHeading>Legend</ApiHeading>

The "Less → More" ramp rendered under the grid, one swatch per level plus the empty cell. Its presence enables it; omit it and none shows. Hidden while loading.


  ### `align`

type: `"left" | "right"` · default: `"right"`

Which side of the chart the ramp sits on.
  ### `lessLabel`

type: `string` · default: `"Less"`

Text before the ramp.
  ### `moreLabel`

type: `string` · default: `"More"`

Text after the ramp.

