"use client"; import { useId, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react"; import { motion, useInView, useReducedMotion } from "motion/react"; type ChartBloomProps = { /** Points in order, like months. Values are 0 or above. */ data: { label: string; value: number }[]; /** Chart title. Also its name for screen readers. */ label: string; /** Turns a value into text, like (v) => `$${v}k`. */ format?: (value: number) => string; /** Plot height in pixels. */ height?: number; /** Line and area colour. */ accent?: string; /** Wrapper look (text colour, width). */ className?: string; }; /* Rounds up to a tidy top value for the axis. */ function niceTop(max: number) { if (max <= 0) return 1; const step = 10 ** Math.floor(Math.log10(max)); return [1, 1.5, 2, 3, 5, 7.5, 10].find((m) => m * step >= max)! * step; } /* ChartBloom: an area chart. The line wipes in from the left, the area blooms up from the baseline, then a crosshair reads any point by pointer, touch or arrow keys. */ export function ChartBloom({ data, label, format = String, height = 160, accent = "#a78bfa", className = "text-white" }: ChartBloomProps) { const reduce = useReducedMotion(); const fill = useId(); const box = useRef(null); const seen = useInView(box, { once: true, amount: 0.4 }); const [active, setActive] = useState(null); if (!data.length) return null; const last = data.length - 1; const top = niceTop(Math.max(...data.map((d) => d.value))); const points = data.map((d, i) => ({ x: (i / (last || 1)) * 100, y: 100 - (d.value / top) * 100 })); const line = points .map((p, i) => { if (!i) return `M${p.x} ${p.y}`; const prev = points[i - 1]; const mid = (prev.x + p.x) / 2; return `C${mid} ${prev.y} ${mid} ${p.y} ${p.x} ${p.y}`; }) .join(" "); const step = Math.ceil(data.length / 6); const current = active ?? last; const point = active === null ? null : points[active]; const pick = (e: PointerEvent) => { const plot = e.currentTarget.getBoundingClientRect(); const place = Math.round(((e.clientX - plot.left) / plot.width) * last); setActive(Math.min(last, Math.max(0, place))); }; const onKey = (e: KeyboardEvent) => { const moves: Record = { ArrowRight: current + 1, ArrowUp: current + 1, ArrowLeft: current - 1, ArrowDown: current - 1, Home: 0, End: last }; if (!(e.key in moves)) return; e.preventDefault(); setActive(Math.min(last, Math.max(0, moves[e.key]))); }; return (
{label}
{format(top)} {format(top / 2)} {format(0)}
e.pointerType === "mouse" && setActive(null)} onFocus={() => setActive((a) => a ?? last)} onBlur={() => setActive(null)} onKeyDown={onKey} style={{ height }} className="relative touch-pan-y rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-white/70" > {[0, 50, 100].map((y) => ( ))} {point && (
80 ? -100 : -50}%, ${point.y < 35 ? "12px" : "calc(-100% - 12px)"})` }} className="absolute top-0 left-0 rounded-lg border border-white/10 bg-[#17151f] px-2.5 py-1.5 text-xs whitespace-nowrap text-white shadow-lg" > {data[current].label} {format(data[current].value)}
)}
{data.map((d, i) => i % step === 0 ? ( {d.label} ) : null, )}
); }