Skip to main content

ns-ui

Heatmap Year Stipple

A GitHub-style year activity calendar where intensity is stipple density, not color: every day cell holds a handful of deterministically jittered ink dots, denser for more activity, like an engraved print. Hovering or arrow-keying through days opens a zoomed loupe with the exact count, and each new day's dots spring into the loupe with a brief ink-settle scatter, like fresh ink landing on paper.

Usage

registry/core/heatmap-year-stipple/demo.tsx
"use client";

import { useEffect, useRef } from "react";
import { StippleYear } from "./component";

// Deterministic sample data (never Math.random() — this renders on the
// server too, and a random value there would mismatch the client's on
// hydration). A small local hash/PRNG, seeded per date, mixes a weekly
// rhythm (heavier midweek) with a seasonal bump so the year reads as
// plausible activity rather than uniform noise.
function hash(s: string): number {
  let h = 2166136261;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return (h >>> 0) / 4294967296;
}

// Local calendar date, not UTC — must match the component's own key format
// exactly (toISOString() shifts to UTC and can land on the wrong day).
function isoDate(d: Date): string {
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, "0");
  const day = String(d.getDate()).padStart(2, "0");
  return `${y}-${m}-${day}`;
}

function sampleValues(end: Date): Record<string, number> {
  const values: Record<string, number> = {};
  for (let i = 0; i < 365; i++) {
    const d = new Date(end);
    d.setDate(d.getDate() - i);
    const iso = isoDate(d);
    const weekday = d.getDay();
    const weekdayBoost = weekday === 0 || weekday === 6 ? 0.3 : 1;
    const seasonal = 0.5 + 0.5 * Math.sin((d.getMonth() / 12) * Math.PI * 2);
    const noise = hash(iso);
    const raw = noise * 10 * weekdayBoost * (0.5 + seasonal);
    values[iso] = noise < 0.12 ? 0 : Math.round(raw);
  }
  return values;
}

// Fixed reference date — deterministic across server and client renders.
const END_DATE = new Date(2026, 6, 22);

export default function StippleYearDemo() {
  const values = sampleValues(END_DATE);
  const rootRef = useRef<HTMLDivElement | null>(null);

  // Self-driving: periodically move focus across a few cells so the loupe
  // and focus ring are visible mid-interaction, not just on a static frame.
  // Paused while a real pointer is resting inside the component — otherwise
  // the synthetic focus() steals hoverIndex out from under a genuine hover
  // (via the cell's own onFocus handler) and the loupe appears to jump to an
  // unrelated day a second and a half later.
  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    let pointerInside = false;
    const onEnter = () => {
      pointerInside = true;
    };
    const onLeave = () => {
      pointerInside = false;
    };
    root.addEventListener("pointerenter", onEnter);
    root.addEventListener("pointerleave", onLeave);

    const cells = () => Array.from(root.querySelectorAll<HTMLElement>('[role="button"]'));
    let i = 0;
    const id = setInterval(() => {
      if (pointerInside) return;
      const list = cells();
      if (!list.length) return;
      const target = list[Math.floor((i * 37) % list.length)];
      target?.focus();
      i += 1;
    }, 1500);
    return () => {
      clearInterval(id);
      root.removeEventListener("pointerenter", onEnter);
      root.removeEventListener("pointerleave", onLeave);
    };
  }, []);

  return (
    <div ref={rootRef} className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / heatmap-year-stipple
      </p>

      <div className="inline-block rounded-[12px] border border-border bg-background p-6">
        <StippleYear values={values} endDate={END_DATE} />
      </div>

      <p className="max-w-md text-center text-xs text-ns-muted">
        Density is stippled ink, not color. Hover or arrow-key through days
        for the loupe and exact count.
      </p>
    </div>
  );
}

Component

registry/core/heatmap-year-stipple/component.tsx
"use client";

import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";

