---
title: Heatmap Blocks
description: Ready-made calendar heatmap blocks, powered by Apache ECharts
image: /og/heatmap-chart.png
---

## Contributions

### Contributions

```tsx
"use client";

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

// A year of daily contributions, seeded so the block renders 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 sprint = Math.floor(i / 7) % 6 < 2;
    const chance = (weekend ? 0.25 : 0.75) + (sprint ? 0.2 : 0);
    if (random() >= chance) continue;
    const value = Math.round(random() * (weekend ? 5 : sprint ? 22 : 12)) + 1;
    data.push({ date: day.toISOString().slice(0, 10), value });
  }
  return data;
}

const chartData = buildContributions(2024);

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

const TOTAL = chartData.reduce((sum, day) => sum + day.value, 0);

const LONGEST_STREAK = (() => {
  const active = new Set(chartData.map((day) => day.date));
  const end = Date.UTC(2025, 11, 31);
  let best = 0;
  let run = 0;
  for (let i = 364; i >= 0; i--) {
    const date = new Date(end - i * 86_400_000).toISOString().slice(0, 10);
    run = active.has(date) ? run + 1 : 0;
    if (run > best) best = run;
  }
  return best;
})();

export function EChartsContributionsHeatmapChart() {
  return (
    <div className="flex h-full w-full flex-col p-4">
      <div className="flex items-baseline justify-between gap-4">
        <div className="flex flex-col gap-0.5">
          <span className="text-primary text-sm font-medium">
            {TOTAL.toLocaleString("en-US")} contributions in the last year
          </span>
          <span className="text-muted-foreground text-[11px]">
            Longest streak {LONGEST_STREAK} days
          </span>
        </div>
        <span className="text-muted-foreground font-mono text-[11px]">2025</span>
      </div>

      <EChartsHeatmapChart
        className="mt-3 min-h-0 w-full flex-1"
        data={chartData}
        config={chartConfig}
        range={2025}
      >
        <EChartsHeatmapChart.Cell dataKey="contributions" radius={2} gap={3} />
        <EChartsHeatmapChart.MonthLabel />
        <EChartsHeatmapChart.DayLabel />
        <EChartsHeatmapChart.Tooltip variant="frosted-glass" />
        <EChartsHeatmapChart.Legend />
      </EChartsHeatmapChart>
    </div>
  );
}

```
### npm

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

### yarn

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

### bun

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

### pnpm

```bash
pnpm dlx shadcn@latest add @evilcharts/contributions-echarts-heatmap-chart
```

## Store Sales

### Store Sales

