"use client"; import { AnimatePresence, animate, type MotionValue, motion, useMotionValue, useReducedMotion, useTransform, } from "motion/react"; import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "@/components/ui/select"; import { cn } from "@/lib/utils"; export interface ExplorerMeasure { id: string; label: string; format?: Intl.NumberFormatOptions; } export interface ExplorerSlice { id: string; label: string; series: string[]; } export interface ExplorerData { periods: string[]; measures: ExplorerMeasure[]; slices: ExplorerSlice[]; values: Record>; } export interface StackedExplorerProps { title?: string; data?: ExplorerData; defaultMeasure?: string; defaultSlice?: string; locale?: string; className?: string; } const COLORS = [ "var(--color-chart-1)", "var(--color-chart-2)", "var(--color-chart-3)", "var(--color-chart-4)", "var(--color-chart-5)", ]; const HEIGHT = 176; const AXIS = 36; const PAD_TOP = 8; const PAD_BOTTOM = 20; const GAP = 2; 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 placeFromRight = 0; let other = 0; const glyphs = [...text] .reverse() .map((char) => { const isDigit = char >= "0" && char <= "9"; return { char, isDigit, key: isDigit ? `d${placeFromRight++}` : `s${other++}${char}` }; }) .reverse(); return ( {text} {glyphs.map((g) => g.isDigit ? ( ) : ( {g.char === " " ? " " : g.char} ), )} ); } 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 buildDefaultData(): ExplorerData { const rand = mulberry(7); const periods = ["Nov", "Dec", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct"]; const measures: ExplorerMeasure[] = [ { id: "revenue", label: "Revenue", format: { style: "currency", currency: "USD", notation: "compact", minimumFractionDigits: 0, maximumFractionDigits: 1 } }, { id: "orders", label: "Orders", format: { maximumFractionDigits: 0 } }, { id: "customers", label: "Customers", format: { maximumFractionDigits: 0 } }, ]; const slices: ExplorerSlice[] = [ { id: "region", label: "Region", series: ["Americas", "Europe", "Asia Pacific", "Africa"] }, { id: "plan", label: "Plan", series: ["Starter", "Pro", "Team", "Enterprise"] }, { id: "channel", label: "Channel", series: ["Direct", "Partners", "Marketplace"] }, ]; const bases: Record = { revenue: 182000, orders: 2400, customers: 860 }; const values: ExplorerData["values"] = {}; for (const m of measures) { const totals = periods.map((_, i) => bases[m.id] * (1 + i * 0.045) * (0.88 + rand() * 0.24)); values[m.id] = {}; for (const s of slices) { const weights = s.series.map((_, k) => (s.series.length - k) * (0.7 + rand() * 0.6)); values[m.id][s.id] = s.series.map((_, k) => periods.map((_, i) => { const drift = weights.map((w, j) => w * (1 + Math.sin(i / 2.4 + j * 1.7) * 0.22)); const sum = drift.reduce((a, b) => a + b, 0); return Math.round((totals[i] * drift[k]) / sum); }), ); } } return { periods, measures, slices, values }; } const defaultData = buildDefaultData(); // Formatted by hand so server and browser ICU data always agree. function compact(v: number) { const units: [number, string][] = [ [1e9, "B"], [1e6, "M"], [1e3, "K"], ]; for (const [size, suffix] of units) { if (Math.abs(v) >= size) return `${Number((v / size).toFixed(1))}${suffix}`; } return String(Math.round(v)); } 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; } export function StackedExplorer({ title = "Breakdown", data = defaultData, defaultMeasure, defaultSlice, locale = "en-US", className, }: StackedExplorerProps) { const reduce = useReducedMotion(); const wrapRef = useRef(null); const [width, setWidth] = useState(460); const [measureId, setMeasureId] = useState(defaultMeasure ?? data.measures[0].id); const [sliceId, setSliceId] = useState(defaultSlice ?? data.slices[0].id); const [column, setColumn] = useState(null); const [seriesHover, setSeriesHover] = useState(null); const [scrubbing, setScrubbing] = useState(false); useEffect(() => { const el = wrapRef.current; if (!el) return; const ro = new ResizeObserver(([entry]) => setWidth(Math.max(240, entry.contentRect.width))); ro.observe(el); return () => ro.disconnect(); }, []); const measure = data.measures.find((m) => m.id === measureId) ?? data.measures[0]; const slice = data.slices.find((s) => s.id === sliceId) ?? data.slices[0]; const rows = data.values[measure.id]?.[slice.id] ?? []; const periods = data.periods; const fmt = useMemo(() => new Intl.NumberFormat(locale, measure.format), [locale, measure.format]); const axisPrefix = measure.format?.style === "currency" ? (fmt.formatToParts(0).find((p) => p.type === "currency")?.value ?? "") : ""; const axisLabel = (v: number) => `${axisPrefix}${compact(v)}`; const stackTotals = periods.map((_, p) => rows.reduce((sum, r) => sum + (r[p] ?? 0), 0)); const rawMax = Math.max(1, ...stackTotals); const step = niceStep(rawMax / 3); const max = Math.ceil(rawMax / step) * step; const plotW = width - AXIS; const plotH = HEIGHT - PAD_TOP - PAD_BOTTOM; const slot = plotW / periods.length; const barW = Math.min(28, slot * 0.62); const labelEvery = slot < 34 ? 2 : 1; const scale = (v: number) => (v / max) * plotH; const baseY = PAD_TOP + plotH; const grid = Array.from({ length: Math.round(max / step) + 1 }, (_, i) => i * step); const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 220, damping: 30 }; const seriesTotals = rows.map((r) => r.reduce((a, b) => a + b, 0)); const grand = seriesTotals.reduce((a, b) => a + b, 0); const shown = column === null ? seriesTotals : rows.map((r) => r[column] ?? 0); const shownTotal = shown.reduce((a, b) => a + b, 0); function onKeyDown(e: KeyboardEvent) { const n = periods.length; setScrubbing(true); if (e.key === "ArrowRight") setColumn((c) => Math.min(n - 1, (c ?? -1) + 1)); else if (e.key === "ArrowLeft") setColumn((c) => Math.max(0, (c ?? n) - 1)); else if (e.key === "Escape") setColumn(null); else return; e.preventDefault(); } const tipX = column === null ? 0 : AXIS + slot * column + slot / 2; const flip = tipX > width * 0.55; return (
{title} · {measure.label} by {slice.label.toLowerCase()} {column === null ? `${periods[0]} – ${periods[periods.length - 1]}` : periods[column]}
setColumn(null)} onKeyDown={onKeyDown} onPointerLeave={() => { setColumn(null); setSeriesHover(null); }} ref={wrapRef} role="application" tabIndex={0} > {grid.map((v) => ( {axisLabel(v)} ))} {periods.map((label, p) => { const x = AXIS + slot * p + (slot - barW) / 2; let acc = 0; const dim = column !== null && column !== p; return ( {rows.map((r, s) => { const h = scale(r[p] ?? 0); const y = baseY - acc - h; acc += h; const segH = Math.max(0, h - GAP); const faded = seriesHover !== null && seriesHover !== s; return ( ); })} {label} { setScrubbing(true); setColumn(p); }} onPointerLeave={() => setSeriesHover(null)} onPointerMove={(e) => { const rect = e.currentTarget.getBoundingClientRect(); const fromBottom = rect.bottom - e.clientY; let sum = 0; let hit: number | null = null; rows.forEach((r, s) => { const h = scale(r[p] ?? 0); if (hit === null && fromBottom >= sum && fromBottom < sum + h) hit = s; sum += h; }); setSeriesHover(hit); }} width={slot} x={AXIS + slot * p} y={0} /> ); })} {column !== null && (
{periods[column]} {fmt.format(stackTotals[column])}
{[...rows.keys()].reverse().map((s) => (
{slice.series[s]} {fmt.format(rows[s][column] ?? 0)}
))}
)}
{[...rows.keys()].reverse().map((s) => { const share = shownTotal ? shown[s] / shownTotal : 0; const lit = seriesHover === s; const faded = seriesHover !== null && !lit; return ( setSeriesHover(null)} onFocus={() => setSeriesHover(s)} onPointerEnter={() => setSeriesHover(s)} onPointerLeave={() => setSeriesHover(null)} tabIndex={0} transition={{ duration: reduce ? 0 : 0.18 }} > ); })}
{measure.label} by {slice.label} {column === null ? "" : `, ${periods[column]}`}
{slice.label} {column === null ? "Total" : periods[column]} Share
{slice.series[s]} {(share * 100).toFixed(1)}%
); }