// ---------------------------------------------------------------------------
// StippleYear — a GitHub-style year activity calendar where intensity is
// STIPPLE DENSITY, not color: every day cell holds 0-8 tiny ink dots placed
// by a deterministic, date-seeded jitter (an engraving/print metaphor, pure
// var(--foreground) on var(--background)). Dot positions are generated once
// in NORMALIZED [0,1] space per date (`stippleUnits`) and only scaled to
// pixels at render time — the loupe renders the exact same normalized
// points at 2x, so it's a literal zoomed copy, not a re-roll.
//
// Every cell is a focusable role="button" (roving tabindex — one cell has
// tabIndex 0 at a time, arrow keys move which one) so the automatic
// hover/press/focus screenshot pass and the a11y audit exercise the same
// interaction keyboard users get. Hover or focus opens a fixed loupe panel
// plus a Geist Mono tooltip; the cell's own aria-label already carries the
// same fact in text for anyone not using either.
// ---------------------------------------------------------------------------

export interface StippleYearProps {
  /** Map of ISO date ("YYYY-MM-DD") to an activity count. */
  values?: Record<string, number>;
  /** Last day of the 371-day window. Defaults to today. */
  endDate?: Date;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

const CELL = 16;
const GAP = 4;
const STEP = CELL + GAP;
const MARGIN = 1.9;
const MAX_DOTS = 8;
const DOT_R = 1.35;
const LOUPE_CELL = 68;
const LOUPE_MARGIN = 6;
const LOUPE_DOT_R = 3.2;
/** Fixed footprint of the side panel that holds the loupe — always reserved
 *  (even when nothing is hovered) so it never overlaps the grid's own cells
 *  and never shifts the component's box on hover. Wide enough for the
 *  longest realistic one-line caption ("20 contributions - Dec 25"). */
const LOUPE_PANEL_W = 172;
const LEFT_LABEL_W = 30;
const TOP_LABEL_H = 20;

/** Loupe ink-settle — every time a new cell is magnified into the loupe,
 *  its dots don't just appear: each one starts flung a short distance from
 *  its resting position (offset derived from the same per-dot mulberry32
 *  stream that placed it, so it's deterministic and replays identically for
 *  a given date, never Math.random()) and springs into place on an
 *  ease-out-expo curve, staggered a few ms apart per dot so the settle
 *  reads as ink landing on paper rather than one uniform pop. Kept well
 *  clear of the loupe's own 160ms zoom-in (LOUPE_SETTLE_MS is longer and
 *  starts from the same frame) so the two read as two distinct events, not
 *  one blurred bloom. */
const LOUPE_SETTLE_MS = 460;
const LOUPE_SETTLE_MAX = 22;
const LOUPE_SETTLE_STAGGER = 18;

const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const WEEKDAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""];

function startOfDay(d: Date): Date {
  const c = new Date(d);
  c.setHours(0, 0, 0, 0);
  return c;
}
function addDays(d: Date, n: number): Date {
  const c = new Date(d);
  c.setDate(c.getDate() + n);
  return c;
}
function isoDate(d: Date): string {
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, "0");
  const day = String(d.getDate()).padStart(2, "0");
  return `${y}-${m}-${day}`;
}

