"use client";
/**
* RangeChart, from https://motion-brew.com/components/range-chart
*
* One self contained file: the component and the cn()
* helper. Needs React 18 or 19 and Tailwind CSS v4.
*/
import * as React from "react";
import { type ClassValue, clsx } from "clsx";
import { ArrowRight } from "lucide-react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react";
import { twMerge } from "tailwind-merge";
// cn(): merges Tailwind classes, later ones win.
/** Merge Tailwind classes, letting later classes win conflicts. */
function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
// RangeChart
/**
* RangeChart
*
* Trigger: hover and press (plus keys and in view). A white card that charts a
* range for each day, like heart rate: a pill from each day's low to high on
* a soft column track, separate bubbles for outlying readings, and a dashed
* average line. A 7D, 15D or 30D switch picks how many days to show: an ink
* pill slides to it, the bars slim down or widen to fit and grow back in, and
* the labels change from weekdays to dates. When the card scrolls into view
* it rises, the pills grow out from the average one after another and the
* average tag slides in. Hovering a day (or focusing it, or stepping with the
* arrow keys) widens its pills, marks their ends with white dots and fades
* the other days, while the big range in the header rolls to that day like
* an odometer, the caption names it, and the Lowest, Average and Peak tiles
* roll to match; leaving rolls it all back. The Full stats button slides its
* arrow through on hover. Reduced motion keeps the states without movement.
*
* Engine: motion/react (staggered springs for the bars, odometer digit
* columns for the numbers, layoutId pills for the switch and the hovered
* column, AnimatePresence for the captions).
*/
export interface RangeDay {
/** The day, used for labels and the header. */
date: Date;
/** One or more [low, high] ranges; a short one reads as a single bubble. */
ranges: [number, number][];
}
export interface RangeChartProps {
title?: string;
/** Unit after the numbers. Default `"bpm"`. */
unit?: string;
/** Every day you have, oldest first. The chart shows the most recent ones. Default 30 days. */
data?: RangeDay[];
/** Spans the switch offers, in days. Default `[7, 15, 30]`. */
spans?: number[];
/** Span shown first. Default `7`. */
defaultSpan?: number;
/** Value of the dashed average line. Default the mean of the shown readings. */
average?: number;
/** Range colour. Default a warm red. */
color?: string;
/** Called by the Full stats button. */
onFullStats?: () => void;
className?: string;
}
// Thirty days of believable heart rate ranges, from a fixed seed so it never jumps.
const DEFAULT_DATA: RangeDay[] = (() => {
let seed = 7;
const rand = () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;
const end = new Date(2026, 8, 17);
return Array.from({ length: 30 }, (_, i) => {
const date = new Date(end);
date.setDate(end.getDate() - (29 - i));
const low = Math.round(46 + rand() * 22);
const high = Math.round(108 + rand() * 80);
const ranges: [number, number][] = [[low, high]];
// Now and then a reading sits apart from the rest.
if (rand() > 0.72) {
const above = rand() > 0.5;
const v = above
? Math.min(196, high + 14 + Math.round(rand() * 14))
: Math.max(38, low - 16 - Math.round(rand() * 6));
ranges.push([v, v + 8]);
}
return { date, ranges };
});
})();
const GLIDE = [0.22, 1, 0.36, 1] as const;
const GROW = { type: "spring", stiffness: 190, damping: 21 } as const;
const PILL = { type: "spring", stiffness: 480, damping: 38 } as const;
const ROLL = {
type: "spring",
stiffness: 280,
damping: 26,
mass: 0.8,
} as const;
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as const;
const CHART_H = 200;
const LABEL_H = 24; // space under the columns for the labels
function Digit({ digit, still }: { digit: number; still: boolean }) {
return (
{DIGITS.map((d) => (
{d}
))}
);
}
/** A number whose digits roll like an odometer; columns are keyed from the right. */
function Rolling({ value, still }: { value: number; still: boolean }) {
const chars = Array.from(String(Math.round(value)));
return (
{chars.map((ch, i) => (
))}
);
}
const weekday = new Intl.DateTimeFormat("en", { weekday: "short" });
const longDay = new Intl.DateTimeFormat("en", {
weekday: "short",
day: "numeric",
month: "short",
});
const shortDate = new Intl.DateTimeFormat("en", {
day: "numeric",
month: "short",
});
export const RangeChart = React.forwardRef(
function RangeChart(
{
title = "Heart rate",
unit = "bpm",
data = DEFAULT_DATA,
spans = [7, 15, 30],
defaultSpan = 7,
average,
color = "#e5484d",
onFullStats,
className,
},
ref,
) {
const root = React.useRef(null);
React.useImperativeHandle(ref, () => root.current!);
const still = !!useReducedMotion();
const inView = useInView(root, { once: true, amount: 0.3 });
const shown = inView || still;
const id = React.useId();
const [span, setSpan] = React.useState(
spans.includes(defaultSpan) ? defaultSpan : spans[0]!,
);
const [hover, setHover] = React.useState(null);
const days = data.slice(-span);
const n = days.length;
const all = days.flatMap((d) => d.ranges.flat());
const lo = Math.min(...all);
const hi = Math.max(...all);
const avg = Math.round(
average ?? all.reduce((s, v) => s + v, 0) / Math.max(1, all.length),
);
const floor = Math.max(0, lo - 12);
const ceil = hi + 12;
const y = (v: number) => ((v - floor) / (ceil - floor)) * CHART_H; // from the bottom
const avgY = y(avg);
const day = hover === null ? null : days[hover]!;
const vals = day ? day.ranges.flat() : all;
const showLo = Math.min(...vals);
const showHi = Math.max(...vals);
const showAvg = day
? Math.round(vals.reduce((s, v) => s + v, 0) / vals.length)
: avg;
const period = `${shortDate.format(days[0]!.date)} to ${shortDate.format(days[n - 1]!.date)}`;
// Bars slim down as more days share the width.
const barW = n <= 7 ? 22 : n <= 15 ? 12 : 6;
const label = (d: Date, i: number) =>
n <= 7
? weekday.format(d).slice(0, 2)
: n <= 15
? String(d.getDate())
: (n - 1 - i) % 5 === 0
? String(d.getDate())
: "";
const move = (to: number) => {
const next = (to + n) % n;
setHover(next);
root.current?.querySelectorAll("[data-day]")[next]?.focus();
};
return (
{/* Header: title and the span switch */}
{title}
{spans.map((s) => {
const on = s === span;
return (
);
})}
{/* The range rolls to the hovered day. */}
to{unit}
{day ? longDay.format(day.date) : `${period}, last ${span} days`}