ContributionGraph
Calendario de actividad estilo GitHub (semanas x días) con intensidad por contribuciones, fechas parametrizables, tooltips y entrada animada. Componente standalone, copiable a otros proyectos.
@/components/ui/contribution-graph1'use client';2 3/**4 * ContributionGraph5 * ─────────────────────────────────────────────────────────────6 * Calendario de actividad estilo GitHub: una cuadrícula de7 * semanas (columnas) x días (filas) donde cada celda se colorea8 * por intensidad según la cantidad de contribuciones (commits,9 * PRs, lo que quieras) de ese día.10 *11 * Componente STANDALONE pensado para copiarse a otros proyectos:12 * no depende de @/lib, ni de tokens del tema, ni de tailwind —13 * solo usa React + d3 + motion. Todo lo visual sale de props14 * (palette, tamaño de celda, textos) con defaults razonables.15 *16 * Librerías necesarias: `d3` y `motion`.17 *18 * Uso mínimo:19 *20 * const data = [21 * { date: "2025-01-03", count: 4 },22 * { date: "2025-01-04", count: 1 },23 * ...24 * ];25 *26 * <ContributionGraph data={data} />27 *28 * Sin `data` y con `demoSeed` genera un demo determinístico29 * (ideal para previews). Expone un ref imperativo:30 *31 * <ContributionGraph ref={ref} data={data} />32 * ref.current.replay() // reanima la entrada33 * ref.current.getTotal() // suma total de contribuciones34 */35 36import * as React from 'react';37import * as d3 from 'd3';38import { motion } from 'motion/react';39 40// ─────────────────────────────────────────────────────────────41// Helpers locales — cero dependencias del proyecto42// ─────────────────────────────────────────────────────────────43function cn(...parts) {44 return parts.filter(Boolean).join(' ');45}46 47// Paleta default estilo GitHub dark (nivel 0 = celda vacía).48const DEFAULT_PALETTE = [49 'rgba(148,163,184,0.14)',50 '#0e4429',51 '#006d32',52 '#26a641',53 '#39d353',54];55 56const DEFAULT_MONTHS = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];57const DEFAULT_WEEKDAY_LABELS = ['Lun', 'Mié', 'Vie'];58 59// PRNG determinista (LCG) — mismo truco que usa RadialChart para60// que el demo sea reproducible con la misma semilla.61function createRandom(seed) {62 let s = (Math.abs(seed) * 9973 + 17) % 2147483647;63 return function rand() {64 s = (s * 9301 + 49297) % 233280;65 return s / 233280;66 };67}68 69function toDate(value) {70 if (value == null) return null;71 if (value instanceof Date) return value;72 return new Date(value);73}74 75function normalizeData(data) {76 const map = new Map();77 if (!Array.isArray(data)) return map;78 for (const item of data) {79 const date = toDate(item?.date);80 if (!date || Number.isNaN(+date)) continue;81 const day = d3.timeDay(date);82 const count = Math.max(0, Number(item?.count) || 0);83 map.set(+day, (map.get(+day) || 0) + count);84 }85 return map;86}87 88// Demo de actividad realista: hay más actividad entre semana,89// con picos ocasionales tipo "racha" como en GitHub.90function generateDemo({ start, end, seed = 1 }) {91 const rand = createRandom(seed);92 const rows = [];93 const tz = start.getTimezoneOffset();94 const offset = (d) => new Date(+d - tz * 60 * 1000);95 let day = offset(start);96 while (+day <= +offset(end)) {97 const dow = day.getDay();98 const weekend = dow === 0 || dow === 6;99 const base = weekend ? 0.16 : 0.62;100 const streak = rand() < 0.09 ? 3 + Math.floor(rand() * 5) : 0;101 const count = rand() < base ? 1 + Math.floor(rand() * 4) + streak : 0;102 rows.push({ date: day, count });103 day = d3.timeDay.offset(day, 1);104 }105 return rows;106}107 108// ─────────────────────────────────────────────────────────────109// Componente110// ─────────────────────────────────────────────────────────────111const ContributionGraph = React.forwardRef(function ContributionGraph(112 {113 // ── Datos ──────────────────────────────────────────────114 data,115 demoSeed,116 117 // ── Fechas ─────────────────────────────────────────────118 startDate,119 endDate,120 weeks = 52,121 weekStartsOn = 1, // 1 = lunes, 0 = domingo122 123 // ── Apariencia ─────────────────────────────────────────124 cellSize = 11,125 cellGap = 3,126 borderRadius = 2,127 palette,128 levels = 5,129 emptyColor,130 title = 'Contribuciones',131 showTitle = true,132 showTotal = true,133 showMonths = true,134 showWeekdays = true,135 showLegend = true,136 monthLabels = DEFAULT_MONTHS,137 weekdayLabels = DEFAULT_WEEKDAY_LABELS,138 139 // ── Animación ──────────────────────────────────────────140 animated = true,141 staggerDelay = 2.5,142 animationDuration = 550,143 144 // ── Interacción ────────────────────────────────────────145 tooltipEnabled = true,146 tooltip,147 onCellClick,148 tooltipDateFormat = '%b %d, %Y',149 150 // ── Misc ───────────────────────────────────────────────151 className,152 ...props153 },154 ref155) {156 const colors = React.useMemo(157 () => (Array.isArray(palette) && palette.length >= 2 ? palette : DEFAULT_PALETTE),158 [palette]159 );160 const empty = emptyColor || colors[0];161 162 // Replay control (ref imperativo) → reanima el stagger.163 const [replayTick, setReplayTick] = React.useState(0);164 165 const today = React.useMemo(() => d3.timeDay(new Date()), []);166 const todayTs = +today;167 168 // ── Normalizar datos a un Map<timestamp, count> ──────────169 const { countMap, total } = React.useMemo(() => {170 const map = normalizeData(data);171 let sum = 0;172 map.forEach((v) => (sum += v));173 return { countMap: map, total: sum };174 }, [data]);175 176 // ── Construcción del grid (d3.timeDay / d3.timeWeek) ─────177 const grid = React.useMemo(() => {178 const rawEnd = toDate(endDate) || new Date();179 const end = d3.timeDay(rawEnd);180 const weekInterval = weekStartsOn === 1 ? d3.timeMonday : d3.timeSunday;181 const endWeek = weekInterval.floor(end);182 const start = weekInterval.offset(endWeek, -(weeks - 1));183 184 // Si el usuario da startDate, respetarlo (y recalcular semanas).185 const realStart = toDate(startDate) ? d3.timeDay(toDate(startDate)) : null;186 const first = realStart && realStart < start ? realStart : start;187 const firstWeek = weekInterval.floor(first);188 189 const cols = [];190 let cursor = firstWeek;191 let guard = 0;192 while (+cursor <= +end && guard < 400) {193 const days = [];194 for (let i = 0; i < 7; i++) {195 days.push(weekInterval.offset(cursor, i));196 }197 cols.push(days);198 cursor = weekInterval.offset(cursor, 1);199 guard++;200 }201 202 // Demo determinístico si no hay data.203 let map = countMap;204 let sum = total;205 if (map.size === 0 && demoSeed != null) {206 const demo = generateDemo({ start: first, end, seed: demoSeed });207 map = normalizeData(demo);208 sum = 0;209 map.forEach((v) => (sum += v));210 }211 212 // Rellenar celdas con su count.213 const cells = cols.map((col) =>214 col.map((day) => {215 const ts = +day;216 return {217 date: day,218 count: map.get(ts) || 0,219 future: ts > todayTs,220 };221 })222 );223 224 return { cols: cells, start: first, end, sum };225 }, [countMap, total, weeks, weekStartsOn, startDate, endDate, demoSeed, todayTs]);226 227 const { cols, sum } = grid;228 229 // ── Escala de intensidad (d3.scaleQuantize) ──────────────230 const maxCount = React.useMemo(() => {231 let max = 0;232 for (const col of cols) for (const c of col) if (c.count > max) max = c.count;233 return max;234 }, [cols]);235 236 const colorScale = React.useMemo(() => {237 const domain = [0, maxCount > 0 ? maxCount : 1];238 return d3.scaleQuantize().domain(domain).range(colors);239 }, [maxCount, colors]);240 241 // ── Etiquetas de mes y día ───────────────────────────────242 const monthMarks = React.useMemo(() => {243 const marks = [];244 let prev = -1;245 cols.forEach((col, ci) => {246 const hasVisible = col.some((c) => !c.future);247 if (!hasVisible) return;248 const firstVisible = col.find((c) => !c.future);249 const m = firstVisible.date.getMonth();250 if (m !== prev) {251 marks.push({ ci, m });252 prev = m;253 }254 });255 return marks;256 }, [cols]);257 258 // Hover state para el tooltip.259 const [hovered, setHovered] = React.useState(null);260 const hovering = React.useRef(false);261 262 // Layout en px del SVG.263 const monthH = 16;264 const weekdayW = 26;265 const topPad = monthH + 4;266 const cellW = cellSize + cellGap;267 const W = weekdayW + cols.length * cellW;268 const H = topPad + 7 * cellW + 2;269 270 const weekdayRows = React.useMemo(() => {271 if (!showWeekdays) return [];272 const rows = [];273 for (let r = 0; r < 7; r++) {274 const dow = (r + weekStartsOn) % 7; // 0 = domingo275 if (dow === 1 || dow === 3 || dow === 5) {276 rows.push({ r, label: weekdayLabels[(dow - 1) / 2] });277 }278 }279 return rows;280 }, [showWeekdays, weekStartsOn, weekdayLabels]);281 282 // ── Conteo animado del total (d3.easeCubicOut) ───────────283 const [displaySum, setDisplaySum] = React.useState(animated ? 0 : sum);284 React.useEffect(() => {285 if (!animated) {286 setDisplaySum(sum);287 return;288 }289 let raf;290 const start = performance.now();291 function tick(now) {292 const t = Math.min(1, (now - start) / animationDuration);293 setDisplaySum(Math.round(sum * d3.easeCubicOut(t)));294 if (t < 1) raf = requestAnimationFrame(tick);295 }296 raf = requestAnimationFrame(tick);297 return () => cancelAnimationFrame(raf);298 }, [sum, animated, animationDuration, replayTick]);299 300 React.useImperativeHandle(ref, () => ({301 replay() {302 setReplayTick((t) => t + 1);303 },304 getTotal() {305 return sum;306 },307 }));308 309 const defaultTooltip = ({ date, count, total }) => (310 <>311 <span className="font-semibold">{count} contribuciones</span>312 <span className="opacity-80">{d3.timeFormat(tooltipDateFormat)(date)}</span>313 <span className="opacity-60">de {total} en el período</span>314 </>315 );316 317 const active = hovered && hovered.col;318 319 return (320 <div className={cn('flex w-full flex-col gap-3 text-current', className)} {...props}>321 {/* Encabezado */}322 {(showTitle || showTotal) && (323 <div className="flex items-baseline gap-2">324 {showTitle && <span className="text-sm font-semibold">{title}</span>}325 {showTotal && (326 <span className="text-sm tabular-nums opacity-80">{displaySum} contribuciones</span>327 )}328 </div>329 )}330 331 {/* Grid */}332 <div className="relative w-full">333 <svg334 viewBox={`0 0 ${W} ${H}`}335 className="block h-auto w-full select-none"336 role="img"337 aria-label={`Gráfica de contribuciones: ${sum} en total`}338 >339 {/* Meses */}340 {showMonths &&341 monthMarks.map(({ ci, m }) => (342 <text343 key={`${ci}-${m}`}344 x={weekdayW + ci * cellW}345 y={monthH - 6}346 fontSize={9.5}347 fill="currentColor"348 opacity={0.5}349 style={{ textAnchor: 'start' }}350 >351 {monthLabels[m]}352 </text>353 ))}354 355 {/* Días de la semana */}356 {weekdayRows.map(({ r, label }) => (357 <text358 key={r}359 x={weekdayW - 5}360 y={topPad + r * cellW + cellSize / 2 + 3}361 fontSize={9}362 fill="currentColor"363 opacity={0.45}364 style={{ textAnchor: 'end' }}365 >366 {label}367 </text>368 ))}369 370 {/* Celdas */}371 <g key={replayTick}>372 {cols.map((col, ci) =>373 col.map((cell, ri) => {374 if (cell.future) return null;375 const isActive = active && active.col === ci && active.row === ri;376 const x = weekdayW + ci * cellW;377 const y = topPad + ri * cellW;378 const cellStyle = {379 transformBox: 'fill-box',380 transformOrigin: 'center',381 ...(isActive382 ? { outline: '1.5px solid currentColor', outlineOffset: 1 }383 : {}),384 };385 return (386 <motion.rect387 key={`${ci}-${ri}`}388 x={x}389 y={y}390 width={cellSize}391 height={cellSize}392 rx={borderRadius}393 fill={cell.count > 0 ? colorScale(cell.count) : empty}394 style={cellStyle}395 initial={animated ? { scale: 0 } : false}396 animate={{ scale: 1 }}397 transition={398 animated399 ? {400 duration: 0.3,401 delay: (ci * 7 + ri) * staggerDelay / 1000,402 ease: 'backOut',403 }404 : undefined405 }406 onMouseEnter={() => {407 if (tooltipEnabled) setHovered({ col: ci, row: ri, ...cell });408 }}409 onMouseLeave={() => {410 if (tooltipEnabled && !hovering.current) setHovered(null);411 }}412 onMouseDown={(e) => {413 if (!tooltipEnabled) return;414 e.stopPropagation();415 setHovered({ col: ci, row: ri, ...cell });416 hovering.current = true;417 document.addEventListener(418 'mouseup',419 () => {420 hovering.current = false;421 setHovered(null);422 },423 { once: true }424 );425 }}426 onClick={() => onCellClick && onCellClick(cell.date, cell.count)}427 className="cursor-pointer"428 />429 );430 })431 )}432 </g>433 </svg>434 435 {/* Tooltip superpuesto (coordenadas en %) */}436 {tooltipEnabled && active && (437 <div438 className="pointer-events-none absolute z-10 flex flex-col items-center gap-0.5 rounded-md px-2.5 py-1.5 text-center text-[11px] leading-tight shadow-lg"439 style={{440 left: `${(weekdayW + active.col * cellW + cellSize / 2) / W * 100}%`,441 top: `${(topPad + active.row * cellW + cellSize / 2) / H * 100}%`,442 transform: `translate(-50%, ${active.row <= 1 ? '140%' : '-140%'})`,443 background: '#101418',444 color: '#eceef2',445 }}446 >447 {tooltip448 ? tooltip({ date: active.date, count: active.count, total: sum })449 : defaultTooltip({ date: active.date, count: active.count, total: sum })}450 </div>451 )}452 </div>453 454 {/* Leyenda */}455 {showLegend && (456 <div className="flex items-center gap-1.5 self-end text-[10px] uppercase tracking-wider opacity-60">457 <span>Menos</span>458 {colors.slice(0, Math.max(2, Math.min(levels, colors.length))).map((c, i) => (459 <span460 key={i}461 className="inline-block"462 style={{463 width: cellSize - 2,464 height: cellSize - 2,465 borderRadius,466 background: c,467 }}468 />469 ))}470 <span>Más</span>471 </div>472 )}473 </div>474 );475});476 477export default ContributionGraph;478 Demo con semilla
Sin `data`, `demoSeed` genera actividad determinística: más movimiento entre semana y picos tipo racha. Ideal para previews y placeholders.
Datos reales por fecha
El camino de producción: un arreglo de `{ date, count }`. Las fechas aceptan ISO o `Date` y los counts se suman por día. El rango se controla con `startDate`, `endDate` o `weeks`.
Paleta, celdas y tooltip custom
Personaliza la paleta de 5 niveles, el tamaño/redondeo de celda y el contenido del tooltip con la render prop `tooltip` (ideal para adaptarlo a tu marca).
Uso
Cómo funciona
El gráfico arma una cuadrícula de 7 filas (días) por N columnas (semanas). La última columna se alinea a la semana actual; los días posteriores a hoy no se dibujan, igual que GitHub. Cada celda guarda el acumulado de contribuciones de ese día.
Datos y fechas
La prop `data` recibe un arreglo de `{ date, count }` donde `date` es una fecha ISO ("YYYY-MM-DD") o un objeto Date. Si repetiste un día, los counts se suman. Sin `data` y con `demoSeed` se genera un demo reproducible. `startDate`/`endDate` recortan el rango y `weeks` define cuántas columnas se pintan (default 52).
Escala de intensidad
El color de cada celda lo decide `d3.scaleQuantize` sobre el máximo del período: el valor más bajo cae en el primer color de `palette` (celda vacía) y el más alto en el último. Con 5 colores por default tienes el look clásico de GitHub, pero `palette` acepta cualquier gradiente.
Integrar la API de GitHub
Puedes alimentar el gráfico desde la API de GitHub: por ejemplo, `GET /users/:user/events/public` y agregar por día los eventos `PushEvent`, `PullRequestEvent` e `IssueCommentEvent`. Mapea el resultado a `{ date, count }` y pásalo como `data`. También funciona para tus propios endpoints de commits o métricas.
Portabilidad
El archivo es standalone: solo importa React, d3 y motion. No usa `@/lib`, ni tokens del tema, ni Tailwind. Cópialo a cualquier proyecto (React o Next) y añade `d3` y `motion` a tus dependencias. Expone un ref imperativo con `replay()` para reanimar la entrada y `getTotal()`.
Props
Acciones del componente
Search for a command to run...
Software a medida, IA integrada y ciberseguridad avanzada para empresas.
Consultoría SEO Avanzado & Analítica
Auditoría técnica profunda para posicionar tu negocio en los primeros resultados de Google.
SEO TécnicoGA4Páginas Web Corporativas Complejas
Plataformas empresariales masivas con múltiples secciones y animaciones fluidas.
Framer MotionMulti-pageIntegración de Inteligencia Artificial en Negocios
Automatizamos la atención, las reservas y las ventas de tu negocio con agentes de IA.
AI AgentsChatbots