function hashStr(s: string): number {
  let h = 2166136261;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return h >>> 0;
}
function mulberry32(seed: number) {
  let a = seed;
  return function () {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function dotCountFor(value: number, max: number): number {
  if (value <= 0) return 0;
  return Math.min(MAX_DOTS, 1 + Math.round((value / max) * (MAX_DOTS - 1)));
}

/** Normalized [0,1] dot positions for a date, deterministic and reused at any scale. */
function stippleUnits(iso: string, count: number): { x: number; y: number }[] {
  const rng = mulberry32(hashStr(iso));
  return Array.from({ length: count }, () => ({ x: rng(), y: rng() }));
}

/** Per-dot starting offset for the loupe's ink-settle entrance — a distinct
 *  PRNG stream (own hash suffix) so it never correlates with the dot's own
 *  resting position, but is still fully deterministic for a given date. */
function loupeSettleOffsets(iso: string, count: number): { dx: number; dy: number }[] {
  const rng = mulberry32(hashStr(`${iso}:settle`));
  return Array.from({ length: count }, () => {
    const angle = rng() * Math.PI * 2;
    const dist = LOUPE_SETTLE_MAX * (0.4 + rng() * 0.6);
    return { dx: Math.cos(angle) * dist, dy: Math.sin(angle) * dist };
  });
}

interface DayCell {
  date: Date;
  iso: string;
  inRange: boolean;
  col: number;
  row: number;
}

export function StippleYear({ values = {}, endDate, className = "" }: StippleYearProps) {
  const today = useMemo(() => startOfDay(endDate ?? new Date()), [endDate]);
  const loupeClipId = useId();

  const { cells, weeks, monthLabels } = useMemo(() => {
    const roughStart = addDays(today, -364);
    const start = addDays(roughStart, -roughStart.getDay());
    const totalDays = Math.round((today.getTime() - start.getTime()) / 86400000) + 1;
    const weeksN = Math.ceil(totalDays / 7);
    const list: DayCell[] = [];
    for (let i = 0; i < weeksN * 7; i++) {
      const date = addDays(start, i);
      list.push({ date, iso: isoDate(date), inRange: date <= today, col: Math.floor(i / 7), row: i % 7 });
    }
    const labels: { col: number; text: string }[] = [];
    let lastMonth = -1;
    for (const c of list) {
      if (c.row === 0 && c.date.getDate() <= 7 && c.date.getMonth() !== lastMonth) {
        labels.push({ col: c.col, text: MONTHS[c.date.getMonth()]! });
        lastMonth = c.date.getMonth();
      }
    }
    return { cells: list, weeks: weeksN, monthLabels: labels };
  }, [today]);

  const maxValue = useMemo(
    () => Math.max(1, ...cells.filter((c) => c.inRange).map((c) => values[c.iso] ?? 0)),
    [cells, values]
  );

  const [activeIndex, setActiveIndex] = useState(() => {
    const idx = cells.findIndex((c) => c.inRange && c.iso === isoDate(today));
    return idx === -1 ? 0 : idx;
  });
  const [hoverIndex, setHoverIndex] = useState<number | null>(null);
  const cellRefs = useRef<(SVGRectElement | null)[]>([]);

  const reducedMotionRef = useRef(false);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const sync = () => {
      reducedMotionRef.current = mq.matches;
    };
    sync();
    mq.addEventListener("change", sync);
    return () => mq.removeEventListener("change", sync);
  }, []);

  // Column/row-aware, not flat-index ±1/±7 — at a row boundary (top/bottom
  // of a week column) a flat index just wraps into the adjacent week's
  // opposite row, which reads as a diagonal jump instead of stopping.
  const focusCell = (col: number, row: number) => {
    if (row < 0 || row > 6 || col < 0 || col >= weeks) return;
    const next = col * 7 + row;
    if (!cells[next]?.inRange) return;
    setActiveIndex(next);
    cellRefs.current[next]?.focus();
  };

  const loupeIdx = hoverIndex;
  const loupeCell = loupeIdx !== null ? cells[loupeIdx] : null;
  const loupeValue = loupeCell ? (values[loupeCell.iso] ?? 0) : 0;
  const loupeUnits = loupeCell ? stippleUnits(loupeCell.iso, dotCountFor(loupeValue, maxValue)) : [];
  const loupeSettle = loupeCell ? loupeSettleOffsets(loupeCell.iso, loupeUnits.length) : [];

  const labelFor = (c: DayCell) => {
    const v = values[c.iso] ?? 0;
    const noun = v === 1 ? "contribution" : "contributions";
    return `${v} ${noun}, ${MONTHS[c.date.getMonth()]} ${c.date.getDate()}`;
  };

  const viewW = LEFT_LABEL_W + weeks * STEP;
  const viewH = TOP_LABEL_H + 7 * STEP;

  return (
    <div className={`ns-sy-grid inline-flex items-start gap-3 ${className}`}>
      <style>{CSS}</style>
      <svg
        className="ns-sy-canvas"
        viewBox={`0 0 ${viewW} ${viewH}`}
        width={viewW}
        style={{ maxWidth: "100%" }}
        focusable="false"
      >
        <g aria-hidden="true" className="font-mono" style={{ fontSize: 8.5 }}>
          {monthLabels.map((m, i) => (
            <text key={i} x={LEFT_LABEL_W + m.col * STEP} y={TOP_LABEL_H - 4} fill="var(--ns-muted)">
              {m.text}
            </text>
          ))}
          {WEEKDAY_LABELS.map((label, row) =>
            label ? (
              <text key={row} x={0} y={TOP_LABEL_H + row * STEP + CELL - 2} fill="var(--ns-muted)">
                {label}
              </text>
            ) : null
          )}
        </g>

        {cells.map((c, i) => {
          if (!c.inRange) return null;
          const value = values[c.iso] ?? 0;
          const count = dotCountFor(value, maxValue);
          const units = stippleUnits(c.iso, count);
          const x = LEFT_LABEL_W + c.col * STEP;
          const y = TOP_LABEL_H + c.row * STEP;
          const isActive = i === activeIndex;
          const isHovered = i === hoverIndex;
          return (
            <g key={c.iso}>
              <rect
                ref={(el) => {
                  cellRefs.current[i] = el;
                }}
                role="button"
                tabIndex={isActive ? 0 : -1}
                aria-label={labelFor(c)}
                x={x}
                y={y}
                width={CELL}
                height={CELL}
                rx={2}
                fill="transparent"
                stroke={isHovered ? "var(--foreground)" : "var(--border)"}
                strokeWidth={isHovered ? 1.25 : 0.9}
                strokeOpacity={isHovered ? 0.7 : 0.6}
                className="ns-sy-cell"
                style={{ outlineOffset: 1 }}
                onPointerEnter={() => setHoverIndex(i)}
                onPointerLeave={() => setHoverIndex((cur) => (cur === i ? null : cur))}
                onFocus={() => {
                  setActiveIndex(i);
                  setHoverIndex(i);
                }}
                onBlur={() => setHoverIndex((cur) => (cur === i ? null : cur))}
                onKeyDown={(e) => {
                  if (e.key === "ArrowUp") {
                    e.preventDefault();
                    focusCell(c.col, c.row - 1);
                  } else if (e.key === "ArrowDown") {
                    e.preventDefault();
                    focusCell(c.col, c.row + 1);
                  } else if (e.key === "ArrowLeft") {
                    e.preventDefault();
                    focusCell(c.col - 1, c.row);
                  } else if (e.key === "ArrowRight") {
                    e.preventDefault();
                    focusCell(c.col + 1, c.row);
                  }
                }}
              />
              <g className="ns-sy-dots">
                {units.map((u, k) => (
                  <circle
                    key={k}
                    cx={x + MARGIN + u.x * (CELL - 2 * MARGIN)}
                    cy={y + MARGIN + u.y * (CELL - 2 * MARGIN)}
                    r={DOT_R}
                    fill="var(--foreground)"
                    aria-hidden="true"
                  />
                ))}
              </g>
            </g>
          );
        })}
      </svg>

      {/* Fixed-footprint side panel, always rendered so the component's own
          box never resizes on hover — only its contents toggle. It sits
          beside the grid (not layered on top), so it can never occlude the
          cell that's actually being hovered. */}
      <div
        className="pointer-events-none flex shrink-0 flex-col items-center justify-center gap-1 rounded-[6px] border border-border bg-background p-1.5 shadow-sm"
        style={{ width: LOUPE_PANEL_W, height: viewH, visibility: loupeCell ? "visible" : "hidden" }}
      >
        {loupeCell && (
          <>
            <svg
              key={loupeCell.iso}
              viewBox={`0 0 ${LOUPE_CELL} ${LOUPE_CELL}`}
              width={LOUPE_CELL}
              height={LOUPE_CELL}
              className="ns-sy-loupe"
              aria-hidden="true"
            >
              <defs>
                <clipPath id={loupeClipId}>
                  <rect x={1} y={1} width={LOUPE_CELL - 2} height={LOUPE_CELL - 2} rx={4} />
                </clipPath>
              </defs>
              <rect x={0} y={0} width={LOUPE_CELL} height={LOUPE_CELL} rx={4} fill="var(--background)" stroke="var(--border)" strokeWidth={1} />
              <g clipPath={`url(#${loupeClipId})`}>
                {loupeUnits.map((u, k) => {
                  const settle = loupeSettle[k] ?? { dx: 0, dy: 0 };
                  return (
                    <circle
                      key={k}
                      cx={LOUPE_MARGIN + u.x * (LOUPE_CELL - 2 * LOUPE_MARGIN)}
                      cy={LOUPE_MARGIN + u.y * (LOUPE_CELL - 2 * LOUPE_MARGIN)}
                      r={LOUPE_DOT_R}
                      fill="var(--foreground)"
                      className="ns-sy-loupe-dot"
                      style={
                        {
                          "--sx": `${settle.dx.toFixed(2)}px`,
                          "--sy": `${settle.dy.toFixed(2)}px`,
                          animationDelay: `${k * LOUPE_SETTLE_STAGGER}ms`,
                        } as CSSProperties
                      }
                    />
                  );
                })}
              </g>
            </svg>
            <span className="whitespace-nowrap font-mono text-[10px] text-foreground">
              {loupeValue} {loupeValue === 1 ? "contribution" : "contributions"} - {MONTHS[loupeCell.date.getMonth()]}{" "}
              {loupeCell.date.getDate()}
            </span>
          </>
        )}
      </div>
    </div>
  );
}

