"use client"; import { TrendingUpIcon } from "lucide-react"; import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from "motion/react"; import { useEffect, useId, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export type GrowthChartHue = "sky" | "emerald" | "amber" | "violet" | "rose" | "orange" | "indigo" | "teal"; const hues: Record = { sky: "[--hue-hi:var(--color-sky-400)] [--hue:var(--color-sky-500)] [--hue-deep:var(--color-sky-600)]", emerald: "[--hue-hi:var(--color-emerald-400)] [--hue:var(--color-emerald-500)] [--hue-deep:var(--color-emerald-600)]", amber: "[--hue-hi:var(--color-amber-400)] [--hue:var(--color-amber-500)] [--hue-deep:var(--color-amber-600)]", violet: "[--hue-hi:var(--color-violet-400)] [--hue:var(--color-violet-500)] [--hue-deep:var(--color-violet-600)]", rose: "[--hue-hi:var(--color-rose-400)] [--hue:var(--color-rose-500)] [--hue-deep:var(--color-rose-600)]", orange: "[--hue-hi:var(--color-orange-400)] [--hue:var(--color-orange-500)] [--hue-deep:var(--color-orange-600)]", indigo: "[--hue-hi:var(--color-indigo-400)] [--hue:var(--color-indigo-500)] [--hue-deep:var(--color-indigo-600)]", teal: "[--hue-hi:var(--color-teal-400)] [--hue:var(--color-teal-500)] [--hue-deep:var(--color-teal-600)]", }; export interface GrowthChartPoint { label: string; value: number; } export interface GrowthChartProps { data?: GrowthChartPoint[]; title?: string; format?: (value: number) => string; hue?: GrowthChartHue; loop?: boolean; className?: string; } const defaultData: GrowthChartPoint[] = [ { label: "Jan", value: 18400 }, { label: "Feb", value: 22900 }, { label: "Mar", value: 21300 }, { label: "Apr", value: 29800 }, { label: "May", value: 34900 }, { label: "Jun", value: 48200 }, ]; const W = 400; const H = 210; const BASE = 180; const TOP = 46; const LEFT = 28; const RIGHT = 372; const grow = { type: "spring", stiffness: 220, damping: 20 } as const; const R = 7; function useCalmMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } function flagPath(x: number, y: number, phase: 0 | 1) { const top = y - 40; const k = phase ? 1 : -1; return `M${x} ${top} C ${x + 10} ${top - 3 * k} ${x + 18} ${top + 3 * k} ${x + 28} ${top + 1} L ${x + 26} ${top + 11} L ${x + 28} ${top + 21} C ${x + 18} ${top + 23 + 3 * k} ${x + 10} ${top + 17 - 3 * k} ${x} ${top + 20} Z`; } export function GrowthChart({ data = defaultData, title = "Record month", format = (v) => `$${Math.round(v).toLocaleString("en-US")}`, hue = "emerald", loop = true, className, }: GrowthChartProps) { const uid = useId().replace(/:/g, ""); const reduce = useCalmMotion(); const root = useRef(null); const inView = useInView(root); const [grown, setGrown] = useState(0); const [planted, setPlanted] = useState(false); const max = Math.max(...data.map((d) => d.value)); const step = (RIGHT - LEFT) / data.length; const barW = Math.min(34, step * 0.56); const pts = data.map((d, i) => ({ x: LEFT + step * i + step / 2, y: BASE - (d.value / max) * (BASE - TOP), })); const last = pts[pts.length - 1]; const prev = data[data.length - 2]?.value ?? data[0].value; const change = Math.round(((data[data.length - 1].value - prev) / prev) * 100); const dataKey = data.map((d) => `${d.label}:${d.value}`).join("|"); const figure = useMotionValue(0); const figureText = useTransform(figure, (v) => format(v)); const bx = useMotionValue(pts[0].x); const by = useMotionValue(TOP - 24); const ballOpacity = useMotionValue(0); const squash = useMotionValue(1); const stretch = useTransform(squash, (v) => 2 - v); useEffect(() => { const finish = () => { setGrown(data.length); setPlanted(true); figure.set(data[data.length - 1].value); bx.set(last.x); by.set(last.y - R); ballOpacity.set(1); }; if (reduce) { finish(); return; } if (!inView) return; let alive = true; const timers: ReturnType[] = []; const stops: { stop: () => void }[] = []; const wait = (ms: number) => new Promise((r) => timers.push(setTimeout(r, ms))); const run = void }>(c: T) => { stops.push(c); return c; }; const hop = (x0: number, y0: number, x1: number, y1: number, duration: number) => { const c = 2 * (Math.min(y0, y1) - 26) - (y0 + y1) / 2; return run( animate(0, 1, { duration, ease: "linear", onUpdate: (t) => { bx.set(x0 + (x1 - x0) * t); by.set((1 - t) ** 2 * y0 + 2 * (1 - t) * t * c + t ** 2 * y1); }, }), ); }; (async () => { while (alive) { setGrown(0); setPlanted(false); figure.set(0); bx.set(pts[0].x); by.set(TOP - 30); squash.set(1); run(animate(ballOpacity, 1, { duration: 0.25 })); await wait(450); if (!alive) return; for (let i = 0; i < pts.length; i++) { const from = i === 0 ? { x: pts[0].x, y: TOP - 30 } : { x: pts[i - 1].x, y: pts[i - 1].y - R }; setGrown(i + 1); await hop(from.x, from.y, pts[i].x, pts[i].y - R, i === 0 ? 0.5 : 0.42); if (!alive) return; run(animate(squash, [1, 0.72, 1], { duration: 0.24, ease: [0.23, 1, 0.32, 1] })); run(animate(figure, data[i].value, { duration: 0.35, ease: [0.23, 1, 0.32, 1] })); await wait(i === pts.length - 1 ? 120 : 60); if (!alive) return; } setPlanted(true); if (!loop) return; await wait(4400); if (!alive) return; setPlanted(false); setGrown(0); run(animate(ballOpacity, 0, { duration: 0.25 })); await wait(700); } })(); return () => { alive = false; for (const t of timers) clearTimeout(t); for (const x of stops) x.stop(); }; }, [reduce, inView, loop, dataKey]); return (

{title}

{figureText}
+{change}%
`${d.label} ${format(d.value)}`).join(", ")}`} className="absolute inset-0 size-full overflow-visible" role="img" viewBox={`0 0 ${W} ${H}`} > {[0.25, 0.5, 0.75].map((f) => ( ))} {pts.map((p, i) => { const on = i < grown; return ( ); })} {!reduce && [0, 1, 2, 3, 4, 5].map((n) => { const a = (-150 + n * 24) * (Math.PI / 180); return ( ); })} {pts.map((p, i) => ( {data[i].label} ))}
); }