"use client"; import { useId, useMemo, useRef, useState } from "react"; import { motion, useReducedMotion } from "motion/react"; type HeatCalendarProps = { /** Values by date, like { "2026-10-12": 8 }. Leave out for sample data. */ data?: Record; /** Last day shown, as YYYY-MM-DD. */ end?: string; /** Weeks shown, each a column. */ weeks?: number; /** Word for one count, in the tooltip and total. */ unit?: string; /** Called when a day is pressed or Enter is hit on it. */ onSelect?: (date: string, value: number) => void; /** Set the width here. Everything scales with it. */ className?: string; }; type Day = { date: string; label: string; value: number; level: number; col: number; row: number; blob: string }; const CELL = 19; const LEFT = 42; const TOP = 44; const DAY_MS = 86400000; const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const HEAT = ["#2c2826", "#5e2210", "#a5330f", "#e8660f", "#ffc34d"]; // cold to hot, one steady ramp const hash = (n: number) => { let h = Math.imul(n ^ 0x9e3779b9, 0x85ebca6b); h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; }; // A lump of coal: a rough ring of points, the same on server and browser. const lump = (x: number, y: number, seed: number) => { let d = ""; for (let i = 0; i < 7; i++) { const a = (i / 7) * Math.PI * 2 + hash(seed + 99); const r = 7.2 + hash(seed * 7 + i) * 1.9; d += `${d ? "L" : "M"}${(x + Math.cos(a) * r).toFixed(1)} ${(y + Math.sin(a) * r).toFixed(1)}`; } return `${d}Z`; }; const sample = (i: number, weekday: number) => { const rest = weekday >= 5 ? 0.3 : 1; const wave = 0.55 + 0.45 * Math.sin(i / 9); const v = hash(i + 7) * wave * rest; return v < 0.1 ? 0 : Math.round(v * 16); }; /* HeatCalendar: a contribution calendar raked out as a bed of coals in an iron tray. Each lump is a day: cold ash for none, up through dull red to white-gold for your hottest days. The hot ones breathe. Hover blows on the coals and reads the day out on a tag. */ export function HeatCalendar({ data, end = "2026-11-01", weeks = 26, unit = "commits", onSelect, className = "w-full max-w-2xl" }: HeatCalendarProps) { const reduce = useReducedMotion(); const id = useId().replace(/:/g, ""); const svg = useRef(null); const [active, setActive] = useState(null); const [puff, setPuff] = useState<{ n: number; x: number; y: number } | null>(null); const [breath, setBreath] = useState<{ x: number; y: number } | null>(null); const { days, months, total, max } = useMemo(() => { const last = Date.parse(`${end}T00:00:00Z`); const lastRow = (new Date(last).getUTCDay() + 6) % 7; const first = last - ((weeks - 1) * 7 + lastRow) * DAY_MS; const raw: { date: string; ms: number; value: number; i: number }[] = []; for (let i = 0, ms = first; ms <= last; i++, ms += DAY_MS) { const date = new Date(ms).toISOString().slice(0, 10); raw.push({ date, ms, i, value: data ? (data[date] ?? 0) : sample(i, i % 7) }); } const max = Math.max(1, ...raw.map((d) => d.value)); const days: Day[] = raw.map(({ date, ms, value, i }) => { const d = new Date(ms); const col = Math.floor(i / 7); const row = i % 7; return { date, value, col, row, level: value === 0 ? 0 : Math.ceil((value / max) * 4), label: `${DAYS[row]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`, blob: lump(LEFT + col * CELL + CELL / 2, TOP + row * CELL + CELL / 2, i + 1), }; }); const months: { name: string; col: number }[] = []; days.forEach((d) => { const name = MONTHS[Number(d.date.slice(5, 7)) - 1]; if (d.row === 0 && months.at(-1)?.name !== name) months.push({ name, col: d.col }); }); return { days, months, total: raw.reduce((sum, d) => sum + d.value, 0), max }; }, [data, end, weeks]); const width = LEFT + weeks * CELL + 18; const height = TOP + 7 * CELL + 40; const shown = active === null ? null : days[active]; const tag = shown && { x: Math.min(width - 150, Math.max(8, LEFT + shown.col * CELL - 56)), y: shown.row < 2 ? TOP + shown.row * CELL + 26 : TOP + shown.row * CELL - 30, }; const pick = (i: number) => { const d = days[i]; setPuff((p) => ({ n: (p?.n ?? 0) + 1, x: LEFT + d.col * CELL + CELL / 2, y: TOP + d.row * CELL + CELL / 2 })); onSelect?.(d.date, d.value); }; const move = (e: React.KeyboardEvent) => { const step = { ArrowLeft: -7, ArrowRight: 7, ArrowUp: -1, ArrowDown: 1 }[e.key]; if (step) { e.preventDefault(); setActive((a) => Math.min(days.length - 1, Math.max(0, (a ?? days.length - 1) + step))); } else if ((e.key === "Enter" || e.key === " ") && active !== null) { e.preventDefault(); pick(active); } }; return (
e.preventDefault()} onFocus={() => setActive((a) => a ?? days.length - 1)} onBlur={() => setActive(null)} onPointerMove={(e) => { const box = svg.current?.getBoundingClientRect(); if (box) setBreath({ x: ((e.clientX - box.left) / box.width) * width, y: ((e.clientY - box.top) / box.height) * height }); }} onPointerLeave={() => { setActive(null); setBreath(null); }} > {/* The tray. */} {[[9, 9], [width - 9, 9], [9, height - 9], [width - 9, height - 9]].map(([x, y]) => ( ))} {months.map((m) => ( {m.name} ))} {[0, 2, 4].map((r) => ( {DAYS[r]} ))} {total} {unit} Cold Hot {HEAT.map((c, i) => ( ))} {/* The coals: glow under, lump on top. */} {days.map((d) => d.level >= 2 ? ( ) : null, )} {days.map((d, i) => ( setActive(i)} onClick={() => pick(i)} className="cursor-pointer"> ))} {breath && !reduce && } {puff && !reduce && ( {[0, 1, 2, 3, 4, 5, 6].map((i) => ( ))} )} {/* The tag. */} {shown && tag && ( {shown.label}: {shown.value} )} {shown ? `${shown.label}, ${shown.value} ${unit}, highest is ${max}` : ""}
); }