const CSS = `
.ns-sy-cell { cursor: pointer; outline: none; transition: stroke 120ms ease-out; }
.ns-sy-cell:focus-visible { outline: 2px solid var(--ns-accent); outline-offset: 1px; }
@keyframes ns-sy-zoom-in { from { transform: scale(0.55); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.ns-sy-loupe { transform-origin: center; animation: ns-sy-zoom-in 160ms cubic-bezier(0.16, 1, 0.3, 1); }
@keyframes ns-sy-ink-settle {
  from { transform: translate(var(--sx), var(--sy)); opacity: 0.15; }
  to { transform: translate(0, 0); opacity: 1; }
}
.ns-sy-loupe-dot { animation: ns-sy-ink-settle ${LOUPE_SETTLE_MS}ms cubic-bezier(0.16, 1, 0.3, 1) backwards; }
@media (prefers-reduced-motion: reduce) {
  .ns-sy-cell { transition: none; }
  .ns-sy-loupe { animation: none; }
  .ns-sy-loupe-dot { animation: none; }
}
`;

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/heatmap-year-stipple.tsx, so point the import there.

Use when A GitHub-style contribution calendar for an audience that reads density, not hue — colorblind-safe by construction and print-legible since it never depends on a color channel. Reach for a conventional color-scale heatmap instead when the surrounding UI already commits to a color legend (e.g. matching a severity palette) that stippling can't reuse.

