/**
* HeatmapChart — a calendar of bins, shaded by how much happened in each.
*
* The contribution grid: one column per period (usually a week), one row per
* bin inside it (usually a weekday), and a colour ramp carrying the count. It
* answers "when was this busy" at a glance, which no line can — a year of daily
* numbers plotted as a series is a hairball, and as a grid it is a pattern.
*
* Composed rather than configured, so a chart that wants no axis simply does
* not have one:
*
* ```tsx
*
*
*
*
*
*
*
*
* ```
*
* The parts sort themselves into a real layout rather than stacking over the
* plot. That is the difference from a line chart, where the axis floats over
* the drawing: here the labels and the legend sit *beside* and *below* the
* grid, so they take up room, and the grid is sized with them accounted for.
* Only the cells and the rules between them are SVG; every label is a React
* Native view, because SVG text ignores the platform's text scaling and the
* theme's font.
*
* The ramp is one colour at five opacities rather than five colours. A heatmap
* reads as *more* and *less* of one thing, and five distinct hues read as five
* different things — which is what the `--color-chart-*` tokens are for, and
* why they are not used here. The base is `--color-chart-1`, so the ramp
* follows the theme, and `levelColors` replaces it outright when a brand needs
* its own.
*/
import {
Children,
createContext,
forwardRef,
isValidElement,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
Easing,
runOnJS,
useAnimatedProps,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import Svg, { G, Line as SvgLine, Rect } from 'react-native-svg';
import { useCSSVariable } from 'uniwind';
import { Text } from '../../primitives/text';
import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility';
import { cn } from '../../utils/cn';
import { normalizeWeekStart, startOfDay } from '../../utils/date';
/**
* Rows in a week — the default, and what the calendar helper and the weekday
* labels assume. The grid itself takes a `rows` prop, so a grid whose bins are
* hours rather than days is the same component with a different number.
*/
const DAYS_IN_WEEK = 7;
/** Opacity of the base colour at each activity level. Index 0 is "nothing". */
const LEVEL_OPACITY = [1, 0.28, 0.5, 0.74, 1] as const;
/**
* A theme token rather than a colour. Tokens are named, not written, so the
* leading `--` tells the two apart without the caller having to say which
* kind they passed.
*/
function isToken(value: string | undefined): value is string {
return typeof value === 'string' && value.startsWith('--');
}
/** Room left for the weekday labels when the y-axis does not ask for its own. */
const DEFAULT_AXIS_WIDTH = 26;
/**
* Height assumed for the readout when placing it above a cell. It is a single
* line of `xs` text in a padded box, so it does not vary — and measuring it
* would put the tooltip a frame behind the finger.
*/
const TOOLTIP_HEIGHT = 26;
/**
* How long the readout waits before claiming the touch, in milliseconds.
*
* Long enough that a swipe scrolls the chart instead of reading it, short
* enough that a deliberate press does not feel like it was ignored.
*/
const DEFAULT_HOLD = 180;
/** Weekday names, indexed from Sunday, as the `Date` API numbers them. */
const WEEKDAYS = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
] as const;
const MONTHS = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
] as const;
/**
* Where a part belongs in the layout. Read off the component itself, so
* composition stays a flat list of children instead of four nested slots the
* caller has to remember the order of.
*/
type Slot = 'cells' | 'x-axis' | 'y-axis' | 'rules' | 'tooltip' | 'legend' | 'header';
export type HeatmapLayout = 'fluid' | 'fill';
/** One bin inside a column — usually a single day. */
export interface HeatmapBin {
/** Row index within the column, `0` to `6`. */
bin: number;
/** How much happened. The ramp is derived from these across the whole chart. */
count: number;
/** The day this bin stands for. Used by the axis labels and the tooltip. */
date?: Date;
}
/** One column — usually a week. Missing bins are drawn as empty cells. */
export interface HeatmapColumn {
/** Column index across the chart. */
bin: number;
bins: HeatmapBin[];
}
/** A cell resolved to its place in the grid, as the tooltip receives it. */
export interface HeatmapCell {
column: number;
row: number;
count: number;
level: number;
date?: Date;
}
interface Grid {
/** Side of one cell, in pixels. */
size: number;
gap: number;
columns: number;
rows: number;
width: number;
height: number;
}
interface HeatmapContextValue {
data: HeatmapColumn[];
grid: Grid;
/** `[level0, level1, …level4]`, already resolved to colours. */
ramp: string[];
/** Opacity to paint each level's colour at. All ones for a supplied ramp. */
opacities: number[];
levelOf: (count: number) => number;
cellAt: (column: number, row: number) => HeatmapCell;
cornerRadius: number;
inactiveOpacity: number;
weekStartDay: number;
activeCell: HeatmapCell | null;
setActiveCell: (cell: HeatmapCell | null) => void;
}
const HeatmapContext = createContext(null);
function useHeatmap(component: string): HeatmapContextValue {
const context = useContext(HeatmapContext);
if (!context) {
throw new Error(`${component} must be used within a `);
}
return context;
}
/**
* The cell under the finger, for something rendered *inside* the chart. A
* readout in the card's header is outside this provider — use
* `onActiveCellChange` for that.
*/
export function useHeatmapChart() {
const { activeCell } = useHeatmap('useHeatmapChart');
return { activeCell };
}
/* -------------------------------------------------------------------------- */
/* Data helpers */
/* -------------------------------------------------------------------------- */
/**
* Build a year of columns from a flat list of dated counts.
*
* Every heatmap starts as "I have some dates and some numbers", and the
* bucketing into weeks is the same arithmetic every time — including the two
* parts that are easy to get wrong: the leading blanks before the first day of
* the first week, and days with no entry at all, which must still be drawn as
* empty cells or the calendar develops holes.
*/
export function buildHeatmapCalendar(
entries: { date: Date; count: number }[],
options: { start?: Date; end?: Date; weekStartDay?: number } = {}
): HeatmapColumn[] {
const weekStartDay = normalizeWeekStart(options.weekStartDay ?? 0);
if (!entries.length && !options.start) return [];
const times = entries.map((entry) => entry.date.getTime());
// Both bounds at local midnight, and both the same way. Comparing a
// normalised lower bound against a raw upper one drops the last day whenever
// the caller's `end` carries a time earlier than the cell being tested.
const start = startOfDay(options.start ?? new Date(Math.min(...times)));
const end = startOfDay(options.end ?? new Date(Math.max(...times)));
const byDay = new Map();
for (const entry of entries) {
const key = dayKey(entry.date);
byDay.set(key, (byDay.get(key) ?? 0) + entry.count);
}
// Back up to the first day of the week the range starts in, so column 0 is a
// whole week and every row lines up with a weekday for the rest of the chart.
const cursor = startOfDay(start);
cursor.setDate(cursor.getDate() - ((cursor.getDay() - weekStartDay + 7) % 7));
const columns: HeatmapColumn[] = [];
let column: HeatmapBin[] = [];
while (cursor <= end || column.length) {
const row = (cursor.getDay() - weekStartDay + 7) % 7;
const date = new Date(cursor);
// Before the range starts and after it ends the cell exists but has no
// reading — `count: 0` and no date, so the tooltip stays quiet on it.
const inRange = date >= start && date <= end;
column.push({
bin: row,
count: inRange ? (byDay.get(dayKey(date)) ?? 0) : 0,
date: inRange ? date : undefined,
});
if (row === DAYS_IN_WEEK - 1) {
columns.push({ bin: columns.length, bins: column });
column = [];
if (cursor > end) break;
}
cursor.setDate(cursor.getDate() + 1);
}
return columns;
}
function dayKey(date: Date) {
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
}
/**
* Four thresholds from the data, so a chart of single-digit counts and a chart
* of thousands both use the whole ramp. Quartiles of the *non-zero* counts:
* zero is its own level, and counting it would drag every threshold down to
* nothing on a sparse chart.
*/
function deriveLevels(data: HeatmapColumn[]): number[] {
const counts: number[] = [];
for (const column of data) {
for (const bin of column.bins) {
if (bin.count > 0) counts.push(bin.count);
}
}
if (!counts.length) return [1, 2, 3, 4];
counts.sort((a, b) => a - b);
const at = (fraction: number) =>
counts[Math.min(counts.length - 1, Math.floor(counts.length * fraction))]!;
const thresholds = [1, at(0.25), at(0.5), at(0.75)];
// Ties collapse the ramp — nudge each threshold past the one below it so
// four distinct levels stay four distinct levels.
for (let i = 1; i < thresholds.length; i++) {
if (thresholds[i]! <= thresholds[i - 1]!) thresholds[i] = thresholds[i - 1]! + 1;
}
return thresholds;
}
/* -------------------------------------------------------------------------- */
/* Root */
/* -------------------------------------------------------------------------- */
export interface HeatmapChartProps extends ViewProps, ChartAccessibilityProps {
className?: string;
/** One column per period, with its row bins inside. */
data: HeatmapColumn[];
/**
* `fluid` draws cells at `binSize` and lets the grid be as wide as it needs
* to be — put it in a horizontal `ScrollView` for a full year. `fill`
* divides the available width between the columns instead.
*/
layout?: HeatmapLayout;
/** Side of one cell in `fluid` layout, in pixels. */
binSize?: number;
/** Space between cells, in pixels. */
gap?: number;
/** Corner radius of a cell. */
cornerRadius?: number;
/** Which weekday is the top row. `0` is Sunday. Labels follow it. */
weekStartDay?: number;
/**
* Rows per column. Seven for a calendar; use another number when the bins
* are not weekdays — twenty-four for a grid of hours.
*/
rows?: number;
/**
* The four counts at which the ramp steps up. Derived from the data's own
* quartiles when omitted, so a chart of single digits and a chart of
* thousands both use the whole ramp.
*/
levels?: number[];
/**
* Five colours — empty, then the four activity levels. Replaces the derived
* ramp outright. Omit it and the ramp is `--color-chart-1` at five
* opacities, which follows the theme.
*/
levelColors?: string[];
/**
* Base colour for the derived ramp — the colour the busiest cells are drawn
* in, with the quieter levels the same colour at lower opacity.
*
* Takes a theme token by name as well as a literal, so `"--color-chart-3"`
* recolours the chart and keeps following the theme through light and dark.
* Defaults to `--color-chart-1`.
*/
color?: string;
/**
* Colour of a cell with nothing in it. Takes a token name too. Defaults to
* `--color-muted`, which is the right weight for "measured, and empty" —
* override it for a chart that should read as denser or fainter than that.
*/
emptyColor?: string;
/**
* Opacity of the base colour at each of the five levels, quietest first.
* The way to retune the ramp's contrast without having to name five colours.
* Ignored when `levelColors` is given, which sets the colours outright.
*/
levelOpacity?: number[];
/** Milliseconds for the reveal on mount. */
animationDuration?: number;
/** Opacity of every cell that is not the one under the finger. */
inactiveOpacity?: number;
/**
* The cell under the finger as it moves, and `null` when it lifts. This is
* how a readout above the chart gets its value — that readout is outside the
* chart, so it cannot use `useHeatmapChart`.
*/
onActiveCellChange?: (cell: HeatmapCell | null) => void;
children?: ReactNode;
}
const HeatmapChartRoot = forwardRef(function HeatmapChartRoot(
{
className,
data,
layout = 'fluid',
binSize = 12,
gap = 3,
cornerRadius = 2,
weekStartDay = 0,
rows = DAYS_IN_WEEK,
levels,
levelColors,
color,
emptyColor,
levelOpacity,
animationDuration = 900,
inactiveOpacity = 1,
onActiveCellChange,
accessible,
accessibilityLabel,
accessibilityHint,
accessibilityLabelForDatum,
onAccessibilityDatumPress,
children,
...props
},
ref
) {
const [width, setWidth] = useState(0);
const [activeCell, setActiveCellState] = useState(null);
const reveal = useSharedValue(0);
const reducedMotion = useReducedMotion();
/*
* `color` and `emptyColor` take a token name as readily as a literal. A
* literal is a colour frozen at the moment it was written — it cannot follow
* the theme into dark mode — so naming the token is almost always what the
* caller meant, and having to resolve it themselves is the reason they did
* not. The hooks run unconditionally; which of the two answers is used is
* decided after.
*/
const baseToken = useCSSVariable(isToken(color) ? color : '--color-chart-1');
const emptyToken = useCSSVariable(
isToken(emptyColor) ? emptyColor : '--color-muted'
);
const base =
(isToken(color) ? undefined : color) ??
(typeof baseToken === 'string' ? baseToken : '#262626');
const empty =
(isToken(emptyColor) ? undefined : emptyColor) ??
(typeof emptyToken === 'string' ? emptyToken : 'rgba(128,128,128,0.16)');
const parts = useMemo(() => splitParts(children), [children]);
// The y-axis is laid out beside the grid rather than over it, so its width
// has to come out of the grid's before the cells are sized.
const axisWidth = parts.y ? (parts.y.props.width ?? DEFAULT_AXIS_WIDTH) : 0;
const grid = useMemo(() => {
const count = data.length;
if (!count) return { size: 0, gap, columns: 0, rows, width: 0, height: 0 };
const available = Math.max(width - axisWidth, 0);
const size =
layout === 'fill' && available > 0
? Math.max((available - (count - 1) * gap) / count, 1)
: binSize;
return {
size,
gap,
columns: count,
rows,
width: count * size + (count - 1) * gap,
height: rows * size + (rows - 1) * gap,
};
}, [data.length, width, axisWidth, layout, binSize, gap, rows]);
const thresholds = useMemo(() => levels ?? deriveLevels(data), [levels, data]);
/*
* One colour at five opacities, unless the caller supplied five colours — in
* which case the opacities are dropped, since dimming a colour someone chose
* on purpose is not a ramp, it is a bug.
*/
const { ramp, opacities } = useMemo(
() =>
levelColors
? { ramp: levelColors, opacities: levelColors.map(() => 1) }
: {
ramp: [empty, base, base, base, base],
opacities: levelOpacity ?? [...LEVEL_OPACITY],
},
[levelColors, levelOpacity, empty, base]
);
const levelOf = useMemo(
() => (count: number) => {
if (count <= 0) return 0;
let level = 1;
for (let i = 1; i < thresholds.length; i++) {
if (count >= thresholds[i]!) level = i + 1;
}
return Math.min(level, 4);
},
[thresholds]
);
const cellAt = useMemo(
() => (columnIndex: number, row: number): HeatmapCell => {
const bin = data[columnIndex]?.bins.find((candidate) => candidate.bin === row);
const count = bin?.count ?? 0;
return { column: columnIndex, row, count, level: levelOf(count), date: bin?.date };
},
[data, levelOf]
);
const accessibilityCells = useMemo(
() =>
data.flatMap((column) =>
column.bins.map((bin) => ({
column: column.bin,
row: bin.bin,
count: bin.count,
level: levelOf(bin.count),
date: bin.date,
}))
),
[data, levelOf]
);
const setActiveCell = useMemo(
() => (cell: HeatmapCell | null) => {
setActiveCellState(cell);
onActiveCellChange?.(cell);
},
[onActiveCellChange]
);
// Plays once, when there is both a grid to reveal and data to reveal in it.
const revealed = useRef(false);
useEffect(() => {
if (revealed.current || grid.width <= 0) return;
revealed.current = true;
if (reducedMotion) {
reveal.value = 1;
return;
}
reveal.value = withTiming(1, {
duration: animationDuration,
easing: Easing.out(Easing.cubic),
});
}, [grid.width, reducedMotion, animationDuration, reveal]);
const onLayout = (event: LayoutChangeEvent) => {
const next = event.nativeEvent.layout.width;
setWidth((current) => (Math.abs(current - next) < 1 ? current : next));
props.onLayout?.(event);
};
const context = useMemo(
() => ({
data,
grid,
ramp,
opacities,
levelOf,
cellAt,
cornerRadius,
inactiveOpacity,
weekStartDay,
activeCell,
setActiveCell,
}),
[
data,
grid,
ramp,
opacities,
levelOf,
cellAt,
cornerRadius,
inactiveOpacity,
weekStartDay,
activeCell,
setActiveCell,
]
);
/*
* One clip wiping left to right, rather than an animation per column. The
* effect is the same — columns arriving in order — and it costs one animated
* value instead of one per week, which for a year is fifty-two.
*/
const revealStyle = useAnimatedStyle(() => ({ width: grid.width * reveal.value }));
return (
{/* Above the axis gutter as well as the grid: the header is about the
whole chart, so it starts at the chart's edge, not the grid's. */}
{parts.header}
[
['date', cell.date],
['column', cell.column],
['row', cell.row],
['count', cell.count],
]}
/>
{parts.x ? (
{parts.x}
) : null}
{parts.y}
{grid.width > 0 ? (
<>
{/*
* The reveal is a view that grows, not an SVG clip path.
*
* It used to be an animated `` inside ``, and on
* Android those animated props never reach the native clip, so
* the grid drew complete and the reveal did not play at all. A
* view with `overflow: 'hidden'` is clipped by the platform
* itself, which both platforms agree on.
*
* Animating `width` is normally a layout pass per frame. This
* view is absolutely positioned and its only child is an
* `
{parts.legend}
);
});
interface Parts {
cells: ReactNode[];
rules: ReactNode[];
x: ReactNode | null;
y: React.ReactElement<{ width?: number }> | null;
tooltip: ReactNode[];
legend: ReactNode[];
header: ReactNode[];
columns: number;
}
/** Sorts the children into the places the layout has for them. */
function splitParts(children: ReactNode): Parts {
const parts: Parts = {
cells: [],
rules: [],
x: null,
y: null,
tooltip: [],
legend: [],
header: [],
columns: 0,
};
Children.forEach(children, (child, index) => {
if (!isValidElement(child)) return;
const slot = (child.type as { slot?: Slot }).slot ?? 'cells';
const keyed = {child};
if (slot === 'x-axis') parts.x = keyed;
else if (slot === 'y-axis') parts.y = child as React.ReactElement<{ width?: number }>;
else if (slot === 'rules') parts.rules.push(keyed);
else if (slot === 'tooltip') parts.tooltip.push(keyed);
else if (slot === 'legend') parts.legend.push(keyed);
else if (slot === 'header') parts.header.push(keyed);
else parts.cells.push(keyed);
});
return parts;
}
/** Identity wrapper, purely so the partitioned arrays can carry keys. */
function ChildSlot({ children }: { children: ReactNode }) {
return <>{children}>;
}
/* -------------------------------------------------------------------------- */
/* Cells */
/* -------------------------------------------------------------------------- */
export interface HeatmapCellsProps {
/** Corner radius of a cell. Falls back to the chart's. */
cornerRadius?: number;
}
/**
* The grid itself. Every row of every column is drawn, including the ones with
* nothing in them — a calendar with holes in it stops being a calendar.
*/
function HeatmapCells({ cornerRadius }: HeatmapCellsProps) {
const chart = useHeatmap('HeatmapChart.Cells');
const { grid, data, ramp, opacities, levelOf, activeCell, inactiveOpacity } = chart;
const radius = cornerRadius ?? chart.cornerRadius;
const step = grid.size + grid.gap;
return (
{data.map((column, columnIndex) =>
Array.from({ length: grid.rows }, (_unused, row) => {
const bin = column.bins.find((candidate) => candidate.bin === row);
const level = levelOf(bin?.count ?? 0);
const dimmed =
activeCell !== null &&
!(activeCell.column === columnIndex && activeCell.row === row);
return (
);
})
)}
);
}
HeatmapCells.slot = 'cells' as Slot;
/* -------------------------------------------------------------------------- */
/* Rules */
/* -------------------------------------------------------------------------- */
export interface HeatmapSeparatorProps {
/**
* `quarter` draws a rule every thirteen columns; a number draws one every
* that many columns.
*/
every?: 'quarter' | number;
color?: string;
/** Dash pattern, e.g. `"2,4"`. Omit for a solid rule. */
dashArray?: string;
}
/** Vertical rules grouping the columns — quarters, months, sprints. */
function HeatmapSeparator({ every = 'quarter', color, dashArray }: HeatmapSeparatorProps) {
const { grid } = useHeatmap('HeatmapChart.Separator');
const token = useCSSVariable('--color-border');
const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.3)');
const interval = every === 'quarter' ? 13 : every;
const step = grid.size + grid.gap;
if (interval <= 0) return null;
const lines: number[] = [];
for (let column = interval; column < grid.columns; column += interval) {
lines.push(column);
}
return (
{lines.map((column) => {
const x = column * step - grid.gap / 2;
return (
);
})}
);
}
HeatmapSeparator.slot = 'rules' as Slot;
/* -------------------------------------------------------------------------- */
/* Axes */
/* -------------------------------------------------------------------------- */
export interface HeatmapXAxisProps {
className?: string;
/**
* Label a column. Given the first dated bin in it, so a month name can be
* derived. Return an empty string to leave the column unlabelled.
*/
formatLabel?: (date: Date, column: number) => string;
/**
* Column labels, left to right. Overrides the month names — for a grid whose
* columns are not weeks, where there is no month to change and so nothing to
* emit a label on.
*/
labels?: string[];
}
/**
* Month labels above the grid.
*
* A label is emitted where the month changes rather than at a fixed interval,
* because months are not the same length — spacing them evenly puts "Mar" over
* a week in February. A grid whose columns are not weeks has no such signal, so
* it passes `labels` and gets one over every column.
*/
function HeatmapXAxis({ className, formatLabel, labels: given }: HeatmapXAxisProps) {
const { data, grid } = useHeatmap('HeatmapChart.XAxis');
const step = grid.size + grid.gap;
const labels = useMemo(() => {
if (given) {
return given
.slice(0, data.length)
.map((label, column) => ({ column, label }))
.filter((entry) => entry.label);
}
const out: { column: number; label: string }[] = [];
let lastMonth = -1;
data.forEach((column, index) => {
const date = column.bins.find((bin) => bin.date)?.date;
if (!date) return;
const month = date.getMonth();
if (month === lastMonth) return;
lastMonth = month;
const label = formatLabel ? formatLabel(date, index) : MONTHS[month]!;
if (label) out.push({ column: index, label });
});
// The first month is usually a stub of a week or two, and a label over it
// collides with the next one. Drop it when it has no room.
if (out.length > 1 && out[1]!.column - out[0]!.column < 3) out.shift();
return out;
}, [data, formatLabel, given]);
return (
{labels.map(({ column, label }) => (
{label}
))}
);
}
HeatmapXAxis.slot = 'x-axis' as Slot;
export interface HeatmapYAxisProps {
className?: string;
/** Width reserved for the labels. The grid is sized around it. */
width?: number;
/** Which rows get a label. Every other row is the usual choice. */
tickFilter?: 'all' | 'odd' | 'even';
/** `initial` is the single letter; `full` is the abbreviated name. */
labelFormat?: 'initial' | 'full';
/**
* Row labels, top to bottom. Overrides the weekday names — for a grid whose
* rows are not days.
*/
labels?: string[];
}
/** Weekday labels down the left of the grid. */
function HeatmapYAxis({
className,
width = DEFAULT_AXIS_WIDTH,
tickFilter = 'odd',
labelFormat = 'full',
labels,
}: HeatmapYAxisProps) {
const { grid, weekStartDay } = useHeatmap('HeatmapChart.YAxis');
const step = grid.size + grid.gap;
return (
{Array.from({ length: grid.rows }, (_unused, row) => {
if (tickFilter === 'odd' && row % 2 === 0) return null;
if (tickFilter === 'even' && row % 2 === 1) return null;
const name = labels
? (labels[row] ?? '')
: WEEKDAYS[(row + weekStartDay) % DAYS_IN_WEEK]!;
return (
{labels
? name
: labelFormat === 'initial'
? name.slice(0, 1)
: name.slice(0, 3)}
);
})}
);
}
HeatmapYAxis.slot = 'y-axis' as Slot;
/* -------------------------------------------------------------------------- */
/* Tooltip */
/* -------------------------------------------------------------------------- */
export interface HeatmapTooltipProps {
className?: string;
/** The line shown for a cell. Defaults to the count and the date. */
formatLabel?: (cell: HeatmapCell) => string;
/**
* How long a press has to be held before the readout takes over, in
* milliseconds.
*
* It is not zero, and cannot be: a full year of columns lives inside a
* horizontal scroller, and a readout that claims the touch on the first pixel
* of movement means the chart can never be scrolled. Holding first is what
* separates "I am moving the chart" from "I am reading it". Set `0` only for
* a chart that is not inside a scroll view at all.
*/
activateAfterLongPress?: number;
}
/**
* A readout following the finger across the grid.
*
* It lives in the view layer, over the SVG: a gesture handler cannot be
* attached to an SVG node, and SVG text ignores the platform's text scaling.
* The cell under the finger is resolved on the UI thread and only crosses back
* into JS when it changes, so a drag across a year costs a handful of
* re-renders rather than one per frame.
*/
function HeatmapTooltip({
className,
formatLabel,
activateAfterLongPress = DEFAULT_HOLD,
}: HeatmapTooltipProps) {
const { grid, activeCell, setActiveCell, cellAt } = useHeatmap(
'HeatmapChart.Tooltip'
);
const step = grid.size + grid.gap;
// Mirrored for the worklet, which cannot read the JS closure's latest value.
const lastKey = useSharedValue(-1);
const resolve = useMemo(
() => (column: number, row: number) => setActiveCell(cellAt(column, row)),
[cellAt, setActiveCell]
);
const clear = useMemo(() => () => setActiveCell(null), [setActiveCell]);
/*
* The cell is resolved here, on the UI thread, and only crosses into JS when
* it *changes*. Sending every frame's coordinates over and picking the cell
* in React would cost a re-render per frame for a value that changes maybe
* fifty times across a whole drag.
*/
const pick = (x: number, y: number) => {
'worklet';
const column = Math.floor(x / step);
const row = Math.floor(y / step);
if (column < 0 || column >= grid.columns || row < 0 || row >= grid.rows) return;
const key = column * grid.rows + row;
if (key === lastKey.value) return;
lastKey.value = key;
runOnJS(resolve)(column, row);
};
/*
* The readout takes over only once the press has been held.
*
* `onStart` rather than `onBegin` is the other half of it: `onBegin` fires on
* touch-down whatever happens next, so picking there would light a cell and
* dim the rest of the grid for the first moment of every scroll swipe — the
* gesture would yield correctly and still leave a flicker behind it.
*/
const pan = Gesture.Pan()
.minDistance(0)
.activateAfterLongPress(activateAfterLongPress)
.onStart((event) => {
'worklet';
pick(event.x, event.y);
})
.onUpdate((event) => {
'worklet';
pick(event.x, event.y);
})
.onFinalize(() => {
'worklet';
// Fires whether or not the gesture ever activated, so a swipe that only
// scrolled must not report a cell change it never made.
if (lastKey.value === -1) return;
lastKey.value = -1;
runOnJS(clear)();
});
const label = activeCell
? formatLabel
? formatLabel(activeCell)
: defaultTooltipLabel(activeCell)
: null;
// Flipped to the left of the finger past the halfway mark, so the readout
// never runs off the edge it is closest to — and dropped below the cell in
// the top two rows, where there is no room above it inside the chart.
const anchorX = activeCell ? activeCell.column * step + grid.size / 2 : 0;
const flipped = anchorX > grid.width / 2;
const below = (activeCell?.row ?? 0) < 2;
const anchorY = activeCell
? below
? activeCell.row * step + grid.size + 6
: activeCell.row * step - TOOLTIP_HEIGHT - 6
: 0;
return (
<>
{activeCell && label ? (
{label}
) : null}
>
);
}
HeatmapTooltip.slot = 'tooltip' as Slot;
/*
* A cell with no date is not a cell with no data — it is a cell in a grid that
* is not a calendar, and it has a count like any other. Saying "No data" over
* it reports a hole in the data that is not there. Without a date there is
* simply nothing to say after the number, so the number is the whole label,
* and a grid that wants a noun for it passes `formatLabel`.
*/
function defaultTooltipLabel(cell: HeatmapCell) {
if (!cell.date) return `${cell.count}`;
const count = `${cell.count} ${cell.count === 1 ? 'contribution' : 'contributions'}`;
const date = `${MONTHS[cell.date.getMonth()]} ${cell.date.getDate()}`;
return `${count} on ${date}`;
}
/* -------------------------------------------------------------------------- */
/* Legend */
/* -------------------------------------------------------------------------- */
export interface HeatmapLegendProps {
className?: string;
/** Text at the low end of the ramp. */
lessLabel?: string;
/** Text at the high end. */
moreLabel?: string;
/** Side of a swatch, in pixels. Defaults to the chart's cell size. */
swatchSize?: number;
}
/** The `Less ▢▢▢▢▢ More` key, under the grid. */
function HeatmapLegend({
className,
lessLabel = 'Less',
moreLabel = 'More',
swatchSize,
}: HeatmapLegendProps) {
const { ramp, opacities, grid, cornerRadius } = useHeatmap('HeatmapChart.Legend');
const size = swatchSize ?? Math.max(grid.size, 10);
return (
{lessLabel}
{ramp.map((fill, level) => (
))}
{moreLabel}
);
}
HeatmapLegend.slot = 'legend' as Slot;
/* -------------------------------------------------------------------------- */
/* Header */
/* -------------------------------------------------------------------------- */
export interface HeatmapHeaderProps extends ViewProps {
className?: string;
/** Small line above the value — what the grid is of. */
title?: string;
/** The readout. The largest thing on the card, and the first thing read. */
value?: string;
/** One muted line under the value — a period, a total, the held cell. */
caption?: string;
/**
* Draw the ramp along the trailing edge, `Less ▢▢▢▢▢ More`. The key for a
* grid that scrolls sideways, where `HeatmapChart.Legend` under the cells
* would scroll away with them.
*/
legend?: boolean;
/** Text at the low end of the ramp, when `legend` is set. */
lessLabel?: string;
/** Text at the high end. */
moreLabel?: string;
/** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */
children?: ReactNode;
}
/**
* The strip above the grid: what the chart is of, what it currently reads, and
* what the shading means.
*
* It belongs to the chart rather than to the card around it because it is about
* the *grid* — the number changes as a finger moves across the cells, and the
* ramp is the scale the chart itself derived. The card's header is a caption on
* the tray the chart sits in; this is the chart introducing itself.
*
* The value is not derived here. Take it from `onActiveCellChange` and pass the
* formatted string down, so one header can show a total when nothing is held
* and a day's own count when something is.
*/
function HeatmapHeader({
className,
title,
value,
caption,
legend = false,
lessLabel = 'Less',
moreLabel = 'More',
children,
...props
}: HeatmapHeaderProps) {
const { ramp, opacities, grid, cornerRadius } = useHeatmap('HeatmapChart.Header');
// Small enough to sit on one line beside the text, whatever the cells are.
const size = Math.max(Math.min(grid.size, 12), 8);
const trailing =
children ??
(legend ? (
{lessLabel}
{ramp.map((fill, level) => (
))}
{moreLabel}
) : null);
return (
{title ? (
{title}
) : null}
{value ? (
{value}
) : null}
{caption ? (
{caption}
) : null}
{/* Shrinkable, unlike a view's default in React Native — held rigid, the
ramp takes the width it wants and the caption wraps around it. */}
{trailing ? {trailing} : null}
);
}
HeatmapHeader.displayName = 'HeatmapChart.Header';
HeatmapHeader.slot = 'header' as Slot;
export const HeatmapChart = Object.assign(HeatmapChartRoot, {
Header: HeatmapHeader,
Cells: HeatmapCells,
Separator: HeatmapSeparator,
XAxis: HeatmapXAxis,
YAxis: HeatmapYAxis,
Tooltip: HeatmapTooltip,
Legend: HeatmapLegend,
});