Bklit UI
componentheatmap-chartsource: ok · hit
Heatmap Chart
A contribution heatmap with animated cells and configurable level colors
npx shadcn add "https://ui.bklit.com/r/heatmap-chart.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Registry dependencies
@bklit/chart-context@bklit/chart-animation@bklit/utils@bklit/chart-utils@bklit/chart-tooltip@bklit/background@bklit/shimmering-text
Files22 · source cached 10/8/2026, 7:41:41 PM
src/charts/heatmap/heatmap-chart.tsx
"use client";
import { ParentSize } from "@visx/responsive";
import { scaleLinear, scaleTime } from "@visx/scale";
import type { Transition } from "motion/react";
import { useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
import type { Margin } from "../chart-context";
import { ChartLoadingLabel } from "../chart-loading-label";
import {
type ChartPhase,
type ChartStatus,
DEFAULT_CHART_STATUS,
resolveRestingChartPhase,
} from "../chart-phase";
import {
HEATMAP_DEFAULT_ENTER_DURATION_MS,
HEATMAP_DEFAULT_ENTER_TRANSITION,
HEATMAP_DEFAULT_LOADING_CELL_MAX_OPACITY,
HEATMAP_DEFAULT_LOADING_CELL_RANDOMNESS,
HEATMAP_LOADING_CHART_OPACITY,
HEATMAP_LOADING_CONCEAL_MS,
} from "./heatmap-animation";
import type { HeatmapLevelColors, HeatmapLevelStyles } from "./heatmap-colors";
import {
buildHeatmapColorScaleFromStyles,
buildHeatmapFillScale,
resolveHeatmapLevelStyles,
} from "./heatmap-colors";
import {
type HeatmapColumn,
type HeatmapContextValue,
HeatmapInteractionRoot,
HeatmapProvider,
type HeatmapRevealMode,
useHeatmap,
useHeatmapInteraction,
} from "./heatmap-context";
import { HeatmapPatternDefs } from "./heatmap-pattern-defs";
import { resolveHeatmapSeparatorConfigWithData } from "./heatmap-resolve-separator";
import {
filterHeatmapColumns,
getHeatmapColumnXOffset,
getHeatmapPlotInnerWidth,
getHeatmapSeparatorCount,
getHeatmapTimeExtent,
type HeatmapSeparatorLayout,
type HeatmapSeparatorParsedConfig,
type HeatmapWeekStartDay,
rotateHeatmapColumnBins,
} from "./heatmap-utils";
export type HeatmapLayout = "fluid" | "fill";
export interface HeatmapChartProps {
/** Column data — one entry per week (or category) with row bins inside. */
data: HeatmapColumn[];
/** Visible time range — filters week columns that overlap the domain. */
xDomain?: [Date, Date];
/**
* Week columns used for cell sizing when `xDomain` is set. Keeps bin height
* stable while scrubbing (filtered columns can vary by ±1 at boundaries).
*/
sizingColumnCount?: number;
/**
* `fluid` — width drives square cells; chart height hugs the grid (GitHub-style).
* `fill` — expands cells to fill the parent.
*/
layout?: HeatmapLayout;
/** Chart margins */
margin?: Partial<Margin>;
/** Fixed cell size in pixels. When 0, cells are square and sized to fit the plot. Default: 0 */
binSize?: number;
/** Gap between cells in pixels. Default: 2 */
gap?: number;
/** Override the default color scale. */
colorScale?: (count: number | null | undefined) => string;
/** Per-level colors for the Less → More scale (5 entries). */
levelColors?: HeatmapLevelColors;
/** Per-level fill styles (color and optional pattern). Takes precedence over `levelColors`. */
levelStyles?: HeatmapLevelStyles;
/** Optional outer aspect ratio (e.g. "2 / 1"). Omit to fill the parent. */
aspectRatio?: string;
/** Additional class name for the container */
className?: string;
/** Fetch / display status. Default: `"ready"`. */
status?: ChartStatus;
/** Centered label while loading. */
loadingLabel?: string;
/** Enter animation duration in milliseconds. Default: 1600 */
animationDuration?: number;
/** Motion enter transition (spring or cubic-bezier tween). */
enterTransition?: Transition;
/** Signature of motion URL state — triggers enter replay when it changes. */
revealSignature?: string;
/** Scales wave stagger delays (1 = default). */
enterStaggerScale?: number;
/** Play enter fade-in / loading shimmer animations. Default: true */
animate?: boolean;
/** Chart opacity while loading. Default: 0.5 */
loadingOpacity?: number;
/** Show loading cell shimmer while loading. Default: true */
showLoadingCells?: boolean;
/** Max opacity loading cells animate toward (0–1). Default: 0.5 */
loadingCellMaxOpacity?: number;
/** Share of cells that participate in loading shimmer (0–1). Default: 0.65 */
loadingCellRandomness?: number;
/**
* Inserts horizontal gaps between column groups. Overridden when a
* {@link HeatmapSeparator} child sets `every` / `spacing`.
*/
columnSeparators?: HeatmapSeparatorParsedConfig;
/**
* First row of the grid — `0` = Sunday (GitHub default), `1` = Monday, etc.
* Rotates column bins for display without reshaping source data.
*/
weekStartDay?: HeatmapWeekStartDay;
/** Child components (HeatmapCells, HeatmapXAxis, HeatmapYAxis) */
children: ReactNode;
}
const DEFAULT_MARGIN: Margin = { top: 28, right: 16, bottom: 0, left: 40 };
function computeHeatmapDimensions({
width,
parentHeight,
margin,
columnCount,
rowCount,
binSize,
layout,
separator,
}: {
width: number;
parentHeight: number;
margin: Margin;
columnCount: number;
rowCount: number;
binSize: number;
layout: HeatmapLayout;
separator: Pick<HeatmapSeparatorLayout, "spacing" | "atColumns"> | null;
}) {
const innerWidth = Math.max(width - margin.left - margin.right, 0);
const availableHeight = Math.max(
parentHeight - margin.top - margin.bottom,
0
);
const separatorCount = separator ? getHeatmapSeparatorCount(separator) : 0;
const totalSpacing = separatorCount * (separator?.spacing ?? 0);
let binWidth: number;
let binHeight: number;
if (binSize > 0) {
binWidth = binSize;
binHeight = binSize;
} else if (layout === "fluid") {
const cellSize = Math.max((innerWidth - totalSpacing) / columnCount, 0);
binWidth = cellSize;
binHeight = cellSize;
} else {
const cellSize = Math.min(
Math.max((innerWidth - totalSpacing) / columnCount, 0),
availableHeight / rowCount
);
binWidth = cellSize;
binHeight = cellSize;
}
const plotInnerWidth = getHeatmapPlotInnerWidth(
columnCount,
binWidth,
separator
);
const innerHeight = rowCount * binHeight;
const height =
layout === "fluid"
? margin.top + innerHeight + margin.bottom
: Math.max(parentHeight, margin.top + innerHeight + margin.bottom);
const chartWidth =
binSize > 0 && layout === "fluid"
? margin.left + plotInnerWidth + margin.right
: width;
return {
binWidth,
binHeight,
innerWidth: plotInnerWidth,
innerHeight,
height,
width: chartWidth,
};
}
interface HeatmapChartInnerProps {
width: number;
height: number;
data: HeatmapColumn[];
xDomain?: [Date, Date];
sizingColumnCount?: number;
margin: Margin;
binSize: number;
gap: number;
layout: HeatmapLayout;
colorScale: (count: number | null | undefined) => string;
fillScale: (count: number | null | undefined) => string;
levelStyles: HeatmapLevelStyles;
chartStatus: ChartStatus;
chartPhase: ChartPhase;
isLoaded: boolean;
revealEpoch: number;
animationDuration: number;
enterTransition?: Transition;
enterStaggerScale: number;
animateCells: boolean;
loadingOpacity: number;
showLoadingCells: boolean;
loadingCellMaxOpacity: number;
loadingCellRandomness: number;
revealMode: HeatmapRevealMode;
loadingLabel?: string;
showLoadingLabel: boolean;
columnSeparators?: HeatmapSeparatorParsedConfig;
weekStartDay: HeatmapWeekStartDay;
children: ReactNode;
}
function HeatmapChartInner({
width,
height: parentHeight,
data,
xDomain,
sizingColumnCount: sizingColumnCountProp,
margin,
binSize,
gap,
layout,
colorScale,
fillScale,
levelStyles,
chartStatus,
chartPhase,
isLoaded,
revealEpoch,
animationDuration,
enterTransition,
enterStaggerScale,
animateCells,
loadingOpacity,
showLoadingCells,
loadingCellMaxOpacity,
loadingCellRandomness,
revealMode,
loadingLabel,
showLoadingLabel,
columnSeparators,
weekStartDay,
children,
}: HeatmapChartInnerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const filteredData = useMemo(
() => filterHeatmapColumns(data, xDomain),
[data, xDomain]
);
const visibleData = useMemo(
() => rotateHeatmapColumnBins(filteredData, weekStartDay),
[filteredData, weekStartDay]
);
const visibleColumnCount = Math.max(visibleData.length, 1);
const columnCount =
xDomain && sizingColumnCountProp != null
? Math.max(sizingColumnCountProp, 1)
: visibleColumnCount;
const rowCount = Math.max(visibleData[0]?.bins.length ?? 7, 1);
const separatorLayout = useMemo(
() =>
resolveHeatmapSeparatorConfigWithData(
children,
visibleData,
columnSeparators
),
[children, columnSeparators, visibleData]
);
const {
binWidth,
binHeight,
innerWidth,
innerHeight,
height,
width: chartWidth,
} = useMemo(
() =>
computeHeatmapDimensions({
width,
parentHeight,
margin,
columnCount,
rowCount,
binSize,
layout,
separator: separatorLayout,
}),
[
binSize,
columnCount,
layout,
margin,
parentHeight,
rowCount,
separatorLayout,
width,
]
);
const xScale = useMemo(
() => (columnIndex: number) =>
columnIndex * binWidth +
getHeatmapColumnXOffset(columnIndex, separatorLayout),
[binWidth, separatorLayout]
);
const yScale = useMemo(
() => (rowIndex: number) => rowIndex * binHeight,
[binHeight]
);
const timeExtent = useMemo(() => getHeatmapTimeExtent(data), [data]);
const timeXScale = useMemo(() => {
const domain = timeExtent ?? [new Date(), new Date()];
return scaleTime({
domain,
range: [0, innerWidth],
});
}, [innerWidth, timeExtent]);
const brushYScale = useMemo(
() =>
scaleLinear({
domain: [0, 1],
range: [innerHeight, 0],
}),
[innerHeight]
);
const contextValue = useMemo<HeatmapContextValue>(
() => ({
data: visibleData,
width: chartWidth,
height,
innerWidth,
innerHeight,
margin,
binWidth,
binHeight,
gap,
weekStartDay,
xScale,
yScale,
separatorLayout,
timeXScale,
brushYScale,
isReady: chartWidth >= 10 && height >= 10,
colorScale,
fillScale,
levelStyles,
containerRef,
chartStatus,
chartPhase,
isLoaded,
revealEpoch,
animationDuration,
enterTransition,
enterStaggerScale,
animateCells,
loadingOpacity,
showLoadingCells,
loadingCellMaxOpacity,
loadingCellRandomness,
revealMode,
loadingLabel,
showLoadingLabel,
}),
[
animateCells,
animationDuration,
binHeight,
binWidth,
brushYScale,
chartPhase,
chartStatus,
chartWidth,
colorScale,
fillScale,
enterStaggerScale,
enterTransition,
gap,
height,
innerHeight,
innerWidth,
isLoaded,
loadingCellMaxOpacity,
loadingCellRandomness,
loadingLabel,
levelStyles,
loadingOpacity,
margin,
revealMode,
separatorLayout,
showLoadingCells,
showLoadingLabel,
revealEpoch,
timeXScale,
visibleData,
weekStartDay,
xScale,
yScale,
]
);
if (chartWidth < 10 || height < 10) {
return null;
}
return (
<HeatmapProvider value={contextValue}>
<HeatmapInteractionRoot>
<HeatmapChartSurface layout={layout}>{children}</HeatmapChartSurface>
</HeatmapInteractionRoot>
</HeatmapProvider>
);
}
function HeatmapChartSurface({
layout,
children,
}: {
layout: HeatmapLayout;
children: ReactNode;
}) {
const {
containerRef,
height,
width,
margin,
levelStyles,
chartPhase,
loadingOpacity,
loadingLabel,
showLoadingLabel,
} = useHeatmap();
const { clearInteraction } = useHeatmapInteraction();
const reducedOpacity =
chartPhase === "loading" || chartPhase === "exitingReady"
? loadingOpacity
: 1;
return (
<div
className={cn("relative w-full", layout === "fill" && "h-full")}
onPointerLeave={clearInteraction}
ref={containerRef}
style={{ opacity: reducedOpacity }}
>
<svg aria-hidden="true" height={height} width={width}>
<HeatmapPatternDefs levelStyles={levelStyles} />
<g transform={`translate(${margin.left},${margin.top})`}>{children}</g>
</svg>
{showLoadingLabel && loadingLabel?.trim() ? (
<div
className="pointer-events-none absolute"
style={{
top: margin.top,
left: margin.left,
width: Math.max(width - margin.left - margin.right, 0),
height: Math.max(height - margin.top - margin.bottom, 0),
}}
>
<ChartLoadingLabel
exiting={chartPhase !== "loading"}
text={loadingLabel}
/>
</div>
) : null}
</div>
);
}
function useHeatmapChartLifecycle({
chartStatus,
animationDuration,
revealSignature = "",
animate,
}: {
chartStatus: ChartStatus;
animationDuration: number;
revealSignature?: string;
animate: boolean;
}) {
const reducedMotion = useReducedMotion();
const [chartPhase, setChartPhase] = useState<ChartPhase>(() =>
resolveRestingChartPhase(chartStatus)
);
const [isLoaded, setIsLoaded] = useState(
() => chartStatus === "ready" && (!animate || animationDuration <= 0)
);
const [revealEpoch, setRevealEpoch] = useState(0);
const [revealMode, setRevealMode] = useState<HeatmapRevealMode>(null);
const prevStatusRef = useRef(chartStatus);
const phaseRef = useRef(chartPhase);
phaseRef.current = chartPhase;
const animateCells = animate && !reducedMotion;
const animateEnter = animateCells && animationDuration > 0;
const finishReveal = useCallback(() => {
setIsLoaded(true);
setChartPhase("ready");
setRevealMode(null);
}, []);
const beginReveal = useCallback(() => {
setRevealMode("enter");
setRevealEpoch((epoch) => epoch + 1);
setIsLoaded(false);
setChartPhase("revealing");
if (!animateEnter) {
finishReveal();
}
}, [animateEnter, finishReveal]);
useLayoutEffect(() => {
const prevStatus = prevStatusRef.current;
if (prevStatus === chartStatus) {
return;
}
prevStatusRef.current = chartStatus;
if (chartStatus === "ready" && prevStatus === "loading") {
setRevealMode("fromLoading");
setIsLoaded(false);
setChartPhase("revealing");
return;
}
if (chartStatus === "loading" && prevStatus === "ready") {
setRevealMode(null);
setIsLoaded(false);
setChartPhase("exitingReady");
}
}, [chartStatus]);
useEffect(() => {
if (chartPhase !== "exitingReady") {
return;
}
const timer = window.setTimeout(() => {
setChartPhase("loading");
}, HEATMAP_LOADING_CONCEAL_MS);
return () => window.clearTimeout(timer);
}, [chartPhase]);
// biome-ignore lint/correctness/useExhaustiveDependencies: revealSignature replays enter
useEffect(() => {
if (!animateEnter) {
setIsLoaded(true);
setChartPhase(resolveRestingChartPhase(chartStatus));
return;
}
if (chartStatus !== "ready") {
return;
}
if (phaseRef.current !== "ready") {
return;
}
beginReveal();
}, [
animateEnter,
animationDuration,
beginReveal,
chartStatus,
revealSignature,
]);
// biome-ignore lint/correctness/useExhaustiveDependencies: revealEpoch replays finish timer
useEffect(() => {
if (!animateEnter || chartPhase !== "revealing") {
return;
}
const finishTimer = window.setTimeout(finishReveal, animationDuration);
return () => window.clearTimeout(finishTimer);
}, [animateEnter, animationDuration, chartPhase, finishReveal, revealEpoch]);
return {
chartPhase,
isLoaded,
revealEpoch,
revealMode,
animateCells,
};
}
export function HeatmapChart({
data,
xDomain,
sizingColumnCount,
layout = "fluid",
margin: marginProp,
binSize = 0,
gap = 2,
colorScale: colorScaleProp,
levelColors,
levelStyles: levelStylesProp,
aspectRatio,
className = "",
status = DEFAULT_CHART_STATUS,
loadingLabel,
animationDuration = HEATMAP_DEFAULT_ENTER_DURATION_MS,
enterTransition = HEATMAP_DEFAULT_ENTER_TRANSITION,
revealSignature = "",
enterStaggerScale = 1,
animate = true,
loadingOpacity = HEATMAP_LOADING_CHART_OPACITY,
showLoadingCells = true,
loadingCellMaxOpacity = HEATMAP_DEFAULT_LOADING_CELL_MAX_OPACITY,
loadingCellRandomness = HEATMAP_DEFAULT_LOADING_CELL_RANDOMNESS,
columnSeparators,
weekStartDay = 0,
children,
}: HeatmapChartProps) {
const margin = { ...DEFAULT_MARGIN, ...marginProp };
const levelStyles = useMemo(
() => resolveHeatmapLevelStyles(levelColors, levelStylesProp),
[levelColors, levelStylesProp]
);
const colorScale = useMemo(
() => colorScaleProp ?? buildHeatmapColorScaleFromStyles(levelStyles),
[colorScaleProp, levelStyles]
);
const fillScale = useMemo(
() => buildHeatmapFillScale(levelStyles),
[levelStyles]
);
const { chartPhase, isLoaded, revealEpoch, revealMode, animateCells } =
useHeatmapChartLifecycle({
chartStatus: status,
animationDuration,
revealSignature,
animate,
});
const showLoadingLabel = Boolean(
loadingLabel?.trim() &&
status === "loading" &&
(chartPhase === "loading" || chartPhase === "exitingReady")
);
return (
<div
className={cn(
"relative w-full",
layout === "fill" && "h-full min-h-0",
className
)}
style={aspectRatio ? { aspectRatio } : undefined}
>
<ParentSize>
{({ width, height: parentHeight }) => (
<HeatmapChartInner
animateCells={animateCells}
animationDuration={animationDuration}
binSize={binSize}
chartPhase={chartPhase}
chartStatus={status}
colorScale={colorScale}
columnSeparators={columnSeparators}
data={data}
enterStaggerScale={enterStaggerScale}
enterTransition={enterTransition}
fillScale={fillScale}
gap={gap}
height={parentHeight}
isLoaded={isLoaded}
layout={layout}
levelStyles={levelStyles}
loadingCellMaxOpacity={loadingCellMaxOpacity}
loadingCellRandomness={loadingCellRandomness}
loadingLabel={loadingLabel}
loadingOpacity={loadingOpacity}
margin={margin}
revealEpoch={revealEpoch}
revealMode={revealMode}
showLoadingCells={showLoadingCells}
showLoadingLabel={showLoadingLabel}
sizingColumnCount={sizingColumnCount}
weekStartDay={weekStartDay}
width={width}
xDomain={xDomain}
>
{children}
</HeatmapChartInner>
)}
</ParentSize>
</div>
);
}
HeatmapChart.displayName = "HeatmapChart";
export default HeatmapChart;
src/charts/heatmap/heatmap-context.tsx
"use client";
import type { scaleLinear, scaleTime } from "@visx/scale";
import type { Transition } from "motion/react";
import {
createContext,
type Dispatch,
type ReactNode,
type RefObject,
type SetStateAction,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import { cn } from "@/lib/utils";
import type { Margin } from "../chart-context";
import type { ChartPhase, ChartStatus } from "../chart-phase";
import {
HEATMAP_DEFAULT_LEVEL_COLORS,
type HeatmapLevelStyles,
} from "./heatmap-colors";
import type {
HeatmapSeparatorLayout,
HeatmapWeekStartDay,
} from "./heatmap-utils";
type HeatmapTimeScale = ReturnType<typeof scaleTime<number>>;
type HeatmapLinearScale = ReturnType<typeof scaleLinear<number>>;
export interface HeatmapBin {
count: number;
bin: number;
date: Date;
}
export interface HeatmapColumn {
bin: number;
bins: HeatmapBin[];
}
export interface HeatmapTooltipData {
column: number;
row: number;
count: number;
date: Date;
x: number;
y: number;
}
export interface HeatmapInteractionContextValue {
hoveredCell: { column: number; row: number } | null;
hoveredLegendLevel: number | null;
tooltipData: HeatmapTooltipData | null;
setHoveredCell: (cell: { column: number; row: number } | null) => void;
setHoveredLegendLevel: (level: number | null) => void;
setTooltipData: Dispatch<SetStateAction<HeatmapTooltipData | null>>;
clearInteraction: () => void;
}
export interface HeatmapContextValue {
data: HeatmapColumn[];
width: number;
height: number;
innerWidth: number;
innerHeight: number;
margin: Margin;
binWidth: number;
binHeight: number;
gap: number;
weekStartDay: HeatmapWeekStartDay;
xScale: (columnIndex: number) => number;
yScale: (rowIndex: number) => number;
separatorLayout: HeatmapSeparatorLayout | null;
timeXScale: HeatmapTimeScale;
brushYScale: HeatmapLinearScale;
isReady: boolean;
colorScale: (count: number | null | undefined) => string;
fillScale: (count: number | null | undefined) => string;
levelStyles: HeatmapLevelStyles;
containerRef: RefObject<HTMLDivElement | null>;
chartStatus: ChartStatus;
chartPhase: ChartPhase;
isLoaded: boolean;
revealEpoch: number;
animationDuration: number;
enterTransition?: Transition;
enterStaggerScale: number;
animateCells: boolean;
loadingOpacity: number;
showLoadingCells: boolean;
loadingCellMaxOpacity: number;
loadingCellRandomness: number;
revealMode: HeatmapRevealMode;
loadingLabel?: string;
showLoadingLabel: boolean;
}
/** How cells transition into view after loading or on first mount. */
export type HeatmapRevealMode = "enter" | "fromLoading" | null;
const HeatmapContext = createContext<HeatmapContextValue | null>(null);
const HeatmapInteractionContext =
createContext<HeatmapInteractionContextValue | null>(null);
export function HeatmapInteractionProvider({
children,
}: {
children: ReactNode;
}) {
const [hoveredCell, setHoveredCell] = useState<{
column: number;
row: number;
} | null>(null);
const [hoveredLegendLevel, setHoveredLegendLevel] = useState<number | null>(
null
);
const [tooltipData, setTooltipData] = useState<HeatmapTooltipData | null>(
null
);
const clearInteraction = useCallback(() => {
setHoveredCell(null);
setHoveredLegendLevel(null);
setTooltipData(null);
}, []);
const value = useMemo<HeatmapInteractionContextValue>(
() => ({
hoveredCell,
hoveredLegendLevel,
tooltipData,
setHoveredCell,
setHoveredLegendLevel,
setTooltipData,
clearInteraction,
}),
[clearInteraction, hoveredCell, hoveredLegendLevel, tooltipData]
);
return (
<HeatmapInteractionContext.Provider value={value}>
{children}
</HeatmapInteractionContext.Provider>
);
}
export function HeatmapProvider({
children,
value,
}: {
children: ReactNode;
value: HeatmapContextValue;
}) {
return (
<HeatmapContext.Provider value={value}>{children}</HeatmapContext.Provider>
);
}
export function useHeatmap(): HeatmapContextValue {
const context = useContext(HeatmapContext);
if (!context) {
throw new Error("useHeatmap must be used within a HeatmapProvider");
}
return context;
}
export function useHeatmapInteraction(): HeatmapInteractionContextValue {
const context = useContext(HeatmapInteractionContext);
if (!context) {
throw new Error(
"useHeatmapInteraction must be used within a HeatmapInteractionProvider"
);
}
return context;
}
export function useHeatmapInteractionOptional(): HeatmapInteractionContextValue | null {
return useContext(HeatmapInteractionContext);
}
/** Clears hover state when the pointer leaves chart + legend. */
export function HeatmapInteractionBoundary({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const { clearInteraction } = useHeatmapInteraction();
return (
<div
className={cn("size-full min-h-0 min-w-0", className)}
onPointerLeave={clearInteraction}
>
{children}
</div>
);
}
/** Nests a provider only when one is not already present upstream. */
export function HeatmapInteractionRoot({ children }: { children: ReactNode }) {
const existing = useContext(HeatmapInteractionContext);
if (existing) {
return children;
}
return <HeatmapInteractionProvider>{children}</HeatmapInteractionProvider>;
}
/** @deprecated Use {@link HEATMAP_DEFAULT_LEVEL_COLORS} */
export const heatmapCssVars = {
empty: HEATMAP_DEFAULT_LEVEL_COLORS[0],
level1: HEATMAP_DEFAULT_LEVEL_COLORS[1],
level2: HEATMAP_DEFAULT_LEVEL_COLORS[2],
level3: HEATMAP_DEFAULT_LEVEL_COLORS[3],
level4: HEATMAP_DEFAULT_LEVEL_COLORS[4],
} as const;
src/charts/heatmap/heatmap-cells.tsx
"use client";
import { Group } from "@visx/group";
import { HeatmapRect } from "@visx/heatmap";
import {
animate,
type MotionValue,
motion,
type Transition,
useMotionValue,
} from "motion/react";
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
import type { ChartStatus } from "../chart-phase";
import { transitionWithDelay } from "../motion-utils";
import {
computeHeatmapEnterFadeDelayMs,
HEATMAP_DEFAULT_ENTER_EASE,
HEATMAP_LOADING_BASE_CELL_OPACITY,
HEATMAP_LOADING_CONCEAL_MS,
heatmapLoadingCellParticipates,
resolveHeatmapEnterFadeDurationSec,
} from "./heatmap-animation";
import { heatmapLevelCellFillOpacity } from "./heatmap-colors";
import {
type HeatmapBin,
type HeatmapColumn,
useHeatmap,
useHeatmapInteraction,
} from "./heatmap-context";
import {
getHeatmapContributionLevel,
isHeatmapGhostBin,
isHeatmapHoverEffectEnabled,
resolveHeatmapDisplayRange,
resolveHeatmapHoverStyle,
resolveHeatmapRowOpacity,
} from "./heatmap-utils";
const HEATMAP_INACTIVE_OPACITY = 0.3;
/** Smooth tween for inactive opacity + scale on hover. */
const HEATMAP_INACTIVE_TRANSITION = {
duration: 0.22,
ease: [0.4, 0, 0.2, 1] as const,
};
const HEATMAP_CONCEAL_TRANSITION = {
duration: HEATMAP_LOADING_CONCEAL_MS / 1000,
ease: HEATMAP_DEFAULT_ENTER_EASE,
};
function computeHeatmapCellFaded(
isCellHovering: boolean,
isLevelHovering: boolean,
hoveredCell: { column: number; row: number } | null,
hoveredLegendLevel: number | null,
cell: { column: number; row: number },
count: number
): { isHighlighted: boolean; isDimmed: boolean } {
if (isCellHovering && hoveredCell) {
const isHighlighted =
hoveredCell.column === cell.column && hoveredCell.row === cell.row;
return { isHighlighted, isDimmed: !isHighlighted };
}
if (isLevelHovering && hoveredLegendLevel !== null) {
const isHighlighted =
getHeatmapContributionLevel(count) === hoveredLegendLevel;
return { isHighlighted, isDimmed: !isHighlighted };
}
return { isHighlighted: false, isDimmed: false };
}
interface SyncCellLayerParams {
animateCells: boolean;
chartStatus: ChartStatus;
dataOpacity: MotionValue<number>;
isAwaitingLoadingConceal: boolean;
isExitingToLoading: boolean;
isLoadingResting: boolean;
isReadyResting: boolean;
isRevealActive: boolean;
participates: boolean;
pulseOpacity: MotionValue<number>;
readyDataOpacity: number;
shimmerOpacity: MotionValue<number>;
showLoadingCellsLayer: boolean;
showShimmerPulse: boolean;
staggeredTransition: Transition;
}
function syncHeatmapCellLayerOpacities(params: SyncCellLayerParams) {
const {
animateCells,
chartStatus,
dataOpacity,
isAwaitingLoadingConceal,
isExitingToLoading,
isLoadingResting,
isReadyResting,
isRevealActive,
participates,
pulseOpacity,
readyDataOpacity,
shimmerOpacity,
showLoadingCellsLayer,
showShimmerPulse,
staggeredTransition,
} = params;
if (!animateCells) {
if (isReadyResting) {
dataOpacity.set(readyDataOpacity);
shimmerOpacity.set(0);
pulseOpacity.set(0);
} else if (chartStatus === "loading") {
dataOpacity.set(0);
pulseOpacity.set(0);
shimmerOpacity.set(
participates && showLoadingCellsLayer
? 0
: HEATMAP_LOADING_BASE_CELL_OPACITY
);
}
return;
}
if (isLoadingResting) {
animate(dataOpacity, 0, { duration: 0 });
if (!showShimmerPulse) {
animate(shimmerOpacity, HEATMAP_LOADING_BASE_CELL_OPACITY, {
duration: 0,
});
}
return;
}
if (isRevealActive) {
pulseOpacity.set(0);
shimmerOpacity.set(0);
animate(dataOpacity, readyDataOpacity, staggeredTransition);
return;
}
if (isAwaitingLoadingConceal || isExitingToLoading) {
animate(dataOpacity, 0, HEATMAP_CONCEAL_TRANSITION);
animate(shimmerOpacity, 0, { duration: 0 });
return;
}
if (isReadyResting) {
animate(dataOpacity, readyDataOpacity, HEATMAP_INACTIVE_TRANSITION);
animate(shimmerOpacity, 0, { duration: 0 });
pulseOpacity.set(0);
}
}
export interface HeatmapCellsProps {
/** Corner radius for each cell. Default: 2 */
cornerRadius?: number;
/** Override the default GitHub-style green color scale. */
colorScale?: (count: number | null | undefined) => string;
/** Opacity for inactive cells while hovering. Default: 0.3 */
inactiveOpacity?: number;
/** Scale for inactive cells while hovering. Default: 1 */
inactiveScale?: number;
/** Scale for the highlighted cell while hovering. Default: 1 */
activeScale?: number;
/** Per-row opacity multiplier (display row index). Default: 1 */
rowOpacity?: number | readonly number[];
/** Pointer hover + dimming. Default: true */
interactive?: boolean;
/** Hide out-of-range bins (GitHub-style ghost cells). Default: true */
hideGhostCells?: boolean;
}
interface HeatmapCellRectProps {
cell: {
column: number;
row: number;
x: number;
y: number;
width: number;
height: number;
color?: string;
opacity?: number;
};
bin: HeatmapBin;
cornerRadius: number;
interactive: boolean;
inactiveOpacity: number;
inactiveScale: number;
activeScale: number;
rowOpacity: number | readonly number[] | undefined;
hoverState: { isHighlighted: boolean; isDimmed: boolean };
onEnter: (
column: number,
row: number,
bin: HeatmapBin,
x: number,
y: number
) => void;
onLeave: () => void;
}
const HeatmapMotionCell = memo(function HeatmapMotionCell({
cell,
bin,
cornerRadius,
fillScale,
interactive,
inactiveOpacity,
inactiveScale,
activeScale,
rowOpacity,
hoverState,
onEnter,
onLeave,
}: HeatmapCellRectProps & {
fillScale: (count: number | null | undefined) => string;
}) {
const {
chartStatus,
chartPhase,
isLoaded,
revealMode,
revealEpoch,
animationDuration,
enterTransition,
enterStaggerScale,
animateCells,
showLoadingCells: showLoadingCellsLayer,
loadingCellMaxOpacity,
loadingCellRandomness,
levelStyles,
} = useHeatmap();
const levelStyle =
levelStyles[getHeatmapContributionLevel(bin.count ?? 0)] ?? levelStyles[0];
const targetFill = fillScale(bin.count);
const emptyFill = fillScale(0);
const patternFillOpacity = heatmapLevelCellFillOpacity(levelStyle);
const dataOpacity = useMotionValue(0);
/** Orchestration layer: conceal, static loading base. */
const shimmerOpacity = useMotionValue(0);
/** Pulse loop only — kept separate so stagger tweens don't break re-entry. */
const pulseOpacity = useMotionValue(0);
const wasShimmerPulsingRef = useRef(false);
const isLoadingResting =
chartStatus === "loading" && chartPhase === "loading";
const isAwaitingLoadingConceal =
chartStatus === "loading" && chartPhase === "ready";
const isExitingToLoading = chartPhase === "exitingReady";
const isRevealActive =
chartPhase === "revealing" &&
!isLoaded &&
(revealMode === "fromLoading" || revealMode === "enter");
const isReadyResting =
chartStatus === "ready" && chartPhase === "ready" && isLoaded;
const participates = useMemo(
() =>
heatmapLoadingCellParticipates(
cell.column,
cell.row,
loadingCellRandomness
),
[cell.column, cell.row, loadingCellRandomness]
);
const showShimmerPulse =
animateCells && showLoadingCellsLayer && participates && isLoadingResting;
const fadeDurationSec = resolveHeatmapEnterFadeDurationSec(
enterTransition,
animationDuration
);
const delayMs = computeHeatmapEnterFadeDelayMs({
column: cell.column,
row: cell.row,
revealEpoch,
animationDurationMs: animationDuration,
enterStaggerScale,
fadeDurationSec,
});
const staggeredTransition = transitionWithDelay(
enterTransition,
delayMs / 1000
);
const readyHoverStyle = resolveHeatmapHoverStyle(
hoverState.isHighlighted,
hoverState.isDimmed,
{ inactiveOpacity, inactiveScale, activeScale }
);
const rowOpacityMultiplier = resolveHeatmapRowOpacity(cell.row, rowOpacity);
const readyDataOpacity = readyHoverStyle.opacity;
const readyScale = isReadyResting ? readyHoverStyle.scale : 1;
const transformOrigin = `${cell.x + cell.width / 2}px ${cell.y + cell.height / 2}px`;
useEffect(() => {
if (!showShimmerPulse) {
wasShimmerPulsingRef.current = false;
pulseOpacity.set(0);
return;
}
const isFreshLoadingPulse =
isLoadingResting && !wasShimmerPulsingRef.current;
wasShimmerPulsingRef.current = true;
if (isFreshLoadingPulse) {
pulseOpacity.set(0);
}
let cancelled = false;
let current: ReturnType<typeof animate> | undefined;
const pulse = async () => {
while (!cancelled) {
const target = Math.random() * loadingCellMaxOpacity;
const duration = 0.35 + Math.random() * 0.85;
current = animate(pulseOpacity, target, {
duration,
ease: [0.45, 0, 0.55, 1],
});
try {
await current;
} catch {
break;
}
if (cancelled) {
break;
}
await new Promise<void>((resolve) => {
window.setTimeout(resolve, 80 + Math.random() * 420);
});
}
};
pulse().catch(() => undefined);
return () => {
cancelled = true;
current?.stop();
};
}, [isLoadingResting, loadingCellMaxOpacity, pulseOpacity, showShimmerPulse]);
useEffect(() => {
syncHeatmapCellLayerOpacities({
animateCells,
chartStatus,
dataOpacity,
isAwaitingLoadingConceal,
isExitingToLoading,
isLoadingResting,
isReadyResting,
isRevealActive,
participates,
pulseOpacity,
readyDataOpacity,
shimmerOpacity,
showLoadingCellsLayer,
showShimmerPulse,
staggeredTransition,
});
}, [
animateCells,
chartStatus,
dataOpacity,
isAwaitingLoadingConceal,
isExitingToLoading,
isLoadingResting,
isReadyResting,
isRevealActive,
participates,
pulseOpacity,
readyDataOpacity,
shimmerOpacity,
showLoadingCellsLayer,
showShimmerPulse,
staggeredTransition,
]);
const cellProps = {
className: "visx-heatmap-rect",
height: cell.height,
rx: cornerRadius,
ry: cornerRadius,
width: cell.width,
x: cell.x,
y: cell.y,
};
return (
<motion.g
animate={{ scale: readyScale }}
style={{ transformOrigin }}
transition={HEATMAP_INACTIVE_TRANSITION}
>
<motion.rect
{...cellProps}
fill={targetFill}
fillOpacity={
(cell.opacity ?? 1) * patternFillOpacity * rowOpacityMultiplier
}
onPointerEnter={() =>
onEnter(cell.column, cell.row, bin, cell.x, cell.y)
}
onPointerLeave={onLeave}
style={{
cursor: interactive ? "pointer" : undefined,
opacity: dataOpacity,
}}
/>
<motion.rect
{...cellProps}
fill={emptyFill}
pointerEvents="none"
style={{
opacity: showShimmerPulse ? pulseOpacity : shimmerOpacity,
}}
/>
</motion.g>
);
});
export const HeatmapCells = memo(function HeatmapCells({
cornerRadius = 2,
colorScale: colorScaleProp,
inactiveOpacity = HEATMAP_INACTIVE_OPACITY,
inactiveScale = 1,
activeScale = 1,
rowOpacity,
interactive = true,
hideGhostCells = true,
}: HeatmapCellsProps) {
const {
data,
binWidth,
binHeight,
gap,
margin,
xScale,
yScale,
chartStatus,
colorScale: contextColorScale,
fillScale: contextFillScale,
} = useHeatmap();
const colorScale = colorScaleProp ?? contextColorScale;
const fillScale = contextFillScale;
const cellsInteractive = interactive && chartStatus !== "loading";
const {
hoveredCell,
hoveredLegendLevel,
setHoveredCell,
setHoveredLegendLevel,
setTooltipData,
} = useHeatmapInteraction();
const handleCellEnter = useCallback(
(column: number, row: number, bin: HeatmapBin, x: number, y: number) => {
if (!cellsInteractive) {
return;
}
setHoveredLegendLevel(null);
setHoveredCell({ column, row });
setTooltipData({
column,
row,
count: bin.count,
date: bin.date,
x: margin.left + x + binWidth / 2,
y: margin.top + y + binHeight / 2,
});
},
[
binHeight,
binWidth,
cellsInteractive,
margin.left,
margin.top,
setHoveredCell,
setHoveredLegendLevel,
setTooltipData,
]
);
const handleCellLeave = useCallback(() => {
if (!cellsInteractive) {
return;
}
setHoveredCell(null);
setTooltipData(null);
}, [cellsInteractive, setHoveredCell, setTooltipData]);
const inactiveEnabled = isHeatmapHoverEffectEnabled({
inactiveOpacity,
inactiveScale,
activeScale,
});
const isCellHovering =
cellsInteractive && hoveredCell !== null && inactiveEnabled;
const isLevelHovering =
cellsInteractive && hoveredLegendLevel !== null && inactiveEnabled;
const displayRange = useMemo(
() => (hideGhostCells ? resolveHeatmapDisplayRange(data) : null),
[data, hideGhostCells]
);
return (
<HeatmapRect<HeatmapColumn, HeatmapBin>
binHeight={binHeight}
bins={(column) => column.bins}
binWidth={binWidth}
colorScale={(count) =>
colorScale(typeof count === "number" ? count : count?.valueOf())
}
count={(bin) => bin.count}
data={data}
gap={gap}
xScale={xScale}
yScale={yScale}
>
{(cells) => (
<Group className="visx-heatmap-rects">
{cells.flatMap((column) =>
column.map((cell) => {
const bin = data[cell.column]?.bins[cell.row];
if (!bin) {
return null;
}
if (displayRange && isHeatmapGhostBin(bin, displayRange)) {
return null;
}
const hoverState = computeHeatmapCellFaded(
isCellHovering,
isLevelHovering,
hoveredCell,
hoveredLegendLevel,
cell,
bin.count
);
return (
<HeatmapMotionCell
activeScale={activeScale}
bin={bin}
cell={cell}
cornerRadius={cornerRadius}
fillScale={fillScale}
hoverState={hoverState}
inactiveOpacity={inactiveOpacity}
inactiveScale={inactiveScale}
interactive={cellsInteractive}
key={`heatmap-cell-${cell.column}-${cell.row}`}
onEnter={handleCellEnter}
onLeave={handleCellLeave}
rowOpacity={rowOpacity}
/>
);
})
)}
</Group>
)}
</HeatmapRect>
);
});
HeatmapCells.displayName = "HeatmapCells";
export default HeatmapCells;
src/charts/heatmap/heatmap-x-axis.tsx
"use client";
import { memo, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { useHeatmap } from "./heatmap-context";
import { getHeatmapColumnMonthAnchor } from "./heatmap-utils";
export interface HeatmapXAxisProps {
/** Additional class name for labels */
className?: string;
}
const monthFmt = new Intl.DateTimeFormat("en-US", { month: "short" });
export const HeatmapXAxis = memo(function HeatmapXAxis({
className,
}: HeatmapXAxisProps) {
const { containerRef, data, margin, xScale } = useHeatmap();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const labels = useMemo(() => {
const ticks: { label: string; x: number; key: string }[] = [];
let lastMonthKey = "";
for (let columnIndex = 0; columnIndex < data.length; columnIndex++) {
const column = data[columnIndex];
if (!column) {
continue;
}
const monthAnchor = getHeatmapColumnMonthAnchor(column);
if (!monthAnchor) {
continue;
}
const monthKey = `${monthAnchor.getFullYear()}-${monthAnchor.getMonth()}`;
if (monthKey === lastMonthKey) {
continue;
}
ticks.push({
label: monthFmt.format(monthAnchor),
x: margin.left + xScale(columnIndex),
key: monthKey,
});
lastMonthKey = monthKey;
}
return ticks;
}, [data, margin.left, xScale]);
const container = containerRef.current;
if (!(mounted && container)) {
return null;
}
return createPortal(
labels.map((tick) => (
<div
className="pointer-events-none absolute"
key={tick.key}
style={{
top: 0,
left: tick.x,
width: 0,
display: "flex",
justifyContent: "flex-start",
}}
>
<span
className={cn(
"whitespace-nowrap text-chart-label text-xs",
className
)}
>
{tick.label}
</span>
</div>
)),
container
);
});
HeatmapXAxis.displayName = "HeatmapXAxis";
export default HeatmapXAxis;
src/charts/heatmap/heatmap-y-axis.tsx
"use client";
import { memo, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { useHeatmap } from "./heatmap-context";
import {
formatHeatmapYAxisLabel,
getHeatmapDayLabels,
type HeatmapYAxisLabelFormat,
type HeatmapYAxisTickFilter,
resolveHeatmapRowOpacity,
shouldShowHeatmapYAxisTick,
} from "./heatmap-utils";
export interface HeatmapYAxisProps {
/** Additional class name for labels */
className?: string;
/** Which row ticks to display. Default: `"odd"` (Mon / Wed / Fri). */
tickFilter?: HeatmapYAxisTickFilter;
/** Label format — `"initial"` shows the first letter only (Mon → M). Default: `"full"`. */
labelFormat?: HeatmapYAxisLabelFormat;
/** Per-row label opacity — mirrors {@link HeatmapCells} `rowOpacity`. */
rowOpacity?: number | readonly number[];
}
export const HeatmapYAxis = memo(function HeatmapYAxis({
className,
tickFilter = "odd",
labelFormat = "full",
rowOpacity,
}: HeatmapYAxisProps) {
const { containerRef, margin, binHeight, gap, yScale, weekStartDay } =
useHeatmap();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const labels = useMemo(
() =>
getHeatmapDayLabels(weekStartDay)
.map((label, row) => ({
row,
label: formatHeatmapYAxisLabel(label, labelFormat),
y: margin.top + yScale(row) + (binHeight - gap) / 2,
}))
.filter((tick) => shouldShowHeatmapYAxisTick(tick.row, tickFilter)),
[binHeight, gap, labelFormat, margin.top, tickFilter, weekStartDay, yScale]
);
const container = containerRef.current;
if (!(mounted && container)) {
return null;
}
return createPortal(
labels.map((tick) => (
<div
className="pointer-events-none absolute"
key={tick.row}
style={{
top: tick.y,
left: 4,
width: Math.max(margin.left - 12, 0),
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
transform: "translateY(-50%)",
}}
>
<span
className={cn(
"whitespace-nowrap text-chart-label text-xs",
className
)}
style={{ opacity: resolveHeatmapRowOpacity(tick.row, rowOpacity) }}
>
{tick.label}
</span>
</div>
)),
container
);
});
HeatmapYAxis.displayName = "HeatmapYAxis";
export default HeatmapYAxis;
src/charts/heatmap/heatmap-legend.tsx
"use client";
import { motion } from "motion/react";
import { memo, useCallback } from "react";
import { cn } from "@/lib/utils";
import {
defaultHeatmapColorScale,
type HeatmapLevelStyles,
} from "./heatmap-colors";
import { useHeatmapInteractionOptional } from "./heatmap-context";
import { HeatmapLegendGradient } from "./heatmap-legend-gradient";
import { HeatmapLegendSwatch } from "./heatmap-legend-swatch";
import {
getHeatmapContributionLevel,
isHeatmapHoverEffectEnabled,
resolveHeatmapHoverStyle,
} from "./heatmap-utils";
export const HEATMAP_LEGEND_LEVELS = [0, 1, 2, 3, 4] as const;
const HEATMAP_INACTIVE_OPACITY = 0.3;
/** Smooth tween for inactive opacity + scale on hover. */
const HEATMAP_INACTIVE_TRANSITION = {
duration: 0.22,
ease: [0.4, 0, 0.2, 1] as const,
};
export type HeatmapLegendVariant = "swatches" | "gradient";
export interface HeatmapLegendProps {
/** Side label before the swatches. Default: "Less" */
lessLabel?: string;
/** Side label after the swatches. Default: "More" */
moreLabel?: string;
/** Swatch size in pixels. Default: 11 */
cellSize?: number;
/** Gap between swatches. Default: 2 */
gap?: number;
/** Corner radius for swatches. Default: 2 */
cornerRadius?: number;
/** Horizontal alignment within the legend cell. Default: "end" */
align?: "start" | "center" | "end";
/** Legend layout — discrete swatches or a continuous gradient bar. Default: `"swatches"` */
variant?: HeatmapLegendVariant;
/** Gradient bar width in swatch units when `variant="gradient"`. Default: 5 */
gradientSpan?: number;
/** Font size in pixels for side labels. */
fontSize?: number;
/** Additional class name for less/more labels. */
labelClassName?: string;
/** Shared level styles for cells and legend swatches. */
levelStyles?: HeatmapLevelStyles;
/** Override the default color scale (used when `levelStyles` is omitted). */
colorScale?: (count: number | null | undefined) => string;
/** Opacity for inactive swatches while interacting. Default: 0.3 */
inactiveOpacity?: number;
/** Scale for inactive swatches while interacting. Default: 1 */
inactiveScale?: number;
/** Scale for the highlighted swatch while interacting. Default: 1 */
activeScale?: number;
/** Sync dimming with chart hover. Default: true when inside HeatmapInteractionProvider */
interactive?: boolean;
className?: string;
}
export const HeatmapLegend = memo(function HeatmapLegend({
lessLabel = "Less",
moreLabel = "More",
cellSize = 11,
gap = 2,
cornerRadius = 2,
align = "end",
variant = "swatches",
gradientSpan = 5,
fontSize,
labelClassName,
levelStyles: levelStylesProp,
colorScale = defaultHeatmapColorScale,
inactiveOpacity = HEATMAP_INACTIVE_OPACITY,
inactiveScale = 1,
activeScale = 1,
interactive,
className,
}: HeatmapLegendProps) {
const interaction = useHeatmapInteractionOptional();
const isInteractive = interactive ?? interaction != null;
const levelStyles =
levelStylesProp ??
(HEATMAP_LEGEND_LEVELS.map((level) => ({
color: colorScale(level),
fillMode: "solid" as const,
pattern: "none" as const,
})) as unknown as HeatmapLevelStyles);
const {
hoveredLegendLevel,
tooltipData,
setHoveredCell,
setHoveredLegendLevel,
setTooltipData,
} = interaction ?? {
hoveredLegendLevel: null,
tooltipData: null,
setHoveredCell: () => undefined,
setHoveredLegendLevel: () => undefined,
setTooltipData: () => undefined,
};
const highlightedLevel =
hoveredLegendLevel ??
(tooltipData ? getHeatmapContributionLevel(tooltipData.count) : null);
const hoverParams = { inactiveOpacity, inactiveScale, activeScale };
const inactiveEnabled = isHeatmapHoverEffectEnabled(hoverParams);
const isDimming =
isInteractive && highlightedLevel !== null && inactiveEnabled;
const handleLegendEnter = useCallback(
(level: number) => {
if (!isInteractive) {
return;
}
setHoveredLegendLevel(level);
setHoveredCell(null);
setTooltipData(null);
},
[isInteractive, setHoveredCell, setHoveredLegendLevel, setTooltipData]
);
const handleLegendLeave = useCallback(() => {
if (!isInteractive) {
return;
}
setHoveredLegendLevel(null);
}, [isInteractive, setHoveredLegendLevel]);
const labelStyle = fontSize == null ? undefined : { fontSize };
return (
<div
className={cn(
"flex w-full items-center gap-1.5 text-chart-label text-xs",
align === "start" && "justify-start",
align === "center" && "justify-center",
align === "end" && "justify-end",
className
)}
style={labelStyle}
>
<span className={labelClassName}>{lessLabel}</span>
{variant === "gradient" ? (
<HeatmapLegendGradient
activeScale={activeScale}
cellSize={cellSize}
cornerRadius={cornerRadius}
gap={gap}
gradientSpan={gradientSpan}
highlightedLevel={highlightedLevel}
inactiveOpacity={inactiveOpacity}
inactiveScale={inactiveScale}
isDimming={isDimming}
isInteractive={isInteractive}
levelStyles={levelStyles}
levels={HEATMAP_LEGEND_LEVELS}
onEnter={handleLegendEnter}
onLeave={handleLegendLeave}
/>
) : (
<div className="flex items-center" style={{ gap }}>
{HEATMAP_LEGEND_LEVELS.map((level) => {
const isHighlighted = highlightedLevel === level;
const isDimmed = isDimming && !isHighlighted;
const hoverStyle = resolveHeatmapHoverStyle(
isHighlighted,
isDimmed,
hoverParams
);
const style = levelStyles[level];
return (
<motion.span
animate={{
opacity: hoverStyle.opacity,
scale: hoverStyle.scale,
}}
aria-hidden="true"
className="block shrink-0 leading-none"
initial={{ opacity: 1, scale: 1 }}
key={level}
onPointerEnter={() => handleLegendEnter(level)}
onPointerLeave={handleLegendLeave}
style={{
cursor: isInteractive ? "pointer" : undefined,
}}
transition={HEATMAP_INACTIVE_TRANSITION}
>
<HeatmapLegendSwatch
cellSize={cellSize}
cornerRadius={cornerRadius}
level={level}
style={style}
/>
</motion.span>
);
})}
</div>
)}
<span className={labelClassName}>{moreLabel}</span>
</div>
);
});
HeatmapLegend.displayName = "HeatmapLegend";
export default HeatmapLegend;
src/charts/heatmap/heatmap-legend-swatch.tsx
"use client";
import { memo } from "react";
import { renderPatternPreset } from "../pattern-preset";
import {
type HeatmapLevelStyle,
heatmapLevelPatternId,
heatmapLevelPatternRenderOptions,
isHeatmapLevelPattern,
} from "./heatmap-colors";
export const HeatmapLegendSwatch = memo(function HeatmapLegendSwatch({
level,
style,
cellSize,
cornerRadius,
}: {
level: number;
style: HeatmapLevelStyle;
cellSize: number;
cornerRadius: number;
}) {
const shellStyle = {
width: cellSize,
height: cellSize,
borderRadius: cornerRadius,
};
if (isHeatmapLevelPattern(style) && style.pattern) {
const patternId = heatmapLevelPatternId(level);
const patternNode = renderPatternPreset(
style.pattern,
patternId,
heatmapLevelPatternRenderOptions(style)
);
const opacity = style.patternOpacity ?? 1;
return (
<span
aria-hidden="true"
className="block shrink-0 overflow-hidden"
style={{ ...shellStyle, opacity }}
>
<svg
aria-hidden="true"
className="block size-full"
viewBox={`0 0 ${cellSize} ${cellSize}`}
>
{patternNode ? <defs>{patternNode}</defs> : null}
<rect
fill={patternNode ? `url(#${patternId})` : style.color}
height={cellSize}
rx={cornerRadius}
ry={cornerRadius}
width={cellSize}
/>
</svg>
</span>
);
}
return (
<span
aria-hidden="true"
className="block shrink-0"
style={{
...shellStyle,
backgroundColor: style.color,
border: level === 0 ? `1px solid ${style.color}` : undefined,
boxSizing: "border-box",
}}
/>
);
});
HeatmapLegendSwatch.displayName = "HeatmapLegendSwatch";
src/charts/heatmap/heatmap-legend-gradient.tsx
"use client";
import { motion } from "motion/react";
import { memo } from "react";
import type { HeatmapLevelStyles } from "./heatmap-colors";
import {
buildHeatmapLegendGradient,
resolveHeatmapHoverStyle,
} from "./heatmap-utils";
const HEATMAP_INACTIVE_TRANSITION = {
duration: 0.22,
ease: [0.4, 0, 0.2, 1] as const,
};
export interface HeatmapLegendGradientProps {
levels: readonly number[];
levelStyles: HeatmapLevelStyles;
cellSize: number;
gap: number;
cornerRadius: number;
gradientSpan: number;
highlightedLevel: number | null;
isDimming: boolean;
inactiveOpacity: number;
inactiveScale: number;
activeScale: number;
isInteractive: boolean;
onEnter: (level: number) => void;
onLeave: () => void;
}
export const HeatmapLegendGradient = memo(function HeatmapLegendGradient({
levels,
levelStyles,
cellSize,
gap,
cornerRadius,
gradientSpan,
highlightedLevel,
isDimming,
inactiveOpacity,
inactiveScale,
activeScale,
isInteractive,
onEnter,
onLeave,
}: HeatmapLegendGradientProps) {
const barWidth = gradientSpan * cellSize + (gradientSpan - 1) * gap;
const barHeight = cellSize;
const pillRadius = Math.min(cornerRadius, barHeight / 2);
const segmentWidth = barWidth / levels.length;
const gradient = buildHeatmapLegendGradient(levelStyles);
return (
<div
className="relative shrink-0"
style={{ width: barWidth, height: barHeight }}
>
<motion.div
animate={{
opacity: isDimming && highlightedLevel === null ? inactiveOpacity : 1,
scale: 1,
}}
aria-hidden="true"
className="absolute inset-0"
initial={{ opacity: 1, scale: 1 }}
style={{
borderRadius: pillRadius,
background: gradient,
}}
transition={HEATMAP_INACTIVE_TRANSITION}
/>
{levels.map((level, index) => {
const isHighlighted = highlightedLevel === level;
const isDimmed = isDimming && !isHighlighted;
const hoverStyle = resolveHeatmapHoverStyle(isHighlighted, isDimmed, {
inactiveOpacity,
inactiveScale,
activeScale,
});
return (
<motion.span
animate={{
opacity: hoverStyle.opacity,
scale: hoverStyle.scale,
}}
className="absolute top-0 block"
initial={{ opacity: 1, scale: 1 }}
key={level}
onPointerEnter={() => onEnter(level)}
onPointerLeave={onLeave}
style={{
left: index * segmentWidth,
width: segmentWidth,
height: barHeight,
cursor: isInteractive ? "pointer" : undefined,
transformOrigin: "center center",
}}
transition={HEATMAP_INACTIVE_TRANSITION}
/>
);
})}
</div>
);
});
HeatmapLegendGradient.displayName = "HeatmapLegendGradient";
export default HeatmapLegendGradient;
src/charts/heatmap/heatmap-tooltip.tsx
"use client";
import { memo } from "react";
import { TooltipBox } from "../tooltip/tooltip-box";
import { useHeatmap, useHeatmapInteraction } from "./heatmap-context";
import {
formatHeatmapContributionLabel,
formatHeatmapTooltipDate,
formatHeatmapTooltipWeekday,
} from "./heatmap-utils";
import { useDelayedTooltipData } from "./use-delayed-tooltip-data";
export interface HeatmapTooltipProps {
/** Custom contribution line (bottom section). Default: `N contribution(s)`. */
formatLabel?: (count: number, date: Date) => string;
/** Custom class name */
className?: string;
/** Inline styles for the tooltip panel (background, blur, etc.). */
panelStyle?: React.CSSProperties;
/**
* Tooltip panel background color (CSS variable or color value).
* Default: `var(--chart-tooltip-background)`.
*/
backgroundColor?: string;
/**
* Delay before showing the tooltip on first hover (ms).
* Moving between cells updates immediately once visible.
*/
showDelay?: number;
/**
* Grace period before hiding when the pointer leaves a cell (ms).
* Helps avoid flicker when moving quickly between adjacent cells.
*/
hideDelay?: number;
/**
* When true, the tooltip appears and disappears instantly with no motion.
*/
instant?: boolean;
}
export const HeatmapTooltip = memo(function HeatmapTooltip({
formatLabel = formatHeatmapContributionLabel,
className = "",
panelStyle,
backgroundColor,
showDelay = 0,
hideDelay = 120,
instant = false,
}: HeatmapTooltipProps) {
const { containerRef, width, height } = useHeatmap();
const { tooltipData } = useHeatmapInteraction();
const displayData = useDelayedTooltipData(tooltipData, showDelay, hideDelay);
if (!displayData) {
return null;
}
const { count, date } = displayData;
return (
<TooltipBox
animate={false}
backgroundColor={backgroundColor}
className={className}
containerHeight={height}
containerRef={containerRef}
containerWidth={width}
entrance={!instant}
panelStyle={panelStyle}
visible
x={displayData.x}
y={displayData.y}
>
<div className="overflow-hidden">
<div className="px-3 py-2.5 text-left">
<div className="font-medium text-chart-tooltip-foreground text-xs">
{formatHeatmapTooltipDate(date)}
</div>
<div className="mt-0.5 text-chart-tooltip-muted text-xs">
{formatHeatmapTooltipWeekday(date)}
</div>
<div className="my-2 border-chart-tooltip-muted/30 border-t" />
<div className="text-chart-tooltip-foreground text-sm">
{formatLabel(count, date)}
</div>
</div>
</div>
</TooltipBox>
);
});
HeatmapTooltip.displayName = "HeatmapTooltip";
export default HeatmapTooltip;
src/charts/heatmap/use-delayed-tooltip-data.ts
"use client";
import { useEffect, useRef, useState } from "react";
import type { HeatmapTooltipData } from "./heatmap-context";
export function useDelayedTooltipData(
tooltipData: HeatmapTooltipData | null,
showDelay: number,
hideDelay: number
): HeatmapTooltipData | null {
const [displayData, setDisplayData] = useState<HeatmapTooltipData | null>(
null
);
const isShowingRef = useRef(false);
const showTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined
);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined
);
useEffect(() => {
if (showTimerRef.current) {
clearTimeout(showTimerRef.current);
showTimerRef.current = undefined;
}
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = undefined;
}
if (tooltipData) {
if (isShowingRef.current) {
setDisplayData(tooltipData);
return;
}
if (showDelay === 0) {
isShowingRef.current = true;
setDisplayData(tooltipData);
return;
}
showTimerRef.current = setTimeout(() => {
isShowingRef.current = true;
setDisplayData(tooltipData);
}, showDelay);
return;
}
if (hideDelay === 0) {
isShowingRef.current = false;
setDisplayData(null);
return;
}
hideTimerRef.current = setTimeout(() => {
isShowingRef.current = false;
setDisplayData(null);
}, hideDelay);
}, [tooltipData, showDelay, hideDelay]);
useEffect(
() => () => {
if (showTimerRef.current) {
clearTimeout(showTimerRef.current);
}
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
}
},
[]
);
return displayData;
}
src/charts/heatmap/heatmap-separator.tsx
"use client";
import { memo, useEffect, useId, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { useHeatmap } from "./heatmap-context";
import {
buildHeatmapSeparatorGradientStops,
getHeatmapSeparatorLineY,
getHeatmapSeparatorX,
type HeatmapSeparatorGradient,
type HeatmapSeparatorStrokeStyle,
resolveHeatmapSeparatorStrokeDasharray,
} from "./heatmap-utils";
/** Marker for reliably identifying {@link HeatmapSeparator} in chart child trees. */
export const HEATMAP_SEPARATOR_MARKER = "__isHeatmapSeparator" as const;
export interface HeatmapSeparatorProps {
/**
* Insert a separator before every Nth column (e.g. 4 = divide every four weeks).
* Used when `groupBy="every"`. When omitted, uses `columnSeparators` from
* {@link HeatmapChart} or a sibling {@link HeatmapSeparator} parsed for layout.
*/
every?: number;
/**
* Group columns by fixed interval (`every`) or calendar quarter (`quarter`).
* Default: `"every"`.
*/
groupBy?: "every" | "quarter";
/** Additional class name for each separator group. */
className?: string;
/**
* Extra horizontal gap between column groups in pixels (shifts cells apart).
* Parsed from this component by {@link HeatmapChart} for layout. Default: 0
*/
spacing?: number;
/** Half-width of the line band in pixels (line is centered). Default: 0 */
paddingX?: number;
/** Inset from the bottom of the plot in pixels. Default: 0 */
paddingY?: number;
/**
* Distance from the chart container top to the line start, in pixels.
* Use to align with {@link HeatmapXAxis} labels (e.g. `14`). The line always
* extends to the plot bottom. Default: plot top (`margin.top`).
*/
startOffset?: number;
/**
* Distance below the separator line top to place quarter labels.
* `0` aligns labels with the line top. Default: 0.
*/
labelOffset?: number;
/** Draw Q1–Q4 labels at the start of each quarter group. Default: false */
showLabels?: boolean;
/** Format quarter group labels. Default: `Q{n}` */
labelFormat?: (quarter: number, startDate: Date) => string;
/** Additional class name for quarter labels */
labelClassName?: string;
/** Solid or dashed separator line. Default: `"solid"` */
strokeStyle?: HeatmapSeparatorStrokeStyle;
/** Dash pattern when `strokeStyle="dashed"`. Default: `"4,4"` */
strokeDasharray?: string;
/** Line stroke color when `gradient` is omitted. Default: `var(--border)` */
stroke?: string;
/**
* Vertical stroke gradient aligned to each line's span.
* Use matching `from`/`via`/`to` colors with fading opacities to soften the
* top and bottom (e.g. `{ fromOpacity: 0, viaOpacity: 1, toOpacity: 0 }`).
*/
gradient?: HeatmapSeparatorGradient;
/** Line stroke width in pixels. Default: 1 */
strokeWidth?: number;
/** Multiplier applied to solid strokes or gradient stop opacities. Default: 1 */
strokeOpacity?: number;
}
export const HeatmapSeparator = memo(function HeatmapSeparator({
className,
paddingX = 0,
paddingY = 0,
startOffset,
labelOffset = 0,
showLabels = false,
labelFormat = (quarter) => `Q${quarter}`,
labelClassName,
strokeStyle = "solid",
strokeDasharray,
stroke = "var(--border)",
gradient,
strokeWidth = 1,
strokeOpacity = 1,
}: HeatmapSeparatorProps) {
const { gap, innerHeight, margin, separatorLayout, xScale, containerRef } =
useHeatmap();
const [mounted, setMounted] = useState(false);
const reactId = useId().replace(/:/g, "");
const gradientId = `heatmap-separator-gradient-${reactId}`;
useEffect(() => {
setMounted(true);
}, []);
const separators = useMemo(() => {
if (!separatorLayout) {
return [];
}
return separatorLayout.atColumns.map((columnIndex) => ({
columnIndex,
x: getHeatmapSeparatorX(columnIndex, gap, separatorLayout, xScale),
}));
}, [gap, separatorLayout, xScale]);
const labels = useMemo(() => {
if (!(showLabels && separatorLayout?.groups.length)) {
return [];
}
return separatorLayout.groups.map((group) => ({
key: `${group.year}-Q${group.quarter}-${group.startColumnIndex}`,
label: labelFormat(group.quarter, group.startDate),
x: margin.left + xScale(group.startColumnIndex),
}));
}, [labelFormat, margin.left, separatorLayout, showLabels, xScale]);
if (!separatorLayout) {
return null;
}
const { y1: lineY1, y2: lineY2 } = getHeatmapSeparatorLineY({
innerHeight,
marginTop: margin.top,
startOffset,
paddingY,
});
const separatorTop = startOffset ?? margin.top;
const labelTop = separatorTop + labelOffset;
const resolvedStroke = gradient ? `url(#${gradientId})` : stroke;
const resolvedStrokeDasharray = resolveHeatmapSeparatorStrokeDasharray(
strokeStyle,
strokeDasharray
);
const gradientStops = gradient
? buildHeatmapSeparatorGradientStops(gradient, strokeOpacity)
: null;
const container = containerRef.current;
const labelPortal =
mounted && container && labels.length > 0
? createPortal(
labels.map((tick) => (
<div
className="pointer-events-none absolute"
key={tick.key}
style={{
top: labelTop,
left: tick.x,
width: 0,
display: "flex",
justifyContent: "flex-start",
}}
>
<span
className={cn(
"whitespace-nowrap text-chart-label text-xs",
labelClassName
)}
>
{tick.label}
</span>
</div>
)),
container
)
: null;
if (separators.length === 0) {
return labelPortal;
}
return (
<>
{gradientStops ? (
<defs>
<linearGradient
gradientUnits="userSpaceOnUse"
id={gradientId}
x1={0}
x2={0}
y1={lineY1}
y2={lineY2}
>
{gradientStops.map((stop) => (
<stop
key={stop.offset}
offset={stop.offset}
stopColor={stop.color}
stopOpacity={stop.opacity}
/>
))}
</linearGradient>
</defs>
) : null}
<g>
{separators.map((separator) => (
<g
className={cn(className)}
key={separator.columnIndex}
transform={`translate(${separator.x}, 0)`}
>
{paddingX > 0 ? (
<rect
fill="transparent"
height={lineY2 - lineY1}
width={paddingX * 2}
x={-paddingX}
y={lineY1}
/>
) : null}
<line
stroke={resolvedStroke}
strokeDasharray={resolvedStrokeDasharray}
strokeOpacity={gradient ? undefined : strokeOpacity}
strokeWidth={strokeWidth}
x1={0}
x2={0}
y1={lineY1}
y2={lineY2}
/>
</g>
))}
</g>
{labelPortal}
</>
);
});
(HeatmapSeparator as unknown as Record<string, boolean>)[
HEATMAP_SEPARATOR_MARKER
] = true;
HeatmapSeparator.displayName = "HeatmapSeparator";
export default HeatmapSeparator;
src/charts/heatmap/heatmap-resolve-separator.ts
import type { ReactElement, ReactNode } from "react";
import { Children, isValidElement } from "react";
import { resolveChartChildElement } from "../chart-child-passthrough";
import type { HeatmapColumn } from "./heatmap-context";
import {
HEATMAP_SEPARATOR_MARKER,
HeatmapSeparator,
} from "./heatmap-separator";
import type {
HeatmapSeparatorGroupBy,
HeatmapSeparatorLayout,
HeatmapSeparatorParsedConfig,
} from "./heatmap-utils";
import { resolveHeatmapSeparatorLayout } from "./heatmap-utils";
function getChildComponentName(child: ReactElement): string {
const childType = child.type as { displayName?: string; name?: string };
return typeof child.type === "function"
? childType.displayName || childType.name || ""
: "";
}
function isHeatmapSeparatorElement(child: ReactElement): boolean {
const resolved = resolveChartChildElement(child);
const type = resolved.type as { [HEATMAP_SEPARATOR_MARKER]?: boolean };
return (
resolved.type === HeatmapSeparator ||
type[HEATMAP_SEPARATOR_MARKER] === true ||
getChildComponentName(resolved) === "HeatmapSeparator"
);
}
function readHeatmapSeparatorConfig(
child: ReactElement
): HeatmapSeparatorParsedConfig | null {
const props = resolveChartChildElement(child).props as {
every?: number;
groupBy?: HeatmapSeparatorGroupBy;
spacing?: number;
};
const groupBy = props.groupBy ?? "every";
if (groupBy === "quarter") {
return {
groupBy: "quarter",
spacing: props.spacing ?? 0,
};
}
if (props.every == null || props.every <= 0) {
return null;
}
return {
groupBy: "every",
every: props.every,
spacing: props.spacing ?? 0,
};
}
/** Reads the first {@link HeatmapSeparator} child for separator config. */
export function resolveHeatmapSeparatorConfig(
children: ReactNode,
chartSeparators?: HeatmapSeparatorParsedConfig | null
): HeatmapSeparatorParsedConfig | null {
let config: HeatmapSeparatorParsedConfig | null = null;
const visit = (node: ReactNode) => {
if (config) {
return;
}
Children.forEach(node, (child) => {
if (config || !isValidElement(child)) {
return;
}
if (isHeatmapSeparatorElement(child)) {
config = readHeatmapSeparatorConfig(child);
return;
}
const childProps = child.props as { children?: ReactNode } | undefined;
if (childProps?.children) {
visit(childProps.children);
}
});
};
visit(children);
return config ?? normalizeHeatmapSeparatorConfig(chartSeparators);
}
function normalizeHeatmapSeparatorConfig(
config?: HeatmapSeparatorParsedConfig | null
): HeatmapSeparatorParsedConfig | null {
if (!config) {
return null;
}
if (config.groupBy === "quarter") {
return {
groupBy: "quarter",
spacing: config.spacing ?? 0,
};
}
if (!config.every || config.every <= 0) {
return null;
}
return {
groupBy: "every",
every: config.every,
spacing: config.spacing ?? 0,
};
}
/** Merges chart prop and child-derived config, then resolves column indices from data. */
export function resolveHeatmapSeparatorConfigWithData(
children: ReactNode,
columns: HeatmapColumn[],
chartSeparators?: HeatmapSeparatorParsedConfig | null
): HeatmapSeparatorLayout | null {
const config = resolveHeatmapSeparatorConfig(children, chartSeparators);
return resolveHeatmapSeparatorLayout(config, columns);
}
src/charts/heatmap/heatmap-colors.ts
import { CHART_SCALE_VARS } from "../chart-scale";
import type { PatternPresetId } from "../pattern-preset";
import { getHeatmapContributionLevel } from "./heatmap-utils";
/** Default Less → More scale using {@link CHART_SCALE_VARS}. */
export const HEATMAP_DEFAULT_LEVEL_COLORS = CHART_SCALE_VARS;
export type HeatmapLevelColors = readonly [
string,
string,
string,
string,
string,
];
export type HeatmapLevelFillMode = "solid" | "pattern";
export interface HeatmapLevelStyle {
color: string;
fillMode?: HeatmapLevelFillMode;
pattern?: PatternPresetId;
patternColor?: string;
patternScale?: number;
patternStrokeWidth?: number;
patternRadius?: number;
patternComplement?: boolean;
patternFill?: string;
patternTileBackground?: string;
patternOpacity?: number;
patternDotsFill?: boolean;
}
export type HeatmapLevelStyles = readonly [
HeatmapLevelStyle,
HeatmapLevelStyle,
HeatmapLevelStyle,
HeatmapLevelStyle,
HeatmapLevelStyle,
];
export const HEATMAP_DEFAULT_LEVEL_STYLES = [
{
color: HEATMAP_DEFAULT_LEVEL_COLORS[0],
fillMode: "solid",
pattern: "none",
},
{
color: HEATMAP_DEFAULT_LEVEL_COLORS[1],
fillMode: "solid",
pattern: "none",
},
{
color: HEATMAP_DEFAULT_LEVEL_COLORS[2],
fillMode: "solid",
pattern: "none",
},
{
color: HEATMAP_DEFAULT_LEVEL_COLORS[3],
fillMode: "solid",
pattern: "none",
},
{
color: HEATMAP_DEFAULT_LEVEL_COLORS[4],
fillMode: "solid",
pattern: "none",
},
] as const satisfies HeatmapLevelStyles;
export function heatmapLevelPatternId(level: number): string {
return `heatmap-level-${level}`;
}
export function isHeatmapLevelPattern(style: HeatmapLevelStyle): boolean {
return (
style.fillMode === "pattern" &&
style.pattern != null &&
style.pattern !== "none"
);
}
export function heatmapPatternStrokeFallback(color: string): string {
return `color-mix(in oklch, ${color} 45%, white)`;
}
export function heatmapLevelPatternRenderOptions(style: HeatmapLevelStyle) {
const preset = style.pattern ?? "diagonal";
let defaultScale = 1;
if (preset === "cross") {
defaultScale = 1.33;
}
return {
color:
style.patternColor?.trim() ||
(preset === "accent"
? "#e879f9"
: heatmapPatternStrokeFallback(style.color)),
tileBackground: style.patternTileBackground?.trim() || style.color,
scale: style.patternScale ?? defaultScale,
strokeWidth: style.patternStrokeWidth,
radius: style.patternRadius,
complement: style.patternComplement,
fill: style.patternFill?.trim() || undefined,
dotFill: style.patternDotsFill,
};
}
export function heatmapLevelCellFillOpacity(style: HeatmapLevelStyle): number {
if (!isHeatmapLevelPattern(style)) {
return 1;
}
return style.patternOpacity ?? 1;
}
export function levelColorsFromStyles(
levelStyles: HeatmapLevelStyles
): HeatmapLevelColors {
return [
levelStyles[0].color,
levelStyles[1].color,
levelStyles[2].color,
levelStyles[3].color,
levelStyles[4].color,
];
}
export function levelStylesFromColors(
levelColors: HeatmapLevelColors
): HeatmapLevelStyles {
return [
{ color: levelColors[0], fillMode: "solid", pattern: "none" },
{ color: levelColors[1], fillMode: "solid", pattern: "none" },
{ color: levelColors[2], fillMode: "solid", pattern: "none" },
{ color: levelColors[3], fillMode: "solid", pattern: "none" },
{ color: levelColors[4], fillMode: "solid", pattern: "none" },
];
}
export function resolveHeatmapLevelStyles(
levelColors?: HeatmapLevelColors,
levelStyles?: HeatmapLevelStyles
): HeatmapLevelStyles {
if (levelStyles) {
return levelStyles;
}
if (levelColors) {
return levelStylesFromColors(levelColors);
}
return HEATMAP_DEFAULT_LEVEL_STYLES;
}
export function buildHeatmapColorScale(
levelColors: HeatmapLevelColors
): (count: number | null | undefined) => string {
return buildHeatmapColorScaleFromStyles(levelStylesFromColors(levelColors));
}
export function buildHeatmapColorScaleFromStyles(
levelStyles: HeatmapLevelStyles
): (count: number | null | undefined) => string {
return (count: number | null | undefined) => {
const level = getHeatmapContributionLevel(count ?? 0);
return levelStyles[level]?.color ?? levelStyles[0].color;
};
}
export function buildHeatmapFillScale(
levelStyles: HeatmapLevelStyles
): (count: number | null | undefined) => string {
return (count: number | null | undefined) => {
const level = getHeatmapContributionLevel(count ?? 0);
const style = levelStyles[level] ?? levelStyles[0];
if (isHeatmapLevelPattern(style)) {
return `url(#${heatmapLevelPatternId(level)})`;
}
return style.color;
};
}
export const defaultHeatmapColorScale = buildHeatmapColorScale(
HEATMAP_DEFAULT_LEVEL_COLORS
);
export const defaultHeatmapFillScale = buildHeatmapFillScale(
HEATMAP_DEFAULT_LEVEL_STYLES
);
src/charts/chart-scale.ts
/** Sequential scale CSS variables for heatmaps, choropleths, and binned data (01 = lowest, 05 = highest). */
export const CHART_SCALE_VARS = [
"var(--chart-scale-01)",
"var(--chart-scale-02)",
"var(--chart-scale-03)",
"var(--chart-scale-04)",
"var(--chart-scale-05)",
] as const;
export type ChartScaleVars = typeof CHART_SCALE_VARS;
export const chartScaleCssVars = {
scale01: CHART_SCALE_VARS[0],
scale02: CHART_SCALE_VARS[1],
scale03: CHART_SCALE_VARS[2],
scale04: CHART_SCALE_VARS[3],
scale05: CHART_SCALE_VARS[4],
patternColor: "var(--chart-scale-pattern-color)",
} as const;
src/charts/heatmap/heatmap-utils.ts
import type { HeatmapLevelStyles } from "./heatmap-colors";
import type { HeatmapBin, HeatmapColumn } from "./heatmap-context";
/** Calendar months shown in default one-year contribution grids. */
export const HEATMAP_MONTHS_ONE_YEAR = 12;
/** Half-year contribution grids (gallery card demos). */
export const HEATMAP_MONTHS_SIX = 6;
/** Nominal week count for one year (~52). Default data uses calendar-month math instead. */
export const HEATMAP_WEEKS_ONE_YEAR = 52;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const MS_PER_WEEK = MS_PER_DAY * 7;
export function getHeatmapCalendarRangeStart(
today: Date,
months: number
): Date {
const monthOffset = months === HEATMAP_MONTHS_SIX ? months : months - 1;
const start = new Date(
today.getFullYear(),
today.getMonth() - monthOffset,
1
);
start.setHours(0, 0, 0, 0);
return start;
}
export function getHeatmapYearStartMonth(today: Date): Date {
return getHeatmapCalendarRangeStart(today, HEATMAP_MONTHS_ONE_YEAR);
}
export function getHeatmapWeekStartSunday(date: Date): Date {
const sunday = new Date(date);
sunday.setDate(sunday.getDate() - sunday.getDay());
sunday.setHours(0, 0, 0, 0);
return sunday;
}
export function getHeatmapWeekCount(startSunday: Date, endDate: Date): number {
const endSunday = getHeatmapWeekStartSunday(endDate);
return (
Math.floor((endSunday.getTime() - startSunday.getTime()) / MS_PER_WEEK) + 1
);
}
/** Days in a Sun–Sat column on or after `threshold` (for trimming partial lead weeks). */
export function countHeatmapWeekDaysOnOrAfter(
weekStart: Date,
threshold: Date
): number {
const day = new Date(weekStart);
day.setHours(0, 0, 0, 0);
const cutoff = new Date(threshold);
cutoff.setHours(0, 0, 0, 0);
let count = 0;
for (let i = 0; i < 7; i++) {
if (day >= cutoff) {
count++;
}
day.setDate(day.getDate() + 1);
}
return count;
}
/**
* First Sunday week column where enough days fall on/after `rangeStart`.
* Skips a lead week that is mostly before the range (e.g. late July before Aug 1).
*/
export function getHeatmapWeekStartAlignedToRange(
rangeStart: Date,
minDaysInFirstWeek = 4
): Date {
const startDate = getHeatmapWeekStartSunday(rangeStart);
const weekEnd = new Date(startDate);
weekEnd.setDate(weekEnd.getDate() + 6);
// Keep the Sun–Sat column that contains the 1st (e.g. Jan 1 in a partial lead week).
if (rangeStart >= startDate && rangeStart <= weekEnd) {
return startDate;
}
while (
countHeatmapWeekDaysOnOrAfter(startDate, rangeStart) < minDaysInFirstWeek
) {
startDate.setDate(startDate.getDate() + 7);
}
return startDate;
}
/** Column index for a month label — snaps to separator group start when layout is set. */
export function getHeatmapMonthLabelColumnIndex(
columnIndex: number,
separatorLayout: Pick<HeatmapSeparatorLayout, "atColumns"> | null
): number {
if (!separatorLayout?.atColumns.length) {
return columnIndex;
}
return getHeatmapSeparatorGroupStartColumn(
columnIndex,
separatorLayout.atColumns
);
}
export interface HeatmapWeekRange {
startDate: Date;
weekCount: number;
/** First in-range calendar day; bins before this are empty in default year grids. */
rangeStart: Date | null;
}
/** Default `weeks` uses 12 calendar months; other values use a rolling week window. */
export function resolveHeatmapWeekRange(
today: Date,
weeks: number = HEATMAP_WEEKS_ONE_YEAR
): HeatmapWeekRange {
const endDate = new Date(today);
endDate.setHours(0, 0, 0, 0);
if (weeks === HEATMAP_WEEKS_ONE_YEAR) {
const rangeStart = getHeatmapYearStartMonth(endDate);
const startDate = getHeatmapWeekStartAlignedToRange(rangeStart);
return {
startDate,
weekCount: getHeatmapWeekCount(startDate, endDate),
rangeStart,
};
}
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - (weeks - 1) * 7);
startDate.setDate(startDate.getDate() - startDate.getDay());
startDate.setHours(0, 0, 0, 0);
return {
startDate,
weekCount: weeks,
rangeStart: null,
};
}
/** Month label anchor for a week column — prefers the 1st, else the 1st of the first bin's month. */
export function getHeatmapColumnMonthAnchor(
column: HeatmapColumn
): Date | null {
for (const bin of column.bins) {
if (bin.date && bin.date.getDate() === 1) {
return bin.date;
}
}
const firstDate = column.bins[0]?.date;
if (!firstDate) {
return null;
}
return new Date(firstDate.getFullYear(), firstDate.getMonth(), 1);
}
export function getHeatmapColumnStartDate(column: HeatmapColumn): Date | null {
return column.bins[0]?.date ?? null;
}
export function getHeatmapColumnEndDate(column: HeatmapColumn): Date | null {
const lastBin = column.bins.at(-1);
return lastBin?.date ?? null;
}
export function getHeatmapTimeExtent(
columns: HeatmapColumn[]
): [Date, Date] | null {
if (columns.length === 0) {
return null;
}
const firstColumn = columns[0];
if (!firstColumn) {
return null;
}
const start = getHeatmapColumnStartDate(firstColumn);
const lastColumn = columns.at(-1);
if (!lastColumn) {
return null;
}
const end = getHeatmapColumnEndDate(lastColumn);
if (!(start && end)) {
return null;
}
return [start, end];
}
export function filterHeatmapColumns(
columns: HeatmapColumn[],
xDomain?: [Date, Date]
): HeatmapColumn[] {
if (!xDomain) {
return columns;
}
const start = Math.min(xDomain[0].getTime(), xDomain[1].getTime());
const end = Math.max(xDomain[0].getTime(), xDomain[1].getTime());
return columns.filter((column) => {
const weekStart = getHeatmapColumnStartDate(column)?.getTime();
const weekEnd = getHeatmapColumnEndDate(column)?.getTime();
if (weekStart == null || weekEnd == null) {
return false;
}
return weekEnd >= start && weekStart <= end;
});
}
const heatmapTooltipMonthFmt = new Intl.DateTimeFormat("en-US", {
month: "long",
});
const heatmapTooltipWeekdayFmt = new Intl.DateTimeFormat("en-US", {
weekday: "long",
});
function formatHeatmapOrdinalDay(day: number): string {
if (day >= 11 && day <= 13) {
return `${day}th`;
}
switch (day % 10) {
case 1:
return `${day}st`;
case 2:
return `${day}nd`;
case 3:
return `${day}rd`;
default:
return `${day}th`;
}
}
/** Tooltip header date — e.g. `January 20th 2026`. */
export function formatHeatmapTooltipDate(date: Date): string {
const month = heatmapTooltipMonthFmt.format(date);
const day = formatHeatmapOrdinalDay(date.getDate());
return `${month} ${day} ${date.getFullYear()}`;
}
/** Tooltip weekday line — e.g. `Monday`. */
export function formatHeatmapTooltipWeekday(date: Date): string {
return heatmapTooltipWeekdayFmt.format(date);
}
/** Tooltip contribution line — e.g. `3 contributions`. */
export function formatHeatmapContributionLabel(
count: number,
_date?: Date
): string {
const word = count === 1 ? "contribution" : "contributions";
return `${count} ${word}`;
}
/** Sunday-first day labels for heatmap row bins. */
export const HEATMAP_DAY_LABELS = [
"Sun",
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
] as const;
/** First row of the grid — `0` = Sunday (GitHub default). */
export type HeatmapWeekStartDay = 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** Day labels with row 0 aligned to `weekStartDay`. */
export function getHeatmapDayLabels(
weekStartDay: HeatmapWeekStartDay = 0
): readonly string[] {
if (weekStartDay === 0) {
return HEATMAP_DAY_LABELS;
}
return [
...HEATMAP_DAY_LABELS.slice(weekStartDay),
...HEATMAP_DAY_LABELS.slice(0, weekStartDay),
];
}
/** Rotates Sun-first column bins so display row 0 starts on `weekStartDay`. */
export function rotateHeatmapColumnBins(
columns: HeatmapColumn[],
weekStartDay: HeatmapWeekStartDay = 0
): HeatmapColumn[] {
if (weekStartDay === 0) {
return columns;
}
return columns.map((column) => ({
...column,
bins: [
...column.bins.slice(weekStartDay),
...column.bins.slice(0, weekStartDay),
],
}));
}
/** Which Y-axis row ticks to display. */
export type HeatmapYAxisTickFilter = "all" | "odd" | "even";
/** Y-axis label display — `initial` shows the first letter only (Mon → M). */
export type HeatmapYAxisLabelFormat = "full" | "initial";
export function formatHeatmapYAxisLabel(
label: string,
labelFormat: HeatmapYAxisLabelFormat
): string {
return labelFormat === "initial" ? label.charAt(0) : label;
}
export function shouldShowHeatmapYAxisTick(
row: number,
tickFilter: HeatmapYAxisTickFilter
): boolean {
switch (tickFilter) {
case "all":
return true;
case "odd":
return row % 2 === 1;
case "even":
return row % 2 === 0;
default:
return row % 2 === 1;
}
}
/** Layout spacing parsed from {@link HeatmapSeparator}. */
export type HeatmapSeparatorGroupBy = "every" | "quarter";
/** Separator config from props — resolved to column indices once data is known. */
export interface HeatmapSeparatorParsedConfig {
groupBy: HeatmapSeparatorGroupBy;
every?: number;
spacing: number;
}
export interface HeatmapSeparatorGroup {
startColumnIndex: number;
quarter: number;
year: number;
startDate: Date;
label: string;
}
/** Resolved separator layout used for column offsets and rendering. */
export interface HeatmapSeparatorLayout {
spacing: number;
atColumns: number[];
groups: HeatmapSeparatorGroup[];
}
/** Separator line style. */
export type HeatmapSeparatorStrokeStyle = "solid" | "dashed";
/** Vertical stroke gradient for separator lines (`from` → optional `via` → `to`). */
export interface HeatmapSeparatorGradient {
from: string;
via?: string;
to: string;
fromOpacity?: number;
viaOpacity?: number;
toOpacity?: number;
}
export interface HeatmapSeparatorGradientStop {
offset: string;
color: string;
opacity: number;
}
/** Builds SVG gradient stops for a vertical separator line. */
export function buildHeatmapSeparatorGradientStops(
gradient: HeatmapSeparatorGradient,
strokeOpacity = 1
): HeatmapSeparatorGradientStop[] {
const scaleOpacity = (value: number | undefined, fallback = 1) =>
(value ?? fallback) * strokeOpacity;
if (gradient.via != null) {
return [
{
offset: "0%",
color: gradient.from,
opacity: scaleOpacity(gradient.fromOpacity),
},
{
offset: "50%",
color: gradient.via,
opacity: scaleOpacity(gradient.viaOpacity),
},
{
offset: "100%",
color: gradient.to,
opacity: scaleOpacity(gradient.toOpacity),
},
];
}
return [
{
offset: "0%",
color: gradient.from,
opacity: scaleOpacity(gradient.fromOpacity),
},
{
offset: "100%",
color: gradient.to,
opacity: scaleOpacity(gradient.toOpacity),
},
];
}
export function resolveHeatmapSeparatorStrokeDasharray(
strokeStyle: HeatmapSeparatorStrokeStyle = "solid",
strokeDasharray?: string
): string | undefined {
if (strokeStyle !== "dashed") {
return undefined;
}
return strokeDasharray ?? "4,4";
}
/** Calendar quarter (1–4) for Jan–Mar, Apr–Jun, Jul–Sep, Oct–Dec. */
export function getCalendarQuarter(date: Date): number {
return Math.floor(date.getMonth() / 3) + 1;
}
const CALENDAR_QUARTER_START_MONTHS = [0, 3, 6, 9] as const;
/** Jan/Apr/Jul/Oct 1 dates strictly after `gridStart` and on or before `gridEnd`. */
export function getCalendarQuarterStartDatesBetween(
gridStart: Date,
gridEnd: Date
): Date[] {
const startTime = gridStart.getTime();
const endTime = gridEnd.getTime();
const dates: Date[] = [];
for (
let year = gridStart.getFullYear();
year <= gridEnd.getFullYear();
year++
) {
for (const month of CALENDAR_QUARTER_START_MONTHS) {
const date = new Date(year, month, 1);
date.setHours(0, 0, 0, 0);
const time = date.getTime();
if (time > startTime && time <= endTime) {
dates.push(date);
}
}
}
return dates;
}
/** Week column index whose Sun–Sat span contains `date`. */
export function findHeatmapColumnIndexForDate(
columns: HeatmapColumn[],
date: Date
): number | null {
const target = new Date(date);
target.setHours(0, 0, 0, 0);
const targetTime = target.getTime();
for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) {
const column = columns[columnIndex];
if (!column) {
continue;
}
const weekStart = getHeatmapColumnStartDate(column);
const weekEnd = getHeatmapColumnEndDate(column);
if (!(weekStart && weekEnd)) {
continue;
}
weekStart.setHours(0, 0, 0, 0);
weekEnd.setHours(0, 0, 0, 0);
if (targetTime >= weekStart.getTime() && targetTime <= weekEnd.getTime()) {
return columnIndex;
}
}
return null;
}
/** Quarter anchor for a week column — prefers the 1st of a quarter month, else the quarter of the first bin. */
export function getHeatmapColumnQuarterAnchor(column: HeatmapColumn): {
quarter: number;
year: number;
date: Date;
} | null {
for (const bin of column.bins) {
if (!bin.date) {
continue;
}
const month = bin.date.getMonth();
if (bin.date.getDate() === 1 && month % 3 === 0) {
return {
quarter: month / 3 + 1,
year: bin.date.getFullYear(),
date: bin.date,
};
}
}
const firstDate = column.bins[0]?.date;
if (!firstDate) {
return null;
}
const quarter = getCalendarQuarter(firstDate);
return {
quarter,
year: firstDate.getFullYear(),
date: new Date(firstDate.getFullYear(), (quarter - 1) * 3, 1),
};
}
/** Label column snapped to the start of a separator group. */
export function getHeatmapSeparatorGroupStartColumn(
columnIndex: number,
atColumns: number[]
): number {
let groupStart = 0;
for (const start of atColumns) {
if (start <= columnIndex) {
groupStart = start;
} else {
break;
}
}
return groupStart;
}
/** Column indices (0-based) where a vertical separator is drawn (fixed interval). */
export function getHeatmapSeparatorColumnIndices(
columnCount: number,
every: number
): number[] {
if (every <= 0 || columnCount <= every) {
return [];
}
const indices: number[] = [];
for (
let columnIndex = every;
columnIndex < columnCount;
columnIndex += every
) {
indices.push(columnIndex);
}
return indices;
}
/** Matches demo-style calendar grids to their range start (Jan 1, etc.). */
export function inferHeatmapCalendarRangeStart(
columns: HeatmapColumn[]
): Date | null {
const firstColumn = columns[0];
if (!firstColumn) {
return null;
}
const gridStart = getHeatmapColumnStartDate(firstColumn);
if (!gridStart) {
return null;
}
const today = new Date();
today.setHours(0, 0, 0, 0);
for (const months of [HEATMAP_MONTHS_SIX, HEATMAP_MONTHS_ONE_YEAR]) {
const rangeStart = getHeatmapCalendarRangeStart(today, months);
const alignedStart = getHeatmapWeekStartAlignedToRange(rangeStart);
if (gridStart.getTime() === alignedStart.getTime()) {
return rangeStart;
}
}
return null;
}
export function buildHeatmapQuarterSeparatorGroups(
columns: HeatmapColumn[]
): HeatmapSeparatorGroup[] {
if (columns.length === 0) {
return [];
}
const extent = getHeatmapTimeExtent(columns);
if (!extent) {
return [];
}
const [extentStart, gridEnd] = extent;
const displayRange = resolveHeatmapDisplayRange(columns);
const quarterRangeStart =
displayRange.start ??
inferHeatmapCalendarRangeStart(columns) ??
extentStart;
const firstQuarter = getCalendarQuarter(quarterRangeStart);
const groups: HeatmapSeparatorGroup[] = [
{
startColumnIndex: 0,
quarter: firstQuarter,
year: quarterRangeStart.getFullYear(),
startDate: quarterRangeStart,
label: `Q${firstQuarter}`,
},
];
const usedColumns = new Set<number>([0]);
const quarterStarts = getCalendarQuarterStartDatesBetween(
quarterRangeStart,
gridEnd
);
for (const quarterStart of quarterStarts) {
const columnIndex = findHeatmapColumnIndexForDate(columns, quarterStart);
if (
columnIndex == null ||
columnIndex === 0 ||
usedColumns.has(columnIndex)
) {
continue;
}
usedColumns.add(columnIndex);
const quarter = getCalendarQuarter(quarterStart);
groups.push({
startColumnIndex: columnIndex,
quarter,
year: quarterStart.getFullYear(),
startDate: quarterStart,
label: `Q${quarter}`,
});
}
groups.sort((a, b) => a.startColumnIndex - b.startColumnIndex);
return groups;
}
export function resolveHeatmapSeparatorLayout(
config: HeatmapSeparatorParsedConfig | null,
columns: HeatmapColumn[]
): HeatmapSeparatorLayout | null {
if (!config) {
return null;
}
if (config.groupBy === "quarter") {
const groups = buildHeatmapQuarterSeparatorGroups(columns);
if (groups.length === 0) {
return null;
}
return {
spacing: config.spacing,
atColumns: groups
.map((group) => group.startColumnIndex)
.filter((columnIndex) => columnIndex > 0),
groups,
};
}
if (!config.every || config.every <= 0) {
return null;
}
const atColumns = getHeatmapSeparatorColumnIndices(
columns.length,
config.every
);
return {
spacing: config.spacing,
atColumns,
groups: [],
};
}
export function getHeatmapSeparatorCount(
separator: Pick<HeatmapSeparatorLayout, "atColumns"> | null
): number {
return separator?.atColumns.length ?? 0;
}
/** Extra x-offset for a column when separator spacing is enabled. */
export function getHeatmapColumnXOffset(
columnIndex: number,
separator: Pick<HeatmapSeparatorLayout, "spacing" | "atColumns"> | null
): number {
if (!separator || separator.spacing <= 0) {
return 0;
}
if (columnIndex <= 0) {
return 0;
}
const separatorCount = separator.atColumns.filter(
(atColumn) => atColumn <= columnIndex
).length;
return separatorCount * separator.spacing;
}
export function getHeatmapPlotInnerWidth(
columnCount: number,
binWidth: number,
separator: Pick<HeatmapSeparatorLayout, "spacing" | "atColumns"> | null
): number {
const separatorCount = separator ? getHeatmapSeparatorCount(separator) : 0;
return columnCount * binWidth + separatorCount * (separator?.spacing ?? 0);
}
/** Vertical span for a separator line in plot coordinates. */
export function getHeatmapSeparatorLineY({
innerHeight,
marginTop,
startOffset,
paddingY = 0,
}: {
innerHeight: number;
marginTop: number;
/** Distance from the chart container top to the line start. Default: plot top. */
startOffset?: number;
paddingY?: number;
}): { y1: number; y2: number } {
const resolvedStart = startOffset ?? marginTop;
const y1 = resolvedStart - marginTop + paddingY;
const y2 = Math.max(innerHeight - paddingY, y1);
return { y1, y2 };
}
/** X position for a separator line (centered in the gutter when spacing > 0). */
export function getHeatmapSeparatorX(
columnIndex: number,
gap: number,
separator: Pick<HeatmapSeparatorLayout, "spacing">,
xScale: (columnIndex: number) => number
): number {
if (separator.spacing > 0) {
return xScale(columnIndex) - separator.spacing / 2;
}
return xScale(columnIndex) - gap / 2;
}
export interface HeatmapDisplayRange {
start: Date | null;
end: Date | null;
}
/** Whether a bin falls outside the contribution display window (not merely inactive). */
export function isHeatmapGhostBin(
bin: HeatmapBin,
range: HeatmapDisplayRange
): boolean {
const time = bin.date.getTime();
if (range.end && time > range.end.getTime()) {
return true;
}
if (range.start && time < range.start.getTime()) {
return true;
}
return false;
}
/**
* Infers GitHub-style display range for calendar-month contribution grids.
* Custom data that does not match a known grid shape returns null bounds (show all).
*/
export function resolveHeatmapDisplayRange(
columns: HeatmapColumn[]
): HeatmapDisplayRange {
if (columns.length === 0) {
return { start: null, end: null };
}
const extent = getHeatmapTimeExtent(columns);
if (!extent) {
return { start: null, end: null };
}
const today = new Date();
today.setHours(0, 0, 0, 0);
const firstColumn = columns[0];
if (!firstColumn) {
return { start: null, end: null };
}
const gridStart = getHeatmapColumnStartDate(firstColumn);
if (!gridStart) {
return { start: null, end: null };
}
const inferredStart = inferHeatmapCalendarRangeStart(columns);
if (inferredStart && extent[1].getTime() >= today.getTime()) {
return { start: inferredStart, end: today };
}
return { start: null, end: null };
}
/** Maps a contribution count to the GitHub-style legend level (0–4). */
export function getHeatmapContributionLevel(count: number): number {
if (count <= 0) {
return 0;
}
if (count === 1) {
return 1;
}
if (count === 2) {
return 2;
}
if (count === 3) {
return 3;
}
return 4;
}
export interface HeatmapHoverStyleParams {
inactiveOpacity: number;
inactiveScale: number;
activeScale: number;
}
/** Whether hover styling runs (disabled when all scale/opacity props are 1). */
export function isHeatmapHoverEffectEnabled(
params: HeatmapHoverStyleParams
): boolean {
return (
params.inactiveOpacity !== 1 ||
params.inactiveScale !== 1 ||
params.activeScale !== 1
);
}
/** Whether inactive hover styling runs (disabled when both props are 1). */
export function isHeatmapInactiveEffectEnabled(
inactiveOpacity: number,
inactiveScale: number
): boolean {
return isHeatmapHoverEffectEnabled({
inactiveOpacity,
inactiveScale,
activeScale: 1,
});
}
/** Opacity and scale for highlighted vs dimmed cells and legend swatches. */
export function resolveHeatmapHoverStyle(
isHighlighted: boolean,
isDimmed: boolean,
params: HeatmapHoverStyleParams
): { opacity: number; scale: number } {
if (isHighlighted && params.activeScale !== 1) {
return { opacity: 1, scale: params.activeScale };
}
if (isDimmed) {
return {
opacity: params.inactiveOpacity,
scale: params.inactiveScale,
};
}
return { opacity: 1, scale: 1 };
}
/** Opacity and scale for an inactive cell or legend swatch. */
export function resolveHeatmapInactiveStyle(
isInactive: boolean,
inactiveOpacity: number,
inactiveScale: number
): { opacity: number; scale: number } {
return resolveHeatmapHoverStyle(false, isInactive, {
inactiveOpacity,
inactiveScale,
activeScale: 1,
});
}
/** Per-row opacity multiplier for display rows (default 1). */
export function resolveHeatmapRowOpacity(
row: number,
rowOpacity?: number | readonly number[]
): number {
if (rowOpacity == null) {
return 1;
}
if (typeof rowOpacity === "number") {
return rowOpacity;
}
return rowOpacity[row] ?? 1;
}
/**
* Builds a per-row opacity map for {@link HeatmapCells} and {@link HeatmapYAxis}.
* Pass explicit row indices (e.g. `[5, 6]` for display Sat/Sun when `weekStartDay={1}`)
* or a predicate — `(row) => row >= 5` fades the last two rows.
*/
export function buildHeatmapRowOpacity(
match: readonly number[] | ((row: number) => boolean),
fadedOpacity = 0.35,
activeOpacity = 1,
rowCount = 7
): number[] {
if (typeof match === "function") {
return Array.from({ length: rowCount }, (_, row) =>
match(row) ? fadedOpacity : activeOpacity
);
}
const opacity = new Array<number>(rowCount).fill(activeOpacity);
for (const row of match) {
if (row >= 0 && row < rowCount) {
opacity[row] = fadedOpacity;
}
}
return opacity;
}
/** CSS `linear-gradient` for a continuous legend bar from level styles. */
export function buildHeatmapLegendGradient(
levelStyles: HeatmapLevelStyles
): string {
const lastIndex = levelStyles.length - 1;
const stops = levelStyles.map((style, index) => {
const offset = lastIndex === 0 ? 0 : (index / lastIndex) * 100;
return `${style.color} ${offset}%`;
});
return `linear-gradient(to right, ${stops.join(", ")})`;
}
src/charts/heatmap/heatmap-animation.ts
import type { Transition } from "motion/react";
import type { HeatmapColumn } from "./heatmap-context";
import { getHeatmapContributionLevel } from "./heatmap-utils";
/** Default enter duration when no motion props are passed (ms). */
export const HEATMAP_DEFAULT_ENTER_DURATION_MS = 1600;
/** Default cubic-bezier for heatmap cell enter / loading transitions. */
export const HEATMAP_DEFAULT_ENTER_EASE = [0.85, 0, 0.916, 0.282] as const;
/** Default enter transition for {@link HeatmapChart}. */
export const HEATMAP_DEFAULT_ENTER_TRANSITION: Transition = {
type: "tween",
duration: HEATMAP_DEFAULT_ENTER_DURATION_MS / 1000,
ease: HEATMAP_DEFAULT_ENTER_EASE,
};
/** Chart opacity while `status="loading"`. */
export const HEATMAP_LOADING_CHART_OPACITY = 1;
/** Default max per-cell opacity during loading shimmer. */
export const HEATMAP_DEFAULT_LOADING_CELL_MAX_OPACITY = 0.85;
/** Default share of cells that participate in loading shimmer (0–1). */
export const HEATMAP_DEFAULT_LOADING_CELL_RANDOMNESS = 1;
/** Opacity for loading cells that do not participate in shimmer. */
export const HEATMAP_LOADING_BASE_CELL_OPACITY = 0.2;
/** Duration for ready → loading cell conceal before shimmer resumes (ms). */
export const HEATMAP_LOADING_CONCEAL_MS = 450;
/** Share of the enter window used for random fade-in delays. */
export const HEATMAP_ENTER_STAGGER_SPREAD = 0.6;
function seededRandom(seed: number): () => number {
let state = seed % 2_147_483_647;
if (state <= 0) {
state += 2_147_483_646;
}
return () => {
state = (state * 16_807) % 2_147_483_647;
return (state - 1) / 2_147_483_646;
};
}
export function heatmapCellSeed(column: number, row: number): number {
return column * 1009 + row * 9176;
}
export interface HeatmapLevelRange {
min: number;
max: number;
}
/** Min/max contribution levels present in the dataset. */
export function computeHeatmapLevelRange(
data: HeatmapColumn[]
): HeatmapLevelRange {
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const column of data) {
for (const bin of column.bins) {
const level = getHeatmapContributionLevel(bin.count);
if (level < min) {
min = level;
}
if (level > max) {
max = level;
}
}
}
if (!(Number.isFinite(min) && Number.isFinite(max))) {
return { min: 0, max: 4 };
}
return { min, max };
}
/** Per-cell fade duration derived from the motion enter transition. */
export function resolveHeatmapEnterFadeDurationSec(
enterTransition: Transition | undefined,
animationDurationMs: number
): number {
if (
enterTransition &&
"duration" in enterTransition &&
typeof enterTransition.duration === "number"
) {
return enterTransition.duration;
}
return Math.min(0.45, (animationDurationMs / 1000) * 0.3);
}
/** Random delay so all cells finish fading in within `animationDurationMs`. */
export function computeHeatmapEnterFadeDelayMs({
column,
row,
revealEpoch,
animationDurationMs,
enterStaggerScale,
fadeDurationSec,
}: {
column: number;
row: number;
revealEpoch: number;
animationDurationMs: number;
enterStaggerScale: number;
fadeDurationSec: number;
}): number {
const random = seededRandom(
heatmapCellSeed(column, row) + revealEpoch * 524_287
);
const fadeMs = fadeDurationSec * 1000;
const maxDelayMs = Math.max(0, animationDurationMs - fadeMs);
const spreadMs =
maxDelayMs *
HEATMAP_ENTER_STAGGER_SPREAD *
Math.max(enterStaggerScale, 0.25);
return random() * spreadMs;
}
/** Whether a cell participates in loading shimmer for a given randomness (0–1). */
export function heatmapLoadingCellParticipates(
column: number,
row: number,
randomness: number
): boolean {
if (randomness >= 1) {
return true;
}
if (randomness <= 0) {
return false;
}
const random = seededRandom(heatmapCellSeed(column, row) + 73_133);
return random() < randomness;
}
src/charts/heatmap/heatmap-pattern-defs.tsx
"use client";
import { Fragment, memo } from "react";
import { renderPatternPreset } from "../pattern-preset";
import {
type HeatmapLevelStyles,
heatmapLevelPatternId,
heatmapLevelPatternRenderOptions,
isHeatmapLevelPattern,
} from "./heatmap-colors";
export const HeatmapPatternDefs = memo(function HeatmapPatternDefs({
levelStyles,
}: {
levelStyles: HeatmapLevelStyles;
}) {
const nodes = levelStyles.flatMap((style, level) => {
if (!isHeatmapLevelPattern(style)) {
return [];
}
const id = heatmapLevelPatternId(level);
const pattern = style.pattern;
if (!pattern) {
return [];
}
const node = renderPatternPreset(
pattern,
id,
heatmapLevelPatternRenderOptions(style)
);
return node ? [<Fragment key={id}>{node}</Fragment>] : [];
});
if (nodes.length === 0) {
return null;
}
return <defs>{nodes}</defs>;
});
HeatmapPatternDefs.displayName = "HeatmapPatternDefs";
src/charts/heatmap/heatmap-chart-loading.tsx
"use client";
import { useMemo } from "react";
import type { Margin } from "../chart-context";
import { generateHeatmapSkeletonFromTarget } from "./generate-heatmap-skeleton-data";
import { HeatmapCells } from "./heatmap-cells";
import { HeatmapChart } from "./heatmap-chart";
import type { HeatmapColumn } from "./heatmap-context";
import { HeatmapXAxis } from "./heatmap-x-axis";
import { HeatmapYAxis } from "./heatmap-y-axis";
export interface HeatmapChartLoadingProps {
/** Target column data used to size the skeleton grid. */
data: HeatmapColumn[];
/** Visible time range — filters week columns that overlap the domain. */
xDomain?: [Date, Date];
/** Chart margins */
margin?: Partial<Margin>;
/** Gap between cells in pixels. Default: 2 */
gap?: number;
/** Corner radius for each cell. Default: 2 */
cornerRadius?: number;
/** Centered shimmer label text. Default: "Loading" */
label?: string;
/** Additional class name for the container */
className?: string;
}
export function HeatmapChartLoading({
data,
xDomain,
margin,
gap = 2,
cornerRadius = 2,
label = "Loading",
className = "",
}: HeatmapChartLoadingProps) {
const skeletonData = useMemo(
() => generateHeatmapSkeletonFromTarget(data),
[data]
);
return (
<HeatmapChart
className={className}
data={skeletonData}
gap={gap}
loadingLabel={label}
margin={margin}
status="loading"
xDomain={xDomain}
>
<HeatmapCells cornerRadius={cornerRadius} interactive={false} />
<HeatmapXAxis />
<HeatmapYAxis />
</HeatmapChart>
);
}
export default HeatmapChartLoading;
src/charts/heatmap/generate-heatmap-skeleton-data.ts
import type { HeatmapColumn } from "./heatmap-context";
/** Placeholder grid with the same week/day shape as target data (all empty cells). */
export function generateHeatmapSkeletonFromTarget(
target: HeatmapColumn[]
): HeatmapColumn[] {
return target.map((column) => ({
bin: column.bin,
bins: column.bins.map((bin) => ({
bin: bin.bin,
count: 0,
date: bin.date,
})),
}));
}
src/charts/heatmap/index.ts
export { generateHeatmapSkeletonFromTarget } from "./generate-heatmap-skeleton-data";
export {
computeHeatmapEnterFadeDelayMs,
computeHeatmapLevelRange,
HEATMAP_DEFAULT_ENTER_DURATION_MS,
HEATMAP_DEFAULT_ENTER_EASE,
HEATMAP_DEFAULT_ENTER_TRANSITION,
HEATMAP_DEFAULT_LOADING_CELL_MAX_OPACITY,
HEATMAP_DEFAULT_LOADING_CELL_RANDOMNESS,
HEATMAP_ENTER_STAGGER_SPREAD,
HEATMAP_LOADING_BASE_CELL_OPACITY,
HEATMAP_LOADING_CHART_OPACITY,
HEATMAP_LOADING_CONCEAL_MS,
type HeatmapLevelRange,
heatmapLoadingCellParticipates,
resolveHeatmapEnterFadeDurationSec,
} from "./heatmap-animation";
export { HeatmapCells, type HeatmapCellsProps } from "./heatmap-cells";
export {
HeatmapChart,
type HeatmapChartProps,
type HeatmapLayout,
} from "./heatmap-chart";
export {
HeatmapChartLoading,
type HeatmapChartLoadingProps,
} from "./heatmap-chart-loading";
export {
buildHeatmapColorScale,
buildHeatmapColorScaleFromStyles,
buildHeatmapFillScale,
defaultHeatmapColorScale,
defaultHeatmapFillScale,
HEATMAP_DEFAULT_LEVEL_COLORS,
HEATMAP_DEFAULT_LEVEL_STYLES,
type HeatmapLevelColors,
type HeatmapLevelFillMode,
type HeatmapLevelStyle,
type HeatmapLevelStyles,
heatmapLevelPatternId,
isHeatmapLevelPattern,
levelColorsFromStyles,
levelStylesFromColors,
resolveHeatmapLevelStyles,
} from "./heatmap-colors";
export {
type HeatmapBin,
type HeatmapColumn,
type HeatmapContextValue,
HeatmapInteractionBoundary,
HeatmapInteractionProvider,
HeatmapInteractionRoot,
HeatmapProvider,
type HeatmapRevealMode,
heatmapCssVars,
useHeatmap,
useHeatmapInteraction,
useHeatmapInteractionOptional,
} from "./heatmap-context";
export {
HEATMAP_LEGEND_LEVELS,
HeatmapLegend,
type HeatmapLegendProps,
type HeatmapLegendVariant,
} from "./heatmap-legend";
export {
resolveHeatmapSeparatorConfig,
resolveHeatmapSeparatorConfigWithData,
} from "./heatmap-resolve-separator";
export {
HeatmapSeparator,
type HeatmapSeparatorProps,
} from "./heatmap-separator";
export { HeatmapTooltip, type HeatmapTooltipProps } from "./heatmap-tooltip";
export {
buildHeatmapLegendGradient,
buildHeatmapQuarterSeparatorGroups,
buildHeatmapRowOpacity,
buildHeatmapSeparatorGradientStops,
countHeatmapWeekDaysOnOrAfter,
filterHeatmapColumns,
findHeatmapColumnIndexForDate,
formatHeatmapContributionLabel,
formatHeatmapTooltipDate,
formatHeatmapTooltipWeekday,
formatHeatmapYAxisLabel,
getCalendarQuarter,
getCalendarQuarterStartDatesBetween,
getHeatmapCalendarRangeStart,
getHeatmapColumnMonthAnchor,
getHeatmapColumnQuarterAnchor,
getHeatmapColumnXOffset,
getHeatmapContributionLevel,
getHeatmapDayLabels,
getHeatmapMonthLabelColumnIndex,
getHeatmapPlotInnerWidth,
getHeatmapSeparatorColumnIndices,
getHeatmapSeparatorCount,
getHeatmapSeparatorGroupStartColumn,
getHeatmapSeparatorLineY,
getHeatmapSeparatorX,
getHeatmapTimeExtent,
getHeatmapWeekCount,
getHeatmapWeekStartAlignedToRange,
getHeatmapWeekStartSunday,
getHeatmapYearStartMonth,
HEATMAP_DAY_LABELS,
HEATMAP_MONTHS_ONE_YEAR,
HEATMAP_MONTHS_SIX,
HEATMAP_WEEKS_ONE_YEAR,
type HeatmapDisplayRange,
type HeatmapHoverStyleParams,
type HeatmapSeparatorGradient,
type HeatmapSeparatorGradientStop,
type HeatmapSeparatorGroup,
type HeatmapSeparatorGroupBy,
type HeatmapSeparatorLayout,
type HeatmapSeparatorParsedConfig,
type HeatmapSeparatorStrokeStyle,
type HeatmapWeekRange,
type HeatmapWeekStartDay,
type HeatmapYAxisLabelFormat,
type HeatmapYAxisTickFilter,
inferHeatmapCalendarRangeStart,
isHeatmapGhostBin,
isHeatmapHoverEffectEnabled,
isHeatmapInactiveEffectEnabled,
resolveHeatmapDisplayRange,
resolveHeatmapHoverStyle,
resolveHeatmapInactiveStyle,
resolveHeatmapRowOpacity,
resolveHeatmapSeparatorLayout,
resolveHeatmapSeparatorStrokeDasharray,
resolveHeatmapWeekRange,
rotateHeatmapColumnBins,
shouldShowHeatmapYAxisTick,
} from "./heatmap-utils";
export { HeatmapXAxis, type HeatmapXAxisProps } from "./heatmap-x-axis";
export { HeatmapYAxis, type HeatmapYAxisProps } from "./heatmap-y-axis";
src/charts/chart-loading-label.tsx
"use client";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
import { ShimmeringText } from "../components/shimmering-text";
import {
LINE_LOADING_PULSE_EASE,
LOADING_LABEL_EXIT_S,
LOADING_LABEL_EXIT_Y_PX,
} from "./line-loading-timing";
export interface ChartLoadingLabelProps {
/** Label shown centered over the chart. */
text?: string;
className?: string;
/** Animate down, fade, and blur during loading → ready handoff. */
exiting?: boolean;
}
export function ChartLoadingLabel({
text = "Loading",
className,
exiting = false,
}: ChartLoadingLabelProps) {
if (!text.trim()) {
return null;
}
return (
<motion.div
animate={{
y: exiting ? LOADING_LABEL_EXIT_Y_PX : 0,
opacity: exiting ? 0 : 1,
filter: exiting ? "blur(2px)" : "blur(0px)",
}}
aria-live="polite"
className={cn(
"pointer-events-none absolute inset-0 flex items-center justify-center",
className
)}
initial={false}
role="status"
transition={{
duration: LOADING_LABEL_EXIT_S,
ease: [...LINE_LOADING_PULSE_EASE],
}}
>
<ShimmeringText
className="font-medium text-sm tracking-wide [--color:var(--muted-foreground)] [--shimmering-color:var(--foreground)]"
text={text}
/>
</motion.div>
);
}
export default ChartLoadingLabel;
src/charts/chart-child-passthrough.ts
import {
Children,
cloneElement,
Fragment,
isValidElement,
type ReactElement,
type ReactNode,
} from "react";
/** Marker on wrapper components whose single child should inherit clip classification. */
export const CHART_CLIP_PASSTHROUGH = "__chartClipPassthrough" as const;
export function isChartClipPassthrough(type: unknown): boolean {
return (
typeof type === "function" &&
(type as { [CHART_CLIP_PASSTHROUGH]?: boolean })[CHART_CLIP_PASSTHROUGH] ===
true
);
}
/** Unwrap visibility wrappers so `Grid` / axes stay outside the series clip. */
export function resolveChartChildElement(child: ReactElement): ReactElement {
if (isChartClipPassthrough(child.type)) {
const inner = (child.props as { children?: unknown }).children;
if (isValidElement(inner)) {
return resolveChartChildElement(inner);
}
}
return child;
}
/** Walk chart children, flattening React fragments (studio often groups layers in `<>...</>`). */
export function forEachChartChild(
children: ReactNode,
callback: (child: ReactElement, index: number) => void
) {
let index = 0;
const visit = (nodes: ReactNode) => {
Children.forEach(nodes, (child) => {
if (!isValidElement(child)) {
return;
}
if (child.type === Fragment) {
visit((child.props as { children?: ReactNode }).children);
return;
}
callback(child, index);
index += 1;
});
};
visit(children);
}
const CLIP_EXCLUDED_COMPONENT_NAMES = new Set([
"Background",
"Grid",
"XAxis",
"YAxis",
"BarXAxis",
"BarYAxis",
"LiveXAxis",
"LiveYAxis",
]);
const UNDERLAY_COMPONENT_NAMES = new Set(["ReferenceArea", "BarColumnTrack"]);
/** Markers render after the interaction overlay so they stay clickable. */
export function isPostOverlayComponent(child: ReactElement): boolean {
const childType = child.type as {
displayName?: string;
name?: string;
__isChartMarkers?: boolean;
__isPostOverlay?: boolean;
};
if (childType.__isChartMarkers || childType.__isPostOverlay) {
return true;
}
const componentName =
typeof child.type === "function"
? childType.displayName || childType.name || ""
: "";
return (
componentName === "ChartMarkers" ||
componentName === "MarkerGroup" ||
componentName === "ChartBrush"
);
}
/** Renders above grid/axes but below series; excluded from grow-clip reveal. */
export function isUnderlayComponent(child: ReactElement): boolean {
const childType = child.type as { displayName?: string; name?: string };
const componentName =
typeof child.type === "function"
? childType.displayName || childType.name || ""
: "";
return UNDERLAY_COMPONENT_NAMES.has(componentName);
}
/** Grid and axes stay visible during series clip reveal (e.g. loading → ready). */
export function isClipExcludedComponent(child: ReactElement): boolean {
const childType = child.type as { displayName?: string; name?: string };
const componentName =
typeof child.type === "function"
? childType.displayName || childType.name || ""
: "";
return CLIP_EXCLUDED_COMPONENT_NAMES.has(componentName);
}
/** SVG layer lists from chart shells need stable keys when rendered as arrays. */
export function renderKeyedChartLayers(children: ReactElement[]) {
return children.map((child, index) =>
cloneElement(child, { key: child.key ?? `chart-layer-${index}` })
);
}