Ir al contenido
Evaluar mi proyecto
Componentes · Datos y visualizaciónDisponible

Mapa de calor de actividad

Calendario de contribuciones con escala de cinco niveles, leyenda y detalle del día al apuntar o enfocar. Datos de muestra.

561 contribuciones en los últimos 26 semanas
Apunta a un día para ver el detalle

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

datos-calor.tsx79 líneas
Descargar
"use client";

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

/** Mapa de calor de actividad por día, estilo calendario de contribuciones: columnas por semana, escala de cinco niveles, leyenda y detalle del día al apuntar o enfocar. Datos de muestra deterministas. React + TypeScript, sin dependencias. */
export type DatosCalorProps = {
  /** Valores por día, del más antiguo al más reciente. Si se omite se genera una muestra. */
  values?: number[];
  weeks?: number;
  unit?: string;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcdca-root { box-sizing: border-box; width: fit-content; max-width: 100%; padding: 20px; border: 1px solid #262b33; border-radius: 14px; background: #0d1117; color: #c9d1d9; font: 500 12px/1.3 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcdca-root *, .gcdca-root *::before, .gcdca-root *::after { box-sizing: border-box; }
.gcdca-root :focus-visible { outline: 2px solid #58a6ff; outline-offset: 1px; }
.gcdca-title { margin: 0 0 14px; font-size: 14px; font-weight: 600; color: #e6edf3; }
.gcdca-scroll { overflow-x: auto; padding-bottom: 6px; }
.gcdca-grid { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 13px); grid-auto-columns: 13px; gap: 3px; width: max-content; }
.gcdca-cell { padding: 0; border: 0; border-radius: 3px; background: var(--c); cursor: pointer; transition: transform .12s, outline-color .12s; outline: 1px solid rgba(255, 255, 255, .06); outline-offset: -1px; }
.gcdca-cell:hover, .gcdca-cell:focus-visible { transform: scale(1.35); }
.gcdca-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; color: #8b949e; }
.gcdca-detail { min-height: 16px; color: #c9d1d9; }
.gcdca-legend { display: flex; align-items: center; gap: 4px; }
.gcdca-legend i { width: 13px; height: 13px; border-radius: 3px; background: var(--c); outline: 1px solid rgba(255, 255, 255, .06); outline-offset: -1px; }
.gcdca-legend span { margin: 0 4px; }`;

const SCALE = ["#161b22", "#0e4429", "#006d32", "#26a641", "#39d353"];

function sample(days: number) {
  let seed = 7;
  const rand = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
  return Array.from({ length: days }, (_, i) => {
    const weekday = i % 7;
    const base = weekday === 0 || weekday === 6 ? 0.25 : 0.7;
    const wave = 0.5 + 0.5 * Math.sin(i / 11);
    return rand() < base * (0.4 + wave) ? Math.round(rand() * 9) + 1 : 0;
  });
}

export function DatosCalor({ values, weeks = 26, unit = "contribuciones", className = "", style }: DatosCalorProps) {
  const data = useMemo(() => values ?? sample(weeks * 7), [values, weeks]);
  const max = Math.max(1, ...data);
  const [active, setActive] = useState<number | null>(null);
  const level = (v: number) => (v === 0 ? 0 : Math.min(4, Math.ceil((v / max) * 4)));
  const today = new Date();
  const dateOf = (i: number) => new Date(today.getFullYear(), today.getMonth(), today.getDate() - (data.length - 1 - i));
  const total = data.reduce((sum, v) => sum + v, 0);
  const label = (i: number) => `${data[i]} ${unit} el ${new Intl.DateTimeFormat("es-MX", { dateStyle: "long" }).format(dateOf(i))}`;

  return (
    <figure className={`gcdca-root ${className}`} style={{ margin: 0, ...style }}>
      <style>{CSS}</style>
      <figcaption className="gcdca-title">
        {total} {unit} en los últimos {weeks} semanas
      </figcaption>
      <div className="gcdca-scroll">
        <div className="gcdca-grid" role="list">
          {data.map((v, i) => (
            <button key={i} className="gcdca-cell" type="button" role="listitem" style={{ "--c": SCALE[level(v)] } as CSSProperties} aria-label={label(i)} onPointerEnter={() => setActive(i)} onFocus={() => setActive(i)} onPointerLeave={() => setActive(null)} onBlur={() => setActive(null)} />
          ))}
        </div>
      </div>
      <div className="gcdca-foot">
        <span className="gcdca-detail" aria-live="polite">{active !== null ? label(active) : "Apunta a un día para ver el detalle"}</span>
        <span className="gcdca-legend" aria-hidden="true">
          <span>Menos</span>
          {SCALE.map((c) => (
            <i key={c} style={{ "--c": c } as CSSProperties} />
          ))}
          <span>Más</span>
        </span>
      </div>
    </figure>
  );
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/datos-calor

Ejemplo de uso de Mapa de calor de actividad

Después de copiar datos-calor.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
import { DatosCalor } from "./datos-calor";

export default function Ejemplo() {
  return <DatosCalor unit="Ejemplo" />;
}

Propiedades de DatosCalor

DatosCalor acepta 5 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.

PropiedadTipoDescripción
values?number[]Valores por día, del más antiguo al más reciente. Si se omite se genera una muestra.
weeks?numberOpcional.
unit?stringOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 79 líneas (4 KB) y no necesita @keyframes. Esto es lo que hace Mapa de calor de actividad por quien lo usa:

  • Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
  • Declara roles ARIA para lectores de pantalla: list, listitem.
  • Es un componente de cliente de Next.js: incluye la directiva "use client".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)