"use client"; import { motion, useSpring, useTransform } from "framer-motion"; import { type CSSProperties, type ReactNode, useCallback, useEffect, useRef, useState, } from "react"; import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; // ─── Utils ─────────────────────────────────────────────────────────────────── function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } // ─── PatternLines ──────────────────────────────────────────────────────────── export interface PatternLinesProps { id: string; width?: number; height?: number; stroke?: string; strokeWidth?: number; orientation?: ("diagonal" | "horizontal" | "vertical")[]; background?: string; } export function PatternLines({ id, width = 6, height = 6, stroke = "var(--chart-line-primary)", strokeWidth = 1, orientation = ["diagonal"], background, }: PatternLinesProps) { const paths: string[] = []; for (const o of orientation) { if (o === "diagonal") { paths.push(`M0,${height}l${width},${-height}`); paths.push(`M${-width / 4},${height / 4}l${width / 2},${-height / 2}`); paths.push( `M${(3 * width) / 4},${height + height / 4}l${width / 2},${-height / 2}` ); } else if (o === "horizontal") { paths.push(`M0,${height / 2}l${width},0`); } else if (o === "vertical") { paths.push(`M${width / 2},0l0,${height}`); } } return ( {background && ( )} ); } PatternLines.displayName = "PatternLines"; // ─── Types ─────────────────────────────────────────────────────────────────── export interface FunnelGradientStop { offset: string | number; color: string; } export interface FunnelStage { label: string; value: number; displayValue?: string; color?: string; gradient?: FunnelGradientStop[]; } export interface FunnelChartProps { data: FunnelStage[]; orientation?: "horizontal" | "vertical"; color?: string; layers?: number; className?: string; style?: CSSProperties; showPercentage?: boolean; showValues?: boolean; showLabels?: boolean; hoveredIndex?: number | null; onHoverChange?: (index: number | null) => void; formatPercentage?: (pct: number) => string; formatValue?: (value: number) => string; staggerDelay?: number; gap?: number; renderPattern?: (id: string, color: string) => ReactNode; edges?: "curved" | "straight"; labelLayout?: "spread" | "grouped"; labelOrientation?: "vertical" | "horizontal"; labelAlign?: "center" | "start" | "end"; grid?: | boolean | { bands?: boolean; bandColor?: string; lines?: boolean; lineColor?: string; lineOpacity?: number; lineWidth?: number; }; } // ─── Defaults ──────────────────────────────────────────────────────────────── const fmtPct = (p: number) => `${Math.round(p)}%`; const fmtVal = (v: number) => v.toLocaleString("en-US"); const springConfig = { stiffness: 120, damping: 20, mass: 1 }; const hoverSpring = { stiffness: 300, damping: 24 }; // ─── SVG Helpers ───────────────────────────────────────────────────────────── function hSegmentPath( normStart: number, normEnd: number, segW: number, H: number, layerScale: number, straight = false ) { const my = H / 2; const h0 = normStart * H * 0.44 * layerScale; const h1 = normEnd * H * 0.44 * layerScale; if (straight) { return `M 0 ${my - h0} L ${segW} ${my - h1} L ${segW} ${my + h1} L 0 ${my + h0} Z`; } const cx = segW * 0.55; const top = `M 0 ${my - h0} C ${cx} ${my - h0}, ${segW - cx} ${my - h1}, ${segW} ${my - h1}`; const bot = `L ${segW} ${my + h1} C ${segW - cx} ${my + h1}, ${cx} ${my + h0}, 0 ${my + h0}`; return `${top} ${bot} Z`; } function vSegmentPath( normStart: number, normEnd: number, segH: number, W: number, layerScale: number, straight = false ) { const mx = W / 2; const w0 = normStart * W * 0.44 * layerScale; const w1 = normEnd * W * 0.44 * layerScale; if (straight) { return `M ${mx - w0} 0 L ${mx - w1} ${segH} L ${mx + w1} ${segH} L ${mx + w0} 0 Z`; } const cy = segH * 0.55; const left = `M ${mx - w0} 0 C ${mx - w0} ${cy}, ${mx - w1} ${segH - cy}, ${mx - w1} ${segH}`; const right = `L ${mx + w1} ${segH} C ${mx + w1} ${segH - cy}, ${mx + w0} ${cy}, ${mx + w0} 0`; return `${left} ${right} Z`; } // ─── Animated Ring ─────────────────────────────────────────────────────────── function HRing({ d, color, fill, opacity, hovered, ringIndex, totalRings, }: { d: string; color: string; fill?: string; opacity: number; hovered: boolean; ringIndex: number; totalRings: number; }) { const extraScale = 1 + (ringIndex / Math.max(totalRings - 1, 1)) * 0.12; const ringSpring = { stiffness: 300 - ringIndex * 60, damping: 24 - ringIndex * 3, }; const scaleY = useSpring(1, ringSpring); useEffect(() => { scaleY.set(hovered ? extraScale : 1); }, [hovered, scaleY, extraScale]); return ( ); } function VRing({ d, color, fill, opacity, hovered, ringIndex, totalRings, }: { d: string; color: string; fill?: string; opacity: number; hovered: boolean; ringIndex: number; totalRings: number; }) { const extraScale = 1 + (ringIndex / Math.max(totalRings - 1, 1)) * 0.12; const ringSpring = { stiffness: 300 - ringIndex * 60, damping: 24 - ringIndex * 3, }; const scaleX = useSpring(1, ringSpring); useEffect(() => { scaleX.set(hovered ? extraScale : 1); }, [hovered, scaleX, extraScale]); return ( ); } // ─── Animated Segments ─────────────────────────────────────────────────────── function HSegment({ index, normStart, normEnd, segW, fullH, color, layers, staggerDelay, hovered, dimmed, renderPattern, straight, gradientStops, }: { index: number; normStart: number; normEnd: number; segW: number; fullH: number; color: string; layers: number; staggerDelay: number; hovered: boolean; dimmed: boolean; renderPattern?: (id: string, color: string) => ReactNode; straight: boolean; gradientStops?: FunnelGradientStop[]; }) { const patternId = `funnel-h-pattern-${index}`; const gradientId = `funnel-h-grad-${index}`; const growProgress = useSpring(0, springConfig); const entranceScaleX = useTransform(growProgress, [0, 1], [0, 1]); const entranceScaleY = useTransform(growProgress, [0, 1], [0, 1]); const dimOpacity = useSpring(1, hoverSpring); useEffect(() => { dimOpacity.set(dimmed ? 0.4 : 1); }, [dimmed, dimOpacity]); useEffect(() => { const timeout = setTimeout( () => growProgress.set(1), index * staggerDelay * 1000 ); return () => clearTimeout(timeout); }, [growProgress, index, staggerDelay]); const rings = Array.from({ length: layers }, (_, l) => { const scale = 1 - (l / layers) * 0.35; const opacity = 0.18 + (l / (layers - 1 || 1)) * 0.65; return { d: hSegmentPath(normStart, normEnd, segW, fullH, scale, straight), opacity, }; }); return ( ); } function VSegment({ index, normStart, normEnd, segH, fullW, color, layers, staggerDelay, hovered, dimmed, renderPattern, straight, gradientStops, }: { index: number; normStart: number; normEnd: number; segH: number; fullW: number; color: string; layers: number; staggerDelay: number; hovered: boolean; dimmed: boolean; renderPattern?: (id: string, color: string) => ReactNode; straight: boolean; gradientStops?: FunnelGradientStop[]; }) { const patternId = `funnel-v-pattern-${index}`; const gradientId = `funnel-v-grad-${index}`; const growProgress = useSpring(0, springConfig); const entranceScaleY = useTransform(growProgress, [0, 1], [0, 1]); const entranceScaleX = useTransform(growProgress, [0, 1], [0, 1]); const dimOpacity = useSpring(1, hoverSpring); useEffect(() => { dimOpacity.set(dimmed ? 0.4 : 1); }, [dimmed, dimOpacity]); useEffect(() => { const timeout = setTimeout( () => growProgress.set(1), index * staggerDelay * 1000 ); return () => clearTimeout(timeout); }, [growProgress, index, staggerDelay]); const rings = Array.from({ length: layers }, (_, l) => { const scale = 1 - (l / layers) * 0.35; const opacity = 0.18 + (l / (layers - 1 || 1)) * 0.65; return { d: vSegmentPath(normStart, normEnd, segH, fullW, scale, straight), opacity, }; }); return ( ); } // ─── Label Overlay ─────────────────────────────────────────────────────────── function SegmentLabel({ stage, pct, isHorizontal, showValues, showPercentage, showLabels, formatPercentage, formatValue, index, staggerDelay, layout = "spread", orientation, align = "center", }: { stage: FunnelStage; pct: number; isHorizontal: boolean; showValues: boolean; showPercentage: boolean; showLabels: boolean; formatPercentage: (p: number) => string; formatValue: (v: number) => string; index: number; staggerDelay: number; layout?: "spread" | "grouped"; orientation?: "vertical" | "horizontal"; align?: "center" | "start" | "end"; }) { const display = stage.displayValue ?? formatValue(stage.value); const valueEl = showValues && ( {display} ); const pctEl = showPercentage && ( {formatPercentage(pct)} ); const labelEl = showLabels && ( {stage.label} ); if (layout === "spread") { return ( {isHorizontal ? ( <>
{valueEl}
{pctEl}
{labelEl}
) : ( <>
{valueEl}
{pctEl}
{labelEl}
)}
); } // Grouped layout const resolvedOrientation = orientation ?? (isHorizontal ? "vertical" : "horizontal"); const isVerticalStack = resolvedOrientation === "vertical"; const justifyMap = { start: "justify-start", center: "justify-center", end: "justify-end", } as const; const itemsMap = { start: "items-start", center: "items-center", end: "items-end", } as const; return (
{valueEl} {pctEl} {labelEl}
); } // ─── FunnelChart ───────────────────────────────────────────────────────────── export function FunnelChart({ data, orientation = "horizontal", color = "var(--chart-1)", layers = 3, className, style, showPercentage = true, showValues = true, showLabels = true, hoveredIndex: hoveredIndexProp, onHoverChange, formatPercentage = fmtPct, formatValue = fmtVal, staggerDelay = 0.12, gap = 4, renderPattern, edges = "curved", labelLayout = "spread", labelOrientation, labelAlign = "center", grid: gridProp = false, }: FunnelChartProps) { const ref = useRef(null); const [sz, setSz] = useState({ w: 0, h: 0 }); const [internalHoveredIndex, setInternalHoveredIndex] = useState< number | null >(null); const isControlled = hoveredIndexProp !== undefined; const hoveredIndex = isControlled ? hoveredIndexProp : internalHoveredIndex; const setHoveredIndex = useCallback( (index: number | null) => { if (isControlled) { onHoverChange?.(index); } else { setInternalHoveredIndex(index); } }, [isControlled, onHoverChange] ); const measure = useCallback(() => { if (!ref.current) return; const { width: w, height: h } = ref.current.getBoundingClientRect(); if (w > 0 && h > 0) setSz({ w, h }); }, []); useEffect(() => { measure(); const ro = new ResizeObserver(measure); if (ref.current) ro.observe(ref.current); return () => ro.disconnect(); }, [measure]); if (!data.length) return null; const first = data[0]; if (!first) return null; const max = first.value; const n = data.length; const norms = data.map((d) => d.value / max); const horiz = orientation === "horizontal"; const { w: W, h: H } = sz; const totalGap = gap * (n - 1); const segW = (W - (horiz ? totalGap : 0)) / n; const segH = (H - (horiz ? 0 : totalGap)) / n; // Grid config const gridEnabled = gridProp !== false; const gridCfg = typeof gridProp === "object" ? gridProp : {}; const showBands = gridEnabled && (gridCfg.bands ?? true); const bandColor = gridCfg.bandColor ?? "var(--color-muted)"; const showGridLines = gridEnabled && (gridCfg.lines ?? true); const gridLineColor = gridCfg.lineColor ?? "var(--chart-grid)"; const gridLineOpacity = gridCfg.lineOpacity ?? 1; const gridLineWidth = gridCfg.lineWidth ?? 1; return (
{W > 0 && H > 0 && ( <> {/* Grid background bands */} {gridEnabled && ( )} {/* Segments */}
{data.map((stage, i) => { const normStart = norms[i] ?? 0; const normEnd = norms[Math.min(i + 1, n - 1)] ?? 0; const firstStop = stage.gradient?.[0]; const segColor = firstStop ? firstStop.color : (stage.color ?? color); return horiz ? ( ) : ( ); })}
{/* Grid lines */} {gridEnabled && showGridLines && ( )} {/* Label overlays — hover triggers */} {data.map((stage, i) => { const pct = (stage.value / max) * 100; const posStyle: CSSProperties = horiz ? { left: (segW + gap) * i, width: segW, top: 0, height: H } : { top: (segH + gap) * i, height: segH, left: 0, width: W }; const isDimmed = hoveredIndex !== null && hoveredIndex !== i; return ( setHoveredIndex(i)} onMouseLeave={() => setHoveredIndex(null)} style={{ ...posStyle, zIndex: 20 }} transition={{ type: "spring", stiffness: 300, damping: 24 }} > ); })} )}
); } FunnelChart.displayName = "FunnelChart"; export default FunnelChart;