```tsx
"use client";

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

const YEAR_START = Date.UTC(2025, 0, 1);
const YEAR_END = Date.UTC(2025, 11, 31);
const DAY_MS = 86_400_000;
const AVERAGE_ORDER = 42; // dollars per order, turns the order count into revenue

// How busy each month runs relative to a normal one: a summer lull, then the holiday rush.
const SEASON = [0.85, 0.8, 0.9, 0.95, 1, 0.95, 0.85, 0.6, 1, 1.05, 1.15, 1.5];
// Days that break the pattern on purpose, so the calendar has something to find.
const EVENTS: Record<string, { name: string; orders: number }> = {
  "2025-02-14": { name: "Valentine's Day", orders: 95 },
  "2025-11-28": { name: "Black Friday", orders: 320 },
  "2025-12-01": { name: "Cyber Monday", orders: 210 },
};
const WEEKDAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];

// A year of daily orders for an online shop, seeded — weekends sell about twice as much.
function buildOrders(seed: number): HeatmapDatum[] {
  let state = seed;
  const random = () => {
    state = (state * 1664525 + 1013904223) % 4294967296;
    return state / 4294967296;
  };
  const data: HeatmapDatum[] = [];
  for (let ms = YEAR_START; ms <= YEAR_END; ms += DAY_MS) {
    const day = new Date(ms);
    const date = day.toISOString().slice(0, 10);
    const weekday = day.getUTCDay();
    const isWeekend = weekday === 0 || weekday === 6;
    // The week between Christmas and New Year is quiet once shipping cuts off.
    const season = ms >= Date.UTC(2025, 11, 24) ? 0.45 : SEASON[day.getUTCMonth()];
    const base = 22 + random() * 14;
    data.push({
      date,
      value: EVENTS[date]?.orders ?? Math.round(base * season * (isWeekend ? 1.9 : 1)),
    });
  }
  return data;
}

const chartData = buildOrders(4242);

// Pinned levels so a normal weekday, a weekend, the holiday rush, and the sale
// days each get their own shade — a quantile ramp would flatten the peaks.
const THRESHOLDS = [40, 75, 150];

const chartConfig = {
  orders: {
    label: "Orders",
    colors: {
      light: ["#fde68a", "#fbbf24", "#d97706", "#92400e"],
      dark: ["#451a03", "#b45309", "#f59e0b", "#fef3c7"],
    },
  },
} satisfies ChartConfig;

const TOTAL = chartData.reduce((sum, day) => sum + day.value, 0);
const BEST = chartData.reduce((best, day) => (day.value > best.value ? day : best), chartData[0]);
// Average orders per weekday, Monday first, leaving the sale days out so they
// don't skew the week they land in.
const BY_WEEKDAY = (() => {
  const sums = Array.from({ length: 7 }, () => ({ orders: 0, days: 0 }));
  for (const day of chartData) {
    if (day.date in EVENTS) continue;
    const slot = sums[(new Date(Date.parse(`${day.date}T00:00:00Z`)).getUTCDay() + 6) % 7];
    slot.orders += day.value;
    slot.days += 1;
  }
  return sums.map((slot, index) => ({
    name: WEEKDAY_NAMES[index],
    average: slot.orders / slot.days,
    isWeekend: index >= 5,
  }));
})();
const PEAK_AVERAGE = Math.max(...BY_WEEKDAY.map((day) => day.average));
const average = (days: typeof BY_WEEKDAY) =>
  days.reduce((sum, day) => sum + day.average, 0) / days.length;
const WEEKEND_LIFT =
  average(BY_WEEKDAY.filter((day) => day.isWeekend)) /
    average(BY_WEEKDAY.filter((day) => !day.isWeekend)) -
  1;
const PEAK_DAYS = Object.entries(EVENTS).sort(([, a], [, b]) => b.orders - a.orders);

function formatDate(date: string) {
  return new Date(Date.parse(`${date}T00:00:00Z`)).toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
    timeZone: "UTC",
  });
}

export function EChartsSalesHeatmapChart() {
  return (
    <div className="flex h-full w-full flex-col p-4">
      <div className="grid grid-cols-3 gap-3">
        <div className="flex flex-col gap-0.5">
          <span className="text-muted-foreground text-[11px]">Revenue · 2025</span>
          <span className="text-primary text-xl leading-none font-semibold tracking-tight tabular-nums">
            ${((TOTAL * AVERAGE_ORDER) / 1000).toFixed(0)}k
          </span>
        </div>
        <div className="flex flex-col gap-0.5">
          <span className="text-muted-foreground text-[11px]">Best day</span>
          <span className="text-primary text-xl leading-none font-semibold tracking-tight">
            {formatDate(BEST.date)}
          </span>
        </div>
        <div className="flex flex-col gap-0.5">
          <span className="text-muted-foreground text-[11px]">Weekend lift</span>
          <span className="text-primary text-xl leading-none font-semibold tracking-tight tabular-nums">
            +{Math.round(WEEKEND_LIFT * 100)}%
          </span>
        </div>
      </div>

      <EChartsHeatmapChart
        className="mt-4 min-h-0 w-full flex-1"
        data={chartData}
        config={chartConfig}
        range={2025}
        weekStart="monday"
        thresholds={THRESHOLDS}
      >
        <EChartsHeatmapChart.Cell dataKey="orders" radius={2} gap={2} />
        <EChartsHeatmapChart.MonthLabel />
        <EChartsHeatmapChart.DayLabel labels={["Sun", "Mon", "", "Wed", "", "Fri", ""]} />
        <EChartsHeatmapChart.Tooltip />
        <EChartsHeatmapChart.Legend align="left" lessLabel="Quiet" moreLabel="Sale days" />
      </EChartsHeatmapChart>

      <div className="mt-3 hidden grid-cols-2 gap-6 border-t pt-3 sm:grid">
        <div className="flex flex-col gap-2">
          <span className="text-muted-foreground text-[11px]">Avg orders by weekday</span>
          <div className="flex h-12 items-end gap-1.5">
            {BY_WEEKDAY.map((day) => (
              <div key={day.name} className="flex h-full flex-1 flex-col justify-end gap-1">
                <div
                  className={
                    day.isWeekend ? "rounded-sm bg-amber-500" : "rounded-sm bg-amber-500/30"
                  }
                  style={{ height: `${(day.average / PEAK_AVERAGE) * 100}%` }}
                />
                <span className="text-muted-foreground text-center text-[10px] leading-none">
                  {day.name}
                </span>
              </div>
            ))}
          </div>
        </div>
        <div className="flex flex-col gap-2">
          <span className="text-muted-foreground text-[11px]">Peak days</span>
          <ul className="flex flex-col gap-1.5">
            {PEAK_DAYS.map(([date, event]) => (
              <li key={date} className="flex items-baseline gap-2 text-[11px]">
                <span className="text-primary font-medium">{event.name}</span>
                <span className="text-muted-foreground">{formatDate(date)}</span>
                <span className="text-primary ml-auto font-medium tabular-nums">
                  {event.orders}
                </span>
              </li>
            ))}
          </ul>
        </div>
      </div>
    </div>
  );
}

```
### npm

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

