// Adapted from jalcoui (MIT) — github.com/jal-co/ui // // GitHub-style activity heatmap that visualizes daily counts as a // color-intensity grid. Auto-sizes blocks to fit the container by default. // // Port notes: // - Cell colors resolve through `useThemeColor('primary')` + `alpha(...)`, // not raw `bg-emerald-*` (CONTRACT §2, AUDIT §1). The five emerald // stops in the original collapse to five opacity stops on the active // theme's `primary` token, which means the heatmap follows the theme // preset automatically. // - Intensity bucketing routed through `getIntensity` from // `@djangocfg/ui-core/lib` (CONTRACT shared util added in Phase 0). // - Container sizing routed through `useResizeObserver` from // `@djangocfg/ui-core/hooks` (shared RO from Phase 0), replacing the // local `ResizeObserver` instance in the source. // - Tooltips: the original wrapped every cell in its own Radix // `Tooltip.Provider`. Mounting a provider per cell violates CONTRACT // §5 ("no nested providers") and explodes to 365 portals on a default // 52-week graph. The port uses the native `title` attribute on each // cell instead — accessible, no provider dependency, no portal. 'use client'; import * as React from 'react'; import { cn } from '@djangocfg/ui-core/lib'; import { getIntensity } from '@djangocfg/ui-core/lib'; import { useResizeObserver } from '@djangocfg/ui-core/hooks'; import { useThemeColor, alpha } from '@djangocfg/ui-core/styles/palette'; import { DAY_LABEL_INDICES, DAY_LABEL_WIDTH, DAY_LABELS, DEFAULT_INTENSITY_OPACITY, DEFAULT_INTENSITY_THRESHOLDS, GAP, MONTH_LABEL_HEIGHT, type ActivityGraphProps, } from './types'; import { buildWeeks, formatDate, getMonthLabels } from './utils'; export function ActivityGraph({ data, intensityOpacity = DEFAULT_INTENSITY_OPACITY, blockSize: fixedBlockSize, blockRadius = 2, weeks: weekCount = 52, className, ...props }: ActivityGraphProps) { const containerRef = React.useRef(null); const { width: containerWidth } = useResizeObserver(containerRef); const isAutoFit = fixedBlockSize == null; // Auto-fit cell size based on container width. Falls back to a small // default until the first measurement lands (consistent with original). const autoSize = React.useMemo(() => { if (!isAutoFit || containerWidth <= 0) return null; const available = containerWidth - DAY_LABEL_WIDTH; const size = (available - GAP * (weekCount - 1)) / weekCount; return Math.max(4, Math.floor(size)); }, [isAutoFit, containerWidth, weekCount]); const blockSize = fixedBlockSize ?? autoSize ?? 10; const showGraph = !isAutoFit || autoSize != null; // Resolve the theme color once; map each intensity bucket to an // opacity stop applied to that color (see CONTRACT §3 — never raw // Tailwind class for parametric colors). const primary = useThemeColor('primary'); const intensityColors = React.useMemo( () => intensityOpacity.map((op) => alpha(primary, op)), [primary, intensityOpacity], ); const weeks = React.useMemo(() => buildWeeks(data, weekCount), [data, weekCount]); const maxCount = React.useMemo( () => data.reduce((m, d) => (d.count > m ? d.count : m), 0), [data], ); const monthLabels = React.useMemo( () => getMonthLabels(weeks, blockSize), [weeks, blockSize], ); const gridWidth = weeks.length * (blockSize + GAP) - GAP; const gridHeight = 7 * (blockSize + GAP) - GAP; const totalWidth = DAY_LABEL_WIDTH + gridWidth; return (
{showGraph && (
{monthLabels.map((m, i) => ( {m.label} ))} {DAY_LABELS.map((label, i) => ( {label} ))}
{weeks.map((week, wi) => (
{week.map((day, di) => { const ratio = maxCount > 0 ? day.count / maxCount : 0; const intensity = getIntensity(ratio, [...DEFAULT_INTENSITY_THRESHOLDS]); const title = `${day.count} contribution${day.count === 1 ? '' : 's'} — ${formatDate(day.date)}`; return (
); })}
))}
Less {intensityColors.map((color, i) => (
))} More
)}
); }