Replace custom FunnelChart with 21st.dev funnel-chart component
Drops in the exact 21st.dev FunnelChart component with motion/react, curved bezier segments, layered rings, and spring hover animations. Removes the previous custom SVG implementation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats
|
|||||||
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
||||||
import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons'
|
import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import FunnelChart from '@/components/dashboard/FunnelChart'
|
import { FunnelChart } from '@/components/ui/funnel-chart'
|
||||||
import { getDateRange } from '@ciphera-net/ui'
|
import { getDateRange } from '@ciphera-net/ui'
|
||||||
|
|
||||||
export default function FunnelReportPage() {
|
export default function FunnelReportPage() {
|
||||||
@@ -108,10 +108,8 @@ export default function FunnelReportPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chartData = stats.steps.map(s => ({
|
const chartData = stats.steps.map(s => ({
|
||||||
name: s.step.name,
|
label: s.step.name,
|
||||||
visitors: s.visitors,
|
value: s.visitors,
|
||||||
dropoff: s.dropoff,
|
|
||||||
conversion: s.conversion
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -173,7 +171,12 @@ export default function FunnelReportPage() {
|
|||||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-6">
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-6">
|
||||||
Funnel Visualization
|
Funnel Visualization
|
||||||
</h3>
|
</h3>
|
||||||
<FunnelChart steps={chartData} className="h-[350px]" />
|
<FunnelChart
|
||||||
|
data={chartData}
|
||||||
|
orientation="vertical"
|
||||||
|
color="var(--chart-1)"
|
||||||
|
layers={3}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detailed Stats Table */}
|
{/* Detailed Stats Table */}
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
|
||||||
import { motion } from 'framer-motion'
|
|
||||||
import { cn, formatNumber } from '@ciphera-net/ui'
|
|
||||||
|
|
||||||
interface FunnelChartProps {
|
|
||||||
steps: Array<{
|
|
||||||
name: string
|
|
||||||
visitors: number
|
|
||||||
dropoff: number
|
|
||||||
conversion: number
|
|
||||||
}>
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FunnelChart({ steps, className }: FunnelChartProps) {
|
|
||||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
|
||||||
|
|
||||||
const maxVisitors = steps[0]?.visitors ?? 0
|
|
||||||
const n = steps.length
|
|
||||||
|
|
||||||
if (!n || maxVisitors === 0) return null
|
|
||||||
|
|
||||||
// SVG layout
|
|
||||||
const W = 800
|
|
||||||
const H = 400
|
|
||||||
const cx = W / 2
|
|
||||||
const maxHW = W * 0.28
|
|
||||||
const minHW = 28
|
|
||||||
const segH = H / n
|
|
||||||
const k = 0.5 // bezier tension
|
|
||||||
|
|
||||||
// n+1 boundary points: top of each step + bottom of last
|
|
||||||
const bounds = useMemo(() => {
|
|
||||||
return Array.from({ length: n + 1 }, (_, i) => {
|
|
||||||
const y = i * segH
|
|
||||||
const visitors = i < n ? steps[i].visitors : steps[n - 1].visitors * 0.5
|
|
||||||
const hw = Math.max(minHW, (visitors / maxVisitors) * maxHW)
|
|
||||||
return { y, hw }
|
|
||||||
})
|
|
||||||
}, [steps, n, maxVisitors, segH])
|
|
||||||
|
|
||||||
// Curved path for one segment
|
|
||||||
const segPath = (i: number) => {
|
|
||||||
const t = bounds[i], b = bounds[i + 1]
|
|
||||||
const dy = b.y - t.y
|
|
||||||
return [
|
|
||||||
`M${cx - t.hw},${t.y}`,
|
|
||||||
`L${cx + t.hw},${t.y}`,
|
|
||||||
`C${cx + t.hw},${t.y + dy * k} ${cx + b.hw},${b.y - dy * k} ${cx + b.hw},${b.y}`,
|
|
||||||
`L${cx - b.hw},${b.y}`,
|
|
||||||
`C${cx - b.hw},${b.y - dy * k} ${cx - t.hw},${t.y + dy * k} ${cx - t.hw},${t.y}`,
|
|
||||||
'Z',
|
|
||||||
].join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full outline for background glow
|
|
||||||
const glowPath = useMemo(() => {
|
|
||||||
let d = `M${cx - bounds[0].hw},${bounds[0].y} L${cx + bounds[0].hw},${bounds[0].y}`
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const t = bounds[i], b = bounds[i + 1], dy = b.y - t.y
|
|
||||||
d += ` C${cx + t.hw},${t.y + dy * k} ${cx + b.hw},${b.y - dy * k} ${cx + b.hw},${b.y}`
|
|
||||||
}
|
|
||||||
d += ` L${cx - bounds[n].hw},${bounds[n].y}`
|
|
||||||
for (let i = n - 1; i >= 0; i--) {
|
|
||||||
const t = bounds[i], b = bounds[i + 1], dy = b.y - t.y
|
|
||||||
d += ` C${cx - b.hw},${b.y - dy * k} ${cx - t.hw},${t.y + dy * k} ${cx - t.hw},${t.y}`
|
|
||||||
}
|
|
||||||
return d + ' Z'
|
|
||||||
}, [bounds, n])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('w-full', className)}>
|
|
||||||
<svg
|
|
||||||
viewBox={`0 0 ${W} ${H}`}
|
|
||||||
className="w-full h-full font-sans"
|
|
||||||
preserveAspectRatio="xMidYMid meet"
|
|
||||||
>
|
|
||||||
{/* Background glow */}
|
|
||||||
<path d={glowPath} fill="rgba(253, 94, 15, 0.07)" />
|
|
||||||
|
|
||||||
{/* Segments */}
|
|
||||||
{steps.map((_, i) => {
|
|
||||||
const opacity = Math.max(0.45, 1 - i * (0.45 / n))
|
|
||||||
const isHovered = hoveredIndex === i
|
|
||||||
return (
|
|
||||||
<motion.path
|
|
||||||
key={i}
|
|
||||||
d={segPath(i)}
|
|
||||||
fill={`rgba(253, 94, 15, ${isHovered ? Math.min(1, opacity + 0.12) : opacity})`}
|
|
||||||
style={{ transition: 'fill 0.2s' }}
|
|
||||||
cursor="pointer"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: i * 0.08, duration: 0.5, ease: 'easeOut' }}
|
|
||||||
onMouseEnter={() => setHoveredIndex(i)}
|
|
||||||
onMouseLeave={() => setHoveredIndex(null)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Divider lines */}
|
|
||||||
{bounds.slice(1, -1).map((b, i) => (
|
|
||||||
<line
|
|
||||||
key={`div-${i}`}
|
|
||||||
x1={cx - b.hw}
|
|
||||||
y1={b.y}
|
|
||||||
x2={cx + b.hw}
|
|
||||||
y2={b.y}
|
|
||||||
stroke="rgba(255,255,255,0.3)"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Labels */}
|
|
||||||
{steps.map((step, i) => {
|
|
||||||
const midY = bounds[i].y + segH / 2
|
|
||||||
const dimmed = hoveredIndex !== null && hoveredIndex !== i
|
|
||||||
return (
|
|
||||||
<g
|
|
||||||
key={`lbl-${i}`}
|
|
||||||
style={{ opacity: dimmed ? 0.3 : 1, transition: 'opacity 0.2s' }}
|
|
||||||
>
|
|
||||||
{/* Visitor count — left */}
|
|
||||||
<text
|
|
||||||
x={cx - bounds[i].hw - 20}
|
|
||||||
y={midY}
|
|
||||||
textAnchor="end"
|
|
||||||
dominantBaseline="central"
|
|
||||||
fill="#FD5E0F"
|
|
||||||
fontSize="16"
|
|
||||||
fontWeight="700"
|
|
||||||
>
|
|
||||||
{formatNumber(step.visitors)}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{/* Percentage pill — center */}
|
|
||||||
<rect
|
|
||||||
x={cx - 23}
|
|
||||||
y={midY - 12}
|
|
||||||
width={46}
|
|
||||||
height={24}
|
|
||||||
rx={12}
|
|
||||||
fill="rgba(255,255,255,0.2)"
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={cx}
|
|
||||||
y={midY}
|
|
||||||
textAnchor="middle"
|
|
||||||
dominantBaseline="central"
|
|
||||||
fill="white"
|
|
||||||
fontSize="12"
|
|
||||||
fontWeight="600"
|
|
||||||
>
|
|
||||||
{Math.round(step.conversion)}%
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{/* Step name — right */}
|
|
||||||
<text
|
|
||||||
x={cx + bounds[i].hw + 20}
|
|
||||||
y={midY}
|
|
||||||
textAnchor="start"
|
|
||||||
dominantBaseline="central"
|
|
||||||
className="fill-neutral-500 dark:fill-neutral-400"
|
|
||||||
fontSize="14"
|
|
||||||
>
|
|
||||||
{step.name}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
935
components/ui/funnel-chart.tsx
Normal file
935
components/ui/funnel-chart.tsx
Normal file
@@ -0,0 +1,935 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion, useSpring, useTransform } from "motion/react";
|
||||||
|
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 (
|
||||||
|
<pattern
|
||||||
|
id={id}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
{background && (
|
||||||
|
<rect width={width} height={height} fill={background} />
|
||||||
|
)}
|
||||||
|
<path
|
||||||
|
d={paths.join(" ")}
|
||||||
|
fill="none"
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="square"
|
||||||
|
/>
|
||||||
|
</pattern>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<motion.path
|
||||||
|
d={d}
|
||||||
|
fill={fill ?? color}
|
||||||
|
opacity={opacity}
|
||||||
|
style={{ scaleY, transformOrigin: "center center" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<motion.path
|
||||||
|
d={d}
|
||||||
|
fill={fill ?? color}
|
||||||
|
opacity={opacity}
|
||||||
|
style={{ scaleX, transformOrigin: "center center" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<motion.div
|
||||||
|
className="pointer-events-none relative shrink-0 overflow-visible"
|
||||||
|
style={{
|
||||||
|
width: segW,
|
||||||
|
height: fullH,
|
||||||
|
zIndex: hovered ? 10 : 1,
|
||||||
|
opacity: dimOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 overflow-visible"
|
||||||
|
style={{
|
||||||
|
scaleX: entranceScaleX,
|
||||||
|
scaleY: entranceScaleY,
|
||||||
|
transformOrigin: "left center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 h-full w-full overflow-visible"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
role="presentation"
|
||||||
|
viewBox={`0 0 ${segW} ${fullH}`}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
{gradientStops && (
|
||||||
|
<linearGradient id={gradientId} x1="0" x2="1" y1="0" y2="0">
|
||||||
|
{gradientStops.map((stop) => (
|
||||||
|
<stop
|
||||||
|
key={`${stop.offset}-${stop.color}`}
|
||||||
|
offset={
|
||||||
|
typeof stop.offset === "number"
|
||||||
|
? `${stop.offset * 100}%`
|
||||||
|
: stop.offset
|
||||||
|
}
|
||||||
|
stopColor={stop.color}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</linearGradient>
|
||||||
|
)}
|
||||||
|
{renderPattern?.(patternId, color)}
|
||||||
|
</defs>
|
||||||
|
{rings.map((r, i) => {
|
||||||
|
const isInnermost = i === rings.length - 1;
|
||||||
|
let ringFill: string | undefined;
|
||||||
|
if (isInnermost && renderPattern) {
|
||||||
|
ringFill = `url(#${patternId})`;
|
||||||
|
} else if (isInnermost && gradientStops) {
|
||||||
|
ringFill = `url(#${gradientId})`;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<HRing
|
||||||
|
color={color}
|
||||||
|
d={r.d}
|
||||||
|
fill={ringFill}
|
||||||
|
hovered={hovered}
|
||||||
|
key={`h-ring-${r.opacity.toFixed(2)}`}
|
||||||
|
opacity={r.opacity}
|
||||||
|
ringIndex={i}
|
||||||
|
totalRings={layers}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<motion.div
|
||||||
|
className="pointer-events-none relative shrink-0 overflow-visible"
|
||||||
|
style={{
|
||||||
|
width: fullW,
|
||||||
|
height: segH,
|
||||||
|
zIndex: hovered ? 10 : 1,
|
||||||
|
opacity: dimOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 overflow-visible"
|
||||||
|
style={{
|
||||||
|
scaleY: entranceScaleY,
|
||||||
|
scaleX: entranceScaleX,
|
||||||
|
transformOrigin: "center top",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 h-full w-full overflow-visible"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
role="presentation"
|
||||||
|
viewBox={`0 0 ${fullW} ${segH}`}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
{gradientStops && (
|
||||||
|
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
|
||||||
|
{gradientStops.map((stop) => (
|
||||||
|
<stop
|
||||||
|
key={`${stop.offset}-${stop.color}`}
|
||||||
|
offset={
|
||||||
|
typeof stop.offset === "number"
|
||||||
|
? `${stop.offset * 100}%`
|
||||||
|
: stop.offset
|
||||||
|
}
|
||||||
|
stopColor={stop.color}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</linearGradient>
|
||||||
|
)}
|
||||||
|
{renderPattern?.(patternId, color)}
|
||||||
|
</defs>
|
||||||
|
{rings.map((r, i) => {
|
||||||
|
const isInnermost = i === rings.length - 1;
|
||||||
|
let ringFill: string | undefined;
|
||||||
|
if (isInnermost && renderPattern) {
|
||||||
|
ringFill = `url(#${patternId})`;
|
||||||
|
} else if (isInnermost && gradientStops) {
|
||||||
|
ringFill = `url(#${gradientId})`;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<VRing
|
||||||
|
color={color}
|
||||||
|
d={r.d}
|
||||||
|
fill={ringFill}
|
||||||
|
hovered={hovered}
|
||||||
|
key={`v-ring-${r.opacity.toFixed(2)}`}
|
||||||
|
opacity={r.opacity}
|
||||||
|
ringIndex={i}
|
||||||
|
totalRings={layers}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 && (
|
||||||
|
<span className="whitespace-nowrap font-semibold text-foreground text-sm">
|
||||||
|
{display}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const pctEl = showPercentage && (
|
||||||
|
<span className="rounded-full bg-foreground px-3 py-1 font-bold text-background text-xs shadow-sm">
|
||||||
|
{formatPercentage(pct)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const labelEl = showLabels && (
|
||||||
|
<span className="whitespace-nowrap font-medium text-muted-foreground text-xs">
|
||||||
|
{stage.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (layout === "spread") {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 flex",
|
||||||
|
isHorizontal ? "flex-col items-center" : "flex-row items-center"
|
||||||
|
)}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
transition={{
|
||||||
|
delay: index * staggerDelay + 0.25,
|
||||||
|
duration: 0.35,
|
||||||
|
ease: "easeOut",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isHorizontal ? (
|
||||||
|
<>
|
||||||
|
<div className="flex h-[16%] items-end justify-center pb-1">
|
||||||
|
{valueEl}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
{pctEl}
|
||||||
|
</div>
|
||||||
|
<div className="flex h-[16%] items-start justify-center pt-1">
|
||||||
|
{labelEl}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex w-[16%] items-center justify-end pr-2">
|
||||||
|
{valueEl}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
{pctEl}
|
||||||
|
</div>
|
||||||
|
<div className="flex w-[16%] items-center justify-start pl-2">
|
||||||
|
{labelEl}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<motion.div
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 flex",
|
||||||
|
isHorizontal
|
||||||
|
? cn("flex-col items-center", justifyMap[align])
|
||||||
|
: cn("flex-row items-center", justifyMap[align])
|
||||||
|
)}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
style={{
|
||||||
|
padding: isHorizontal ? "8% 0" : "0 8%",
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
delay: index * staggerDelay + 0.25,
|
||||||
|
duration: 0.35,
|
||||||
|
ease: "easeOut",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex gap-1.5",
|
||||||
|
isVerticalStack
|
||||||
|
? cn("flex-col", itemsMap[isHorizontal ? "center" : align])
|
||||||
|
: cn("flex-row", itemsMap.center)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{valueEl}
|
||||||
|
{pctEl}
|
||||||
|
{labelEl}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
className={cn("relative w-full select-none overflow-visible", className)}
|
||||||
|
ref={ref}
|
||||||
|
style={{
|
||||||
|
aspectRatio: horiz ? "2.2 / 1" : "1 / 1.8",
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{W > 0 && H > 0 && (
|
||||||
|
<>
|
||||||
|
{/* Grid background bands */}
|
||||||
|
{gridEnabled && (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
role="presentation"
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
>
|
||||||
|
{showBands &&
|
||||||
|
data.map((stage, i) => {
|
||||||
|
if (i % 2 !== 0) return null;
|
||||||
|
if (horiz) {
|
||||||
|
const x = (segW + gap) * i;
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
fill={bandColor}
|
||||||
|
height={H}
|
||||||
|
key={`band-${stage.label}`}
|
||||||
|
width={segW}
|
||||||
|
x={x}
|
||||||
|
y={0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const y = (segH + gap) * i;
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
fill={bandColor}
|
||||||
|
height={segH}
|
||||||
|
key={`band-${stage.label}`}
|
||||||
|
width={W}
|
||||||
|
x={0}
|
||||||
|
y={y}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Segments */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 flex overflow-visible",
|
||||||
|
horiz ? "flex-row" : "flex-col"
|
||||||
|
)}
|
||||||
|
style={{ gap }}
|
||||||
|
>
|
||||||
|
{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 ? (
|
||||||
|
<HSegment
|
||||||
|
color={segColor}
|
||||||
|
dimmed={hoveredIndex !== null && hoveredIndex !== i}
|
||||||
|
fullH={H}
|
||||||
|
gradientStops={stage.gradient}
|
||||||
|
hovered={hoveredIndex === i}
|
||||||
|
index={i}
|
||||||
|
key={stage.label}
|
||||||
|
layers={layers}
|
||||||
|
normEnd={normEnd}
|
||||||
|
normStart={normStart}
|
||||||
|
renderPattern={renderPattern}
|
||||||
|
segW={segW}
|
||||||
|
staggerDelay={staggerDelay}
|
||||||
|
straight={edges === "straight"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<VSegment
|
||||||
|
color={segColor}
|
||||||
|
dimmed={hoveredIndex !== null && hoveredIndex !== i}
|
||||||
|
fullW={W}
|
||||||
|
gradientStops={stage.gradient}
|
||||||
|
hovered={hoveredIndex === i}
|
||||||
|
index={i}
|
||||||
|
key={stage.label}
|
||||||
|
layers={layers}
|
||||||
|
normEnd={normEnd}
|
||||||
|
normStart={normStart}
|
||||||
|
renderPattern={renderPattern}
|
||||||
|
segH={segH}
|
||||||
|
staggerDelay={staggerDelay}
|
||||||
|
straight={edges === "straight"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid lines */}
|
||||||
|
{gridEnabled && showGridLines && (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
role="presentation"
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
>
|
||||||
|
{Array.from({ length: n - 1 }, (_, i) => {
|
||||||
|
const idx = i + 1;
|
||||||
|
if (horiz) {
|
||||||
|
const x = segW * idx + gap * i + gap / 2;
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={`grid-${idx}`}
|
||||||
|
stroke={gridLineColor}
|
||||||
|
strokeOpacity={gridLineOpacity}
|
||||||
|
strokeWidth={gridLineWidth}
|
||||||
|
x1={x}
|
||||||
|
x2={x}
|
||||||
|
y1={0}
|
||||||
|
y2={H}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const y = segH * idx + gap * i + gap / 2;
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={`grid-${idx}`}
|
||||||
|
stroke={gridLineColor}
|
||||||
|
strokeOpacity={gridLineOpacity}
|
||||||
|
strokeWidth={gridLineWidth}
|
||||||
|
x1={0}
|
||||||
|
x2={W}
|
||||||
|
y1={y}
|
||||||
|
y2={y}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<motion.div
|
||||||
|
animate={{ opacity: isDimmed ? 0.4 : 1 }}
|
||||||
|
className="absolute cursor-pointer"
|
||||||
|
key={`lbl-${stage.label}`}
|
||||||
|
onMouseEnter={() => setHoveredIndex(i)}
|
||||||
|
onMouseLeave={() => setHoveredIndex(null)}
|
||||||
|
style={{ ...posStyle, zIndex: 20 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300, damping: 24 }}
|
||||||
|
>
|
||||||
|
<SegmentLabel
|
||||||
|
align={labelAlign}
|
||||||
|
formatPercentage={formatPercentage}
|
||||||
|
formatValue={formatValue}
|
||||||
|
index={i}
|
||||||
|
isHorizontal={horiz}
|
||||||
|
layout={labelLayout}
|
||||||
|
orientation={labelOrientation}
|
||||||
|
pct={pct}
|
||||||
|
showLabels={showLabels}
|
||||||
|
showPercentage={showPercentage}
|
||||||
|
showValues={showValues}
|
||||||
|
stage={stage}
|
||||||
|
staggerDelay={staggerDelay}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
FunnelChart.displayName = "FunnelChart";
|
||||||
|
|
||||||
|
export default FunnelChart;
|
||||||
41
package-lock.json
generated
41
package-lock.json
generated
@@ -24,6 +24,7 @@
|
|||||||
"iso-3166-2": "^1.0.0",
|
"iso-3166-2": "^1.0.0",
|
||||||
"jspdf": "^4.0.0",
|
"jspdf": "^4.0.0",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
|
"motion": "^12.35.2",
|
||||||
"next": "^16.1.1",
|
"next": "^16.1.1",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
@@ -9277,12 +9278,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/framer-motion": {
|
"node_modules/framer-motion": {
|
||||||
"version": "12.34.1",
|
"version": "12.35.2",
|
||||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.1.tgz",
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.35.2.tgz",
|
||||||
"integrity": "sha512-kcZyNaYQfvE2LlH6+AyOaJAQV4rGp5XbzfhsZpiSZcwDMfZUHhuxLWeyRzf5I7jip3qKRpuimPA9pXXfr111kQ==",
|
"integrity": "sha512-dhfuEMaNo0hc+AEqyHiIfiJRNb9U9UQutE9FoKm5pjf7CMitp9xPEF1iWZihR1q86LBmo6EJ7S8cN8QXEy49AA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"motion-dom": "^12.34.1",
|
"motion-dom": "^12.35.2",
|
||||||
"motion-utils": "^12.29.2",
|
"motion-utils": "^12.29.2",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
},
|
},
|
||||||
@@ -11566,10 +11567,36 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/motion": {
|
||||||
|
"version": "12.35.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion/-/motion-12.35.2.tgz",
|
||||||
|
"integrity": "sha512-8zCi1DkNyU6a/tgEHn/GnnXZDcaMpDHbDOGORY1Rg/6lcNMSOuvwDB3i4hMSOvxqMWArc/vrGaw/Xek1OP69/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"framer-motion": "^12.35.2",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/is-prop-valid": "*",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/is-prop-valid": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/motion-dom": {
|
"node_modules/motion-dom": {
|
||||||
"version": "12.34.1",
|
"version": "12.35.2",
|
||||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.1.tgz",
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.35.2.tgz",
|
||||||
"integrity": "sha512-SC7ZC5dRcGwku2g7EsPvI4q/EzHumUbqsDNumBmZTLFg+goBO5LTJvDu9MAxx+0mtX4IA78B2be/A3aRjY0jnw==",
|
"integrity": "sha512-pWXFMTwvGDbx1Fe9YL5HZebv2NhvGBzRtiNUv58aoK7+XrsuaydQ0JGRKK2r+bTKlwgSWwWxHbP5249Qr/BNpg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"motion-utils": "^12.29.2"
|
"motion-utils": "^12.29.2"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"iso-3166-2": "^1.0.0",
|
"iso-3166-2": "^1.0.0",
|
||||||
"jspdf": "^4.0.0",
|
"jspdf": "^4.0.0",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
|
"motion": "^12.35.2",
|
||||||
"next": "^16.1.1",
|
"next": "^16.1.1",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
|
|||||||
Reference in New Issue
Block a user