"use client"; import { ArrowUpRightIcon } from "lucide-react"; import { animate, type MotionValue, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react"; import { useEffect, useRef, useState } from "react"; import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; export interface SplitPart { id: string; label: string; value: number; } export interface SplitPeriod { id: string; label: string; parts: SplitPart[]; previousTotal?: number; } export interface SplitCardProps { title?: string; periods: SplitPeriod[]; defaultPeriod?: string; format?: (value: number) => string; good?: "up" | "down"; maxRows?: number; 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 defaultFormat = (v: number) => new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(v); const GAP_PX = 3; 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} ), )} ); } export function SplitCard({ title = "Breakdown", periods, defaultPeriod, format = defaultFormat, good = "up", maxRows = 5, className, }: SplitCardProps) { const reduce = useReducedMotion(); const [periodId, setPeriodId] = useState(defaultPeriod ?? periods[0]?.id); const [hovered, setHovered] = useState(null); const period = periods.find((p) => p.id === periodId) ?? periods[0]; const colorIndex = new Map(); for (const p of periods) for (const part of p.parts) if (!colorIndex.has(part.id)) colorIndex.set(part.id, colorIndex.size); const colorOf = (id: string) => (id === "__other" ? "var(--color-muted-foreground)" : COLORS[(colorIndex.get(id) ?? 0) % COLORS.length]); const total = period.parts.reduce((sum, p) => sum + p.value, 0); const ranked = [...period.parts].sort((a, b) => b.value - a.value); const inBarOrder = [...period.parts].sort((a, b) => (colorIndex.get(a.id) ?? 0) - (colorIndex.get(b.id) ?? 0)); const rows = ranked.length > maxRows ? [ ...ranked.slice(0, maxRows - 1), { id: "__other", label: "Other", value: ranked.slice(maxRows - 1).reduce((s, p) => s + p.value, 0) }, ] : ranked; const change = period.previousTotal ? (total - period.previousTotal) / period.previousTotal : null; const up = (change ?? 0) >= 0; const better = up === (good === "up"); const glide = reduce ? { duration: 0 } : ({ type: "spring", duration: 0.45, bounce: 0 } as const); let acc = 0; const segments = inBarOrder.map((part, i) => { const start = total ? (acc / total) * 100 : 0; acc += part.value; const end = total ? (acc / total) * 100 : 0; return { part, start, end, first: i === 0, last: i === inBarOrder.length - 1 }; }); return (

{title}

setPeriodId(String(v))} value={period.id}> {periods.map((p) => ( {p.label} ))}
{change !== null && ( {up ? "up" : "down"} )}
{segments.map(({ part, start, end, first, last }) => { const folded = !rows.some((r) => r.id === part.id); const lit = !hovered || hovered === part.id || (hovered === "__other" && folded); const l = first ? 0 : GAP_PX / 2; const r = last ? 0 : GAP_PX / 2; return ( ); })}
    {rows.map((row, i) => (
  1. {i + 1}
  2. ))}
    {rows.map((row) => { const share = total ? row.value / total : 0; return ( setHovered(row.id)} onPointerLeave={() => setHovered(null)} transition={glide} > {row.label} {Math.round(share * 100)}% ); })}
); }