### yarn

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

### bun

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

### pnpm

```bash
pnpm dlx shadcn@latest add @evilcharts/sales-echarts-heatmap-chart
```

## Workout Tracker

### Workout Tracker

```tsx
"use client";

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

const YEAR_START = Date.UTC(2025, 0, 1);
const YEAR_END = Date.UTC(2025, 11, 31);
const DAY_MS = 86_400_000;

// The weekly plan, Sunday first: what each day is for and roughly how long it runs.
const PLAN = [
  { activity: "Walk", minutes: 30, odds: 0.15 },
  { activity: "Strength", minutes: 50, odds: 0.85 },
  { activity: "Run", minutes: 35, odds: 0.8 },
  { activity: "Yoga", minutes: 25, odds: 0.6 },
  { activity: "Strength", minutes: 50, odds: 0.8 },
  { activity: "Run", minutes: 35, odds: 0.65 },
  { activity: "Long run", minutes: 75, odds: 0.75 },
];

function activityOf(date: string) {
  return PLAN[new Date(Date.parse(`${date}T00:00:00Z`)).getUTCDay()].activity;
}

// A year of workouts in minutes, seeded: a keen January, a two-week summer
// break, and a strong finish so the current streak is still running.
function buildWorkouts(seed: number): HeatmapDatum[] {
  let state = seed;
  const random = () => {
    state = (state * 1664525 + 1013904223) % 4294967296;
    return state / 4294967296;
  };
  const data: HeatmapDatum[] = [];
  for (let ms = YEAR_START; ms <= YEAR_END; ms += DAY_MS) {
    const day = new Date(ms);
    const plan = PLAN[day.getUTCDay()];
    const isBreak = ms >= Date.UTC(2025, 7, 4) && ms <= Date.UTC(2025, 7, 17);
    const isKeen = ms < Date.UTC(2025, 1, 1) || ms >= Date.UTC(2025, 11, 1);
    const odds = isKeen ? Math.min(1, plan.odds + 0.25) : plan.odds;
    if (isBreak || random() >= odds) continue;
    const minutes = plan.minutes * (0.7 + random() * 0.6);
    data.push({ date: day.toISOString().slice(0, 10), value: Math.round(minutes / 5) * 5 });
  }
  return data;
}

const chartData = buildWorkouts(9);

const chartConfig = {
  minutes: {
    label: "Minutes",
    colors: {
      light: ["#ddd6fe", "#a78bfa", "#7c3aed", "#4c1d95"],
      dark: ["#3b0764", "#7c3aed", "#a78bfa", "#ede9fe"],
    },
  },
} satisfies ChartConfig;

// Pinned levels in minutes: a short session, a normal one, a long one, and a long run.
const THRESHOLDS = [30, 45, 65];

const active = new Set(chartData.map((day) => day.date));
const SESSIONS = chartData.length;
const STREAKS = (() => {
  let longest = 0;
  let run = 0;
  for (let ms = YEAR_START; ms <= YEAR_END; ms += DAY_MS) {
    run = active.has(new Date(ms).toISOString().slice(0, 10)) ? run + 1 : 0;
    longest = Math.max(longest, run);
  }
  return { current: run, longest };
})();
const HOURS = Math.round(chartData.reduce((sum, day) => sum + day.value, 0) / 60);
// Sessions per activity, busiest first — the bars under the calendar.
const BY_ACTIVITY = (() => {
  const counts = new Map<string, number>();
  for (const day of chartData) {
    const activity = activityOf(day.date);
    counts.set(activity, (counts.get(activity) ?? 0) + 1);
  }
  return [...counts]
    .map(([name, sessions]) => ({ name, sessions }))
    .sort((a, b) => b.sessions - a.sessions);
})();
const TOP_SESSIONS = BY_ACTIVITY[0].sessions;

function formatDate(date: string) {
  return new Date(Date.parse(`${date}T00:00:00Z`)).toLocaleDateString("en-US", {
    weekday: "short",
    month: "short",
    day: "numeric",
    timeZone: "UTC",
  });
}

export function EChartsWorkoutsHeatmapChart() {
  const [selection, setSelection] = useState<HeatmapSelection | null>(null);
  const selectedActivity = selection && selection.value > 0 ? activityOf(selection.date) : null;

  return (
    <div className="flex h-full w-full flex-col p-4">
      <div className="flex items-start justify-between gap-4">
        <div className="flex flex-col gap-0.5">
          <span className="text-muted-foreground text-[11px]">
            {selection ? formatDate(selection.date) : "Workouts · 2025"}
          </span>
          <span className="text-primary text-sm font-medium">
            {selection ? (selectedActivity ?? "Rest day") : "Click a day for details"}
          </span>
        </div>
        <div className="flex items-baseline gap-1">
          <span className="text-primary text-xl leading-none font-semibold tracking-tight tabular-nums">
            {selection ? selection.value : HOURS}
          </span>
          <span className="text-muted-foreground text-[11px]">{selection ? "min" : "hours"}</span>
        </div>
      </div>

      <EChartsHeatmapChart
        className="mt-3 min-h-0 w-full flex-1"
        data={chartData}
        config={chartConfig}
        range={2025}
        thresholds={THRESHOLDS}
        onSelectionChange={setSelection}
      >
        <EChartsHeatmapChart.Cell dataKey="minutes" radius={2} gap={2} isClickable />
        <EChartsHeatmapChart.MonthLabel />
        <EChartsHeatmapChart.DayLabel />
        <EChartsHeatmapChart.Tooltip variant="frosted-glass" />
        <EChartsHeatmapChart.Legend lessLabel="Short" moreLabel="Long" />
      </EChartsHeatmapChart>

      <div className="mt-3 hidden grid-cols-[1fr_auto] gap-6 border-t pt-3 sm:grid">
        <div className="flex flex-col gap-1">
          <span className="text-muted-foreground mb-1 text-[11px]">Sessions by activity</span>
          {BY_ACTIVITY.map((activity) => (
            <div key={activity.name} className="flex items-center gap-2 text-[11px]">
              <span
                className={
                  activity.name === selectedActivity
                    ? "text-primary w-14 font-medium"
                    : "text-muted-foreground w-14"
                }
              >
                {activity.name}
              </span>
              <div className="h-1.5 flex-1">
                <div
                  className={
                    activity.name === selectedActivity
                      ? "h-full rounded-full bg-violet-500"
                      : "h-full rounded-full bg-violet-500/35"
                  }
                  style={{ width: `${(activity.sessions / TOP_SESSIONS) * 100}%` }}
                />
              </div>
              <span className="text-primary w-6 text-right tabular-nums">{activity.sessions}</span>
            </div>
          ))}
        </div>
        <div className="flex flex-col justify-between gap-2">
          {[
            { label: "Sessions", value: SESSIONS },
            { label: "Current streak", value: `${STREAKS.current}d` },
            { label: "Longest streak", value: `${STREAKS.longest}d` },
          ].map((stat) => (
            <div key={stat.label} className="flex items-baseline justify-between gap-4">
              <span className="text-muted-foreground text-[11px]">{stat.label}</span>
              <span className="text-primary text-sm font-semibold tabular-nums">{stat.value}</span>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

```
### npm

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

### yarn

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

### bun

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

### pnpm

```bash
pnpm dlx shadcn@latest add @evilcharts/workouts-echarts-heatmap-chart
```
