amicro
blockactivity-heatmapsource: ok · hit
Activity Dither Heatmap
GitHub-style activity heatmap with interactive dither dots.
npx shadcn add "https://amicro.vercel.app/r/activity-heatmap.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
iconslucide-react
Files1 · source cached 10/8/2026, 12:51:46 PM
src/components/dither-charts/ActivityHeatmap.tsx
import React, { useEffect, useRef, useState, useMemo } from 'react';
import { motion, useSpring, useTransform, useReducedMotion } from 'motion/react';
import { Activity } from 'lucide-react';
import { useCanvasSetup } from '../../hooks/useCanvasSetup';
const PERIODS = [
{ name: 'Last 3 Months', weeks: 12 },
{ name: 'Last 6 Months', weeks: 24 },
];
const smoothstep = (min: number, max: number, value: number) => {
const x = Math.max(0, Math.min(1, (value - min) / (max - min)));
return x * x * (3 - 2 * x);
};
const hash = (x: number, y: number) => {
let h = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453;
return h - Math.floor(h);
};
function AnimatedNumber({ value }: { value: number }) {
const prefersReducedMotion = useReducedMotion();
const spring = useSpring(value, { stiffness: 190, damping: 27, mass: 0.7 });
const display = useTransform(spring, (current) =>
Math.round(current).toLocaleString('en-US')
);
useEffect(() => {
if (prefersReducedMotion) spring.jump(value);
else spring.set(value);
}, [value, spring, prefersReducedMotion]);
return <motion.span className="tabular-nums">{display}</motion.span>;
}
export function ActivityHeatmap({ theme = 'dark', compact = false }: { theme?: 'dark' | 'light'; compact?: boolean }) {
const [periodIndex, setPeriodIndex] = useState(0);
const { canvasRef, rect, isVisible, reducedMotion } = useCanvasSetup();
const period = PERIODS[periodIndex];
const { data, total } = useMemo(() => {
const weeks = period.weeks;
const newData = [];
let tot = 0;
for (let w = 0; w < weeks; w++) {
const col = [];
for (let d = 0; d < 7; d++) {
const val = Math.floor(Math.sin(w * 0.5 + d * 0.2) * 2 + Math.cos(w * 1.2) * 1.5 + 2);
const clamped = Math.max(0, Math.min(4, val));
col.push(clamped);
tot += clamped * 10;
}
newData.push(col);
}
return { data: newData, total: tot };
}, [periodIndex]);
const targetDataRef = useRef(data);
const fromDataRef = useRef(data);
const morphStartTimeRef = useRef(0);
const timeRef = useRef(0);
useEffect(() => {
fromDataRef.current = targetDataRef.current;
targetDataRef.current = data;
morphStartTimeRef.current = performance.now();
}, [data]);
useEffect(() => {
let req: number;
const draw = () => {
if (!isVisible.current) { req = requestAnimationFrame(draw); return; }
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const { width: w, height: h } = rect.current;
if (w === 0 || h === 0) { req = requestAnimationFrame(draw); return; }
timeRef.current += reducedMotion ? 0 : 0.02;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
ctx.save();
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
const prog = reducedMotion ? 1 : Math.min(1, (performance.now() - morphStartTimeRef.current) / 500);
const e = 1 - Math.pow(2, -10 * prog);
const weeks = Math.max(fromDataRef.current.length, targetDataRef.current.length);
const cellSize = 10;
const gap = 2.5;
const totalW = weeks * (cellSize + gap) - gap;
const totalH = 7 * (cellSize + gap) - gap;
const startX = (w - totalW) / 2;
const startY = (h - totalH) / 2;
const colors = ['rgba(255,255,255,0.15)', 'rgba(255,255,255,0.35)', 'rgba(255,255,255,0.55)', 'rgba(255,255,255,0.75)', '#FFFFFF'];
const cell = Math.max(2, Math.round(w / 250));
const t2 = timeRef.current;
for (let wk = 0; wk < weeks; wk++) {
for (let d = 0; d < 7; d++) {
const targetVal = targetDataRef.current[wk]?.[d] || 0;
const fromVal = fromDataRef.current[wk]?.[d] || 0;
const val = fromVal + (targetVal - fromVal) * e;
const x = startX + wk * (cellSize + gap);
const y = startY + d * (cellSize + gap);
const colorIdx = Math.max(0, Math.min(4, Math.round(val)));
ctx.save();
ctx.beginPath();
ctx.rect(x, y, cellSize, cellSize);
ctx.clip();
ctx.globalAlpha = 0.85;
ctx.fillStyle = colors[colorIdx];
for (let tx = Math.floor(x); tx <= Math.ceil(x + cellSize); tx += cell) {
for (let ty = Math.floor(y); ty <= Math.ceil(y + cellSize); ty += cell) {
const jx = tx + cell / 2;
const jy = ty + cell / 2;
const jit = hash(jx, jy);
const waveRaw = reducedMotion ? 0 :
Math.sin(jx * 0.05 + t2) + Math.sin(jy * 0.05 + t2 * 0.7);
const mod = smoothstep(-1.5, 1.5, waveRaw);
const sz = cell * (0.4 + 0.4 * mod) * (0.8 + 0.4 * jit);
ctx.fillRect(tx + (cell - sz)/2, ty + (cell - sz)/2, sz, sz);
}
}
ctx.restore();
}
}
ctx.restore();
req = requestAnimationFrame(draw);
};
req = requestAnimationFrame(draw);
return () => cancelAnimationFrame(req);
}, [reducedMotion]);
return (
<div className="w-full h-full flex flex-col items-center justify-center p-2">
<div className="w-full flex flex-col items-center gap-2">
{!compact && (
<div className="flex items-center justify-between w-full px-2">
<span className={`text-xs font-semibold ${theme === 'dark' ? 'text-white' : 'text-black'}`}>
<AnimatedNumber value={total} /> contributions
</span>
</div>
)}
<div className="w-full flex justify-center">
<canvas ref={canvasRef} className="w-full h-[120px]" />
</div>
</div>
</div>
);
}