Activity Heatmap

Grille de type contributions pour visualiser l'activite operationnelle dans le temps. Installez Activity Heatmap depuis le registre VLLNT UI avec la CLI shadcn.

Signaler un bug

Grille de type contributions pour visualiser l'activite operationnelle dans le temps. Fait partie de la famille data dans VLLNT UI, il est publie comme entree de registre lisible par machine — copiez la source directement dans votre application avec la CLI shadcn et possedez-la, sans dependance d'execution a une bibliotheque de composants.

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Installation

Ajoutez Activity Heatmap a votre projet avec la CLI shadcn. La source arrive dans votre code, prete a etre adaptee :

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/activity-heatmap.json

Source

import * as React from "react"; import { normalizeDate } from "../../lib/format"; import type { HeadingTag } from "../../lib/types"; import { cn } from "../../lib/utils"; export type ActivityHeatmapItem = { count: number; date: string; }; export type ActivityHeatmapProps = React.ComponentPropsWithoutRef<"div"> & { /** Heading tag for the title. Defaults to `h2`. */ as?: HeadingTag; data: ActivityHeatmapItem[]; description?: string; endDate?: Date | number | string; title?: string; weeks?: number; }; type DayCell = { count: number; date: Date; key: string; level: number; }; const WEEKDAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const VISIBLE_DAY_LABELS = new Set(["Mon", "Wed", "Fri"]); const LEVEL_CLASS_NAMES = [ "bg-muted", "bg-emerald-500/25", "bg-emerald-500/45", "bg-emerald-500/65", "bg-emerald-500", ]; function toUtcDate(date: Date): Date { return new Date( Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), ); } function addUtcDays(date: Date, days: number): Date { return new Date( Date.UTC( date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days, ), ); } function formatDayKey(date: Date): string { return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`; } function getIntensityLevel(count: number, maxCount: number): number { if (count <= 0 || maxCount <= 0) { return 0; } const ratio = count / maxCount; if (ratio < 0.25) { return 1; } if (ratio < 0.5) { return 2; } if (ratio < 0.75) { return 3; } return 4; } function getGridData( data: ActivityHeatmapItem[], endDate: Date, weeks: number, ): DayCell[][] { const normalizedEnd = toUtcDate(endDate); const startDate = addUtcDays(normalizedEnd, -(weeks * 7 - 1)); const countsByDate = new Map<string, number>(); data.forEach((item) => { const normalizedDate = toUtcDate(normalizeDate(item.date)); countsByDate.set(formatDayKey(normalizedDate), item.count); }); const maxCount = Math.max(...data.map((item) => item.count), 0); return Array.from({ length: weeks }, (_, weekIndex) => { return Array.from({ length: 7 }, (_, dayIndex) => { const date = addUtcDays(startDate, weekIndex * 7 + dayIndex); const key = formatDayKey(date); const count = countsByDate.get(key) ?? 0; return { count, date, key, level: getIntensityLevel(count, maxCount), }; }); }); } const MONTH_LABEL_FORMATTER = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC", }); const TOOLTIP_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "short", timeZone: "UTC", year: "numeric", }); function formatMonthLabel(date: Date): string { return MONTH_LABEL_FORMATTER.format(date); } function formatTooltip(date: Date, count: number): string { const formattedDate = TOOLTIP_DATE_FORMATTER.format(date); return `${count} activity ${count === 1 ? "event" : "events"} on ${formattedDate}`; } function HeatmapGrid({ gridData, weeks, }: { gridData: DayCell[][]; weeks: number; }) { return ( <div className="min-w-[640px] space-y-3"> <div className="grid gap-2 text-xs text-muted-foreground" style={{ gridTemplateColumns: `40px repeat(${weeks}, minmax(0, 1fr))` }} > <span /> {gridData.map((week) => ( <span className="text-center" key={`month-${week[0]?.key}`}> {week[0] && week[0].date.getUTCDate() <= 7 ? formatMonthLabel(week[0].date) : ""} </span> ))} </div> <div className="grid gap-2" style={{ gridTemplateColumns: `40px repeat(${weeks}, minmax(0, 1fr))` }} > <div className="grid grid-rows-7 gap-2 pt-1 text-xs text-muted-foreground"> {WEEKDAY_LABELS.map((label) => ( <span className="h-4 leading-4" key={label}> {VISIBLE_DAY_LABELS.has(label) ? label : ""} </span> ))} </div> {gridData.map((week) => ( <div className="grid grid-rows-7 gap-2" key={`week-${week[0]?.key}`}> {week.map((day) => ( <div className={cn( "h-4 rounded-sm border border-border/40 transition-colors", LEVEL_CLASS_NAMES[day.level], )} key={day.key} role="img" title={formatTooltip(day.date, day.count)} /> ))} </div> ))} </div> <div className="flex items-center justify-end gap-2 text-xs text-muted-foreground"> <span>Less</span> {LEVEL_CLASS_NAMES.map((className) => ( <span className={cn( "size-3 rounded-[3px] border border-border/40", className, )} key={`legend-${className}`} /> ))} <span>More</span> </div> </div> ); } export const ActivityHeatmap = ({ as: Heading = "h2", className, data, description, endDate = new Date(), ref, title = "Activity heatmap", weeks = 12, ...props }: ActivityHeatmapProps & { ref?: React.Ref<HTMLDivElement> }) => { const normalizedEndDate = normalizeDate(endDate); const gridData = getGridData(data, normalizedEndDate, weeks); return ( <div className={cn("space-y-4", className)} ref={ref} {...props}> <div className="space-y-1"> <Heading className="text-lg font-semibold tracking-tight"> {title} </Heading> {description ? ( <p className="text-sm text-muted-foreground">{description}</p> ) : null} </div> <div className="overflow-x-auto rounded-lg border bg-card p-4 shadow-sm"> <HeatmapGrid gridData={gridData} weeks={weeks} /> </div> </div> ); }; ActivityHeatmap.displayName = "ActivityHeatmap";

Stories

Explorez chaque variante et etat dans le Storybook interactif :

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Dependances

  • @vllnt/ui@^0.3.0