Install

npx shadcn add https://design.helpmarq.com/r/heatmap-year-stipple.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build a GitHub-style year contribution calendar where activity intensity is communicated by STIPPLE DENSITY rather than color — a monochrome ink-on-paper engraving metaphor using only var(--foreground) dots on var(--background). Layout: a week-columns x weekday-rows grid (Sunday-aligned like GitHub, 16px cells, 4px gaps), built from a `values` prop (Record<ISO date string, activity count>) and an `endDate` prop (defaults to today) covering the trailing ~365 days rounded out to full weeks (so 52-53 columns depending on alignment). Render everything in one SVG for the whole grid: month abbreviations in Geist Mono above the column where each new month's first week starts, and 'Mon'/'Wed'/'Fri' row labels to the left (GitHub's convention of skipping Sun/Tue/Thu/Sat labels). Each day's dot count is derived from its value via `count = value<=0 ? 0 : min(8, 1 + round((value/max)*7))` where max is the largest value in the visible range. Dot POSITIONS are generated in normalized [0,1] space by a small deterministic PRNG (an FNV-1a-style string hash of the ISO date feeding a mulberry32 generator) — same date always produces the same pattern — and only scaled to actual pixel coordinates at render time (position = margin + unit * (cellSize - 2*margin)). This normalized-first design is what lets the loupe be a literal zoomed copy: it re-renders the SAME normalized units at a larger cell size (68px vs 16px) rather than regenerating a new pattern. Every day cell is a focusable SVG rect (role="button", roving tabindex — exactly one cell has tabIndex 0 at a time, matching whichever cell is currently 'active'; every other cell is -1) with an aria-label stating the fact directly as text: 'N contributions, Mon D' (or 'No contributions, Mon D' style pluralization for 0/1). Arrow keys move the active cell and call .focus() on the new one: Left/Right move to the same weekday in the adjacent week (column ±1), Up/Down move to the adjacent weekday within the same week (row ±1), resolved by column/row rather than flat-index arithmetic so a move at the top/bottom edge of a week column stops instead of wrapping diagonally into the neighboring week — both clamped to the valid date range and to cells that actually exist (no wrapping onto out-of-range trailing cells in the final partial week). Hovering OR focusing a cell fills a fixed-footprint loupe panel in a permanently reserved slot BESIDE the grid (never overlaid on top of it — an overlay large enough to be legible also covers the cell it's magnifying, which reads as the hover jumping elsewhere) showing that day's stipple pattern at ~4x scale plus a Geist Mono caption reading '14 contributions - Mar 4' (or the exact singular/plural + date for whatever cell is active). The panel occupies the same box whether or not anything is hovered — only its contents toggle via `visibility` — so the component's own size never changes on hover and no cell ever shifts under the cursor. The loupe's entrance is a real animation — scale 0.55->1 and opacity 0->1 over 160ms with an ease-out-expo-style curve, replayed on every new cell via a React `key` keyed to the date so the keyframe restarts each time. Hovered/focused cells also get a slightly heavier cell-border stroke than the resting hairline var(--border), and keyboard focus additionally gets a var(--ns-accent) focus-visible outline distinct from the hover stroke. The main grid's stipple pattern itself is never animated — only the loupe moves. The loupe has its own second, distinct entrance: every time it re-magnifies a new cell, its dots don't just appear at rest — each one starts flung a short distance from its resting position (a per-dot offset drawn from its own deterministic PRNG stream, seeded off the same ISO date so it's stable and never Math.random()) and springs into place on an ease-out-expo curve, staggered a few milliseconds apart per dot, reading as ink landing and settling on paper rather than a uniform pop-in. This settle runs longer than the loupe box's own 160ms zoom-in (roughly 460ms, vs the box's scale/opacity entrance) so the two are legible as two separate events instead of one blurred bloom, and it fires on every hover AND every keyboard-focus change alike (both drive the same `hoverIndex`), so keyboard users see identical motion to pointer users. The scattered dots are clipped to the loupe box's own rounded-rect bounds so none can visually escape it even at their widest starting offset. Implemented with a CSS `@keyframes` animation driving `transform: translate()` from a per-dot `--sx`/`--sy` custom-property starting offset to `(0, 0)`, `animation-delay` staggered per dot index, and `animation-fill-mode: backwards` so a delayed dot sits at its scattered start position rather than its resting one during the delay — no React state, no per-frame JS, the browser's own compositor drives it. `prefers-reduced-motion: reduce` removes both the loupe's zoom-in and its ink-settle animation entirely (dots simply render at their resting position) while leaving the stipple pattern and every non-motion interaction untouched. No color scale anywhere, no canvas — pure SVG, zero dependencies.

Props

PropTypeDefaultDescription
values?Record<string, number>{}Map of ISO date ("YYYY-MM-DD") to an activity count.
endDate?Date—Last day of the 371-day window. Defaults to today.
className?string—extra classes merged onto the rendered root element