Componentes
[gráficas] · d3 / motion

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.

d3motion
@/components/ui/contribution-graph
Actividad del repositorio0 contribuciones
FebMarAbrMayJunJulAgoSepOctLunMiéVie
MenosMás
1'use client';
2 
3/**
4 * ContributionGraph
5 * ─────────────────────────────────────────────────────────────
6 * Calendario de actividad estilo GitHub: una cuadrícula de
7 * semanas (columnas) x días (filas) donde cada celda se colorea
8 * 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 props
14 * (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ístico
29 * (ideal para previews). Expone un ref imperativo:
30 *
31 * <ContributionGraph ref={ref} data={data} />
32 * ref.current.replay() // reanima la entrada
33 * ref.current.getTotal() // suma total de contribuciones
34 */
35 
36import * as React from 'react';
37import * as d3 from 'd3';
38import { motion } from 'motion/react';
39 
40// ─────────────────────────────────────────────────────────────
41// Helpers locales — cero dependencias del proyecto
42// ─────────────────────────────────────────────────────────────
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 para
60// 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// Componente
110// ─────────────────────────────────────────────────────────────
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 = domingo
122 
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 ...props
153 },
154 ref
155) {
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 = domingo
275 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 <svg
334 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 <text
343 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 <text
358 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 ...(isActive
382 ? { outline: '1.5px solid currentColor', outlineOffset: 1 }
383 : {}),
384 };
385 return (
386 <motion.rect
387 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 animated
399 ? {
400 duration: 0.3,
401 delay: (ci * 7 + ri) * staggerDelay / 1000,
402 ease: 'backOut',
403 }
404 : undefined
405 }
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 <div
438 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 {tooltip
448 ? 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 <span
460 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 
[ejemplos]
01

Demo con semilla

Sin `data`, `demoSeed` genera actividad determinística: más movimiento entre semana y picos tipo racha. Ideal para previews y placeholders.

Actividad reciente0 contribuciones
FebMarAbrMayJunJulAgoSepOctLunMiéVie
02

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`.

PRs del trimestre0 contribuciones
JunJulAgoSepOctLunMiéVie
03

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).

Trazabilidad violeta0 contribuciones
AbrMayJunJulAgoSepOctLunMiéVie

Uso

[documentación]

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

PropTipoDefaultDescripción
dataArray<{date, count}>—Contribuciones por día (ISO o Date).
demoSeednumber—Si no hay data, genera un demo determinístico.
startDate / endDatestring | Datehoy ± 52 semanasRango de fechas del gráfico.
weeksnumber52Columnas (semanas) a mostrar.
weekStartsOn0 | 11Lunes (1) o domingo (0).
cellSize / cellGapnumber11 / 3Tamaño y separación de celdas en px.
palettestring[]verde GitHubColores de intensidad (nivel 0 → nivel máximo).
levelsnumber5Niveles de la leyenda.
animatedbooleantrueEntrada con stagger + total animado.
tooltip(day) => ReactNode—Render prop del tooltip.
onCellClick(date, count) => void—Click en una celda.

Acciones del componente

Search for a command to run...

Potenciá tu negocio con SFESS

Software a medida, IA integrada y ciberseguridad avanzada para empresas.

0años de ingeniería
0proyectos entregados
0% uptime
Servicios destacados
  • Consultoría SEO Avanzado & Analítica

    Auditoría técnica profunda para posicionar tu negocio en los primeros resultados de Google.

    SEO TécnicoGA4
  • Páginas Web Corporativas Complejas

    Plataformas empresariales masivas con múltiples secciones y animaciones fluidas.

    Framer MotionMulti-page
  • Integración de Inteligencia Artificial en Negocios

    Automatizamos la atención, las reservas y las ventas de tu negocio con agentes de IA.

    AI AgentsChatbots
Nuestro ecosistema
WORKENTA_OSSFESS_SOLUTIONSPRANALUZ_YOGAREMY_AI
Ver todos los servicios