"use client"; import { ArrowDownRightIcon, ArrowUpRightIcon } from "lucide-react"; import { AnimatePresence, animate, type MotionValue, motion, useMotionValue, useReducedMotion, useTransform, } from "motion/react"; import { type KeyboardEvent, type PointerEvent, useEffect, useId, useMemo, useRef, useState } from "react"; import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; export interface MetricSeries { name: string; color?: string; } export interface MetricPoint { label: string; values: number[]; } export interface MetricRange { id: string; label: string; caption?: string; points: MetricPoint[]; } export interface MetricCardProps { title?: string; series?: MetricSeries[]; ranges?: MetricRange[]; defaultRange?: string; format?: Intl.NumberFormatOptions; locale?: string; className?: string; } const CHART_COLORS = [ "var(--color-chart-1)", "var(--color-chart-2)", "var(--color-chart-3)", "var(--color-chart-4)", "var(--color-chart-5)", ]; const HEIGHT = 168; const PAD_TOP = 10; const PAD_BOTTOM = 22; const AXIS = 34; const SAMPLES = 180; const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; function Face({ n, turn }: { n: number; turn: MotionValue }) { const y = useTransform(turn, (t) => { let offset = (((n - t) % 10) + 10) % 10; if (offset > 5) offset -= 10; return `${offset * 100}%`; }); return ( {n} ); } function Wheel({ digit, instant, delay }: { digit: number; instant: boolean; delay: number }) { const reduce = useReducedMotion(); const turn = useMotionValue(digit); const target = useRef(digit); useEffect(() => { let step = digit - (((target.current % 10) + 10) % 10); if (step > 5) step -= 10; else if (step < -5) step += 10; target.current += step; if (reduce || instant) turn.jump(target.current); else if (step !== 0 || (!turn.isAnimating() && turn.get() !== target.current)) animate(turn, target.current, { type: "spring", stiffness: 280, damping: 34, delay }); }, [digit, instant, delay, reduce, turn]); useEffect(() => () => turn.stop(), [turn]); return ( 0 {DIGITS.map((n) => ( ))} ); } function Roll({ text, className, instant = false, delay = 0, }: { text: string; className?: string; instant?: boolean; delay?: number; }) { let place = 0; let other = 0; // Key places from the right so existing digits keep their wheel when the number grows. const glyphs = [...text] .reverse() .map((char) => { const isDigit = char >= "0" && char <= "9"; return { char, isDigit, key: isDigit ? `d${place++}` : `s${other++}${char}` }; }) .reverse(); return ( {text} {glyphs.map((g) => g.isDigit ? ( ) : ( {g.char === " " ? " " : g.char} ), )} ); } function sampleCurve(values: number[], count: number) { const n = values.length; if (n === 1) return Array.from({ length: count }, () => values[0]); const at = (i: number) => values[Math.max(0, Math.min(n - 1, i))]; return Array.from({ length: count }, (_, k) => { const u = (k / (count - 1)) * (n - 1); const i = Math.min(n - 2, Math.floor(u)); const s = u - i; const p0 = at(i - 1); const p1 = at(i); const p2 = at(i + 1); const p3 = at(i + 2); return ( 0.5 * (2 * p1 + (-p0 + p2) * s + (2 * p0 - 5 * p1 + 4 * p2 - p3) * s * s + (-p0 + 3 * p1 - 3 * p2 + p3) * s * s * s) ); }); } function mulberry(seed: number) { return () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function makeRange(id: string, label: string, caption: string, count: number, stepDays: number, seed: number) { const rand = mulberry(seed); const end = Date.UTC(2026, 9, 23); const bases = [3200, 1900, 900]; return { id, label, caption, points: Array.from({ length: count }, (_, i) => { const date = new Date(end - (count - 1 - i) * stepDays * 86400000); const trend = 1 + (i / count) * 0.35; return { label: date.toLocaleDateString("en-US", { month: "short", day: stepDays >= 28 ? undefined : "numeric", timeZone: "UTC", }), values: bases.map((b, s) => Math.round(b * trend * (0.86 + rand() * 0.28) * (1 + 0.12 * Math.sin(i / (1.6 + s)))), ), }; }), }; } const defaultSeries: MetricSeries[] = [{ name: "Organic" }, { name: "Paid" }, { name: "Referral" }]; const defaultRanges: MetricRange[] = [ makeRange("7d", "7D", "Last 7 days", 7, 1, 11), makeRange("30d", "30D", "Last 30 days", 30, 1, 23), makeRange("90d", "90D", "Last 90 days", 13, 7, 37), makeRange("12m", "12M", "Last 12 months", 12, 30.4, 51), ]; export function MetricCard({ title = "Active users", series = defaultSeries, ranges = defaultRanges, defaultRange, format = { maximumFractionDigits: 0 }, locale = "en-US", className, }: MetricCardProps) { const reduce = useReducedMotion(); const uid = useId().replace(/:/g, ""); const wrapRef = useRef(null); const [width, setWidth] = useState(420); const [rangeId, setRangeId] = useState(defaultRange ?? ranges[1]?.id ?? ranges[0].id); const [hidden, setHidden] = useState>(() => new Set()); const [active, setActive] = useState(null); const [announce, setAnnounce] = useState(""); const [scrubbing, setScrubbing] = useState(false); useEffect(() => { const el = wrapRef.current; if (!el) return; const ro = new ResizeObserver(([entry]) => setWidth(Math.max(200, entry.contentRect.width))); ro.observe(el); return () => ro.disconnect(); }, []); const range = ranges.find((r) => r.id === rangeId) ?? ranges[0]; const points = range.points; const n = points.length; const fmt = useMemo(() => new Intl.NumberFormat(locale, format), [locale, format]); const axisFmt = useMemo( () => new Intl.NumberFormat(locale, { notation: "compact", maximumFractionDigits: 1 }), [locale], ); const visible = series.map((_, i) => !hidden.has(i)); const plotW = width - AXIS; const plotH = HEIGHT - PAD_TOP - PAD_BOTTOM; const rawMax = Math.max(1, ...points.flatMap((p) => p.values.filter((_, i) => visible[i]))); const step = niceStep(rawMax / 3); const max = Math.ceil((rawMax * 1.08) / step) * step; const xAt = (i: number) => AXIS + (n === 1 ? plotW / 2 : (i / (n - 1)) * plotW); const yAt = (v: number) => PAD_TOP + plotH - (Math.max(0, Math.min(max, v)) / max) * plotH; const paths = series.map((_, s) => { const ys = sampleCurve( points.map((p) => p.values[s] ?? 0), SAMPLES, ); const line = ys .map((v, k) => `${k ? "L" : "M"}${(AXIS + (k / (SAMPLES - 1)) * plotW).toFixed(2)} ${yAt(v).toFixed(2)}`) .join(" "); return { line, area: `${line} L${(AXIS + plotW).toFixed(2)} ${PAD_TOP + plotH} L${AXIS} ${PAD_TOP + plotH} Z` }; }); const focusIndex = active ?? n - 1; const total = (i: number) => points[i].values.reduce((sum, v, s) => sum + (visible[s] ? v : 0), 0); const headline = total(focusIndex); const first = total(0); const delta = first ? (headline - first) / first : 0; const up = delta >= 0; const gridValues = Array.from({ length: Math.round(max / step) + 1 }, (_, i) => i * step); const xLabels = n > 2 ? [0, Math.floor((n - 1) / 2), n - 1] : points.map((_, i) => i); const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 260, damping: 30 }; function onPointerMove(e: PointerEvent) { const rect = e.currentTarget.getBoundingClientRect(); const x = e.clientX - rect.left - AXIS; setScrubbing(true); setActive(Math.max(0, Math.min(n - 1, Math.round((x / plotW) * (n - 1))))); } function onKeyDown(e: KeyboardEvent) { let next: number | null = null; if (e.key === "ArrowRight") next = Math.min(n - 1, (active ?? -1) + 1); else if (e.key === "ArrowLeft") next = Math.max(0, (active ?? n) - 1); else if (e.key === "Home") next = 0; else if (e.key === "End") next = n - 1; else if (e.key === "Escape") { setActive(null); return; } else return; e.preventDefault(); setScrubbing(true); setActive(next); const p = points[next]; setAnnounce( `${p.label}: ${series .map((s, i) => (visible[i] ? `${s.name} ${fmt.format(p.values[i] ?? 0)}` : null)) .filter(Boolean) .join(", ")}`, ); } const tipLeft = active === null ? 0 : xAt(active); const flip = tipLeft > width * 0.58; return (
{title}
{up ? : } {active === null ? (range.caption ?? range.label) : `${points[active].label} vs ${points[0].label}`}
{ setScrubbing(false); setRangeId(String(v)); setActive(null); }} value={rangeId} > {ranges.map((r) => ( {r.label} ))}
{ setActive(null); setScrubbing(false); }} onKeyDown={onKeyDown} onPointerLeave={() => { setActive(null); }} onPointerMove={onPointerMove} ref={wrapRef} role="application" tabIndex={0} > {series.map((s, i) => ( ))} {gridValues.map((v) => ( {axisFmt.format(v)} ))} {xLabels.map((i, k) => ( {points[i].label} ))} {series.map((s, i) => ( ))} {series.map((s, i) => ( ))} {active !== null && ( {series.map((s, i) => visible[i] ? ( ) : null, )} )} {active !== null && (
{points[active].label}
{series.map((s, i) => visible[i] ? (
{s.name} {fmt.format(points[active].values[i] ?? 0)}
) : null, )}
)}
{announce}
{series.map((s, i) => ( ))}
); } function niceStep(raw: number) { const pow = 10 ** Math.floor(Math.log10(raw)); const unit = raw / pow; return (unit <= 1 ? 1 : unit <= 2 ? 2 : unit <= 2.5 ? 2.5 : unit <= 5 ? 5 : 10) * pow; }