Rewrite FunnelChart as proper SVG funnel with curved sides
Replaces crude clip-path divs with SVG bezier paths matching the 21st.dev reference: smooth curved sides, background glow, divider lines, centered percentage pills, and labels on both sides. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { cn, formatNumber } from '@ciphera-net/ui'
|
import { cn, formatNumber } from '@ciphera-net/ui'
|
||||||
|
|
||||||
@@ -17,105 +17,160 @@ interface FunnelChartProps {
|
|||||||
export default function FunnelChart({ steps, className }: FunnelChartProps) {
|
export default function FunnelChart({ steps, className }: FunnelChartProps) {
|
||||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
if (!steps.length) return null
|
const maxVisitors = steps[0]?.visitors ?? 0
|
||||||
|
|
||||||
const maxVisitors = steps[0].visitors
|
|
||||||
const n = steps.length
|
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 (
|
return (
|
||||||
<div className={cn('flex gap-6', className)}>
|
<div className={cn('w-full', className)}>
|
||||||
{/* Left labels */}
|
<svg
|
||||||
<div className="hidden md:flex flex-col w-40 shrink-0">
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
{steps.map((step, i) => (
|
className="w-full h-full font-sans"
|
||||||
<div
|
preserveAspectRatio="xMidYMid meet"
|
||||||
key={i}
|
>
|
||||||
className={cn(
|
{/* Background glow */}
|
||||||
'flex-1 flex items-center justify-end transition-opacity duration-200',
|
<path d={glowPath} fill="rgba(253, 94, 15, 0.07)" />
|
||||||
hoveredIndex !== null && hoveredIndex !== i && 'opacity-30',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="text-right">
|
|
||||||
<span className="text-[11px] font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider">
|
|
||||||
Step {i + 1}
|
|
||||||
</span>
|
|
||||||
<p className="text-sm font-medium text-neutral-900 dark:text-white truncate max-w-[152px]">
|
|
||||||
{step.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Funnel segments */}
|
{/* Segments */}
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
{steps.map((_, i) => {
|
||||||
{steps.map((step, i) => {
|
const opacity = Math.max(0.45, 1 - i * (0.45 / n))
|
||||||
const topPct = maxVisitors > 0
|
|
||||||
? Math.max(30, (step.visitors / maxVisitors) * 100)
|
|
||||||
: 100
|
|
||||||
const bottomPct = steps[i + 1]
|
|
||||||
? Math.max(30, (steps[i + 1].visitors / maxVisitors) * 100)
|
|
||||||
: topPct * 0.7
|
|
||||||
const topInset = (100 - topPct) / 2
|
|
||||||
const bottomInset = (100 - bottomPct) / 2
|
|
||||||
const opacity = Math.max(0.3, 1 - i * (0.55 / n))
|
|
||||||
const isHovered = hoveredIndex === i
|
const isHovered = hoveredIndex === i
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.path
|
||||||
key={i}
|
key={i}
|
||||||
className="flex-1 relative cursor-default"
|
d={segPath(i)}
|
||||||
style={{
|
fill={`rgba(253, 94, 15, ${isHovered ? Math.min(1, opacity + 0.12) : opacity})`}
|
||||||
backgroundColor: `rgba(253, 94, 15, ${isHovered ? Math.min(1, opacity + 0.15) : opacity})`,
|
style={{ transition: 'fill 0.2s' }}
|
||||||
transition: 'background-color 0.2s',
|
cursor="pointer"
|
||||||
}}
|
initial={{ opacity: 0 }}
|
||||||
initial={{
|
animate={{ opacity: 1 }}
|
||||||
clipPath: 'polygon(50% 0%, 50% 0%, 50% 100%, 50% 100%)',
|
transition={{ delay: i * 0.08, duration: 0.5, ease: 'easeOut' }}
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
clipPath: `polygon(${topInset}% 0%, ${100 - topInset}% 0%, ${100 - bottomInset}% 100%, ${bottomInset}% 100%)`,
|
|
||||||
opacity: 1,
|
|
||||||
}}
|
|
||||||
transition={{ delay: i * 0.1, duration: 0.6, ease: [0.25, 0.1, 0.25, 1] }}
|
|
||||||
onMouseEnter={() => setHoveredIndex(i)}
|
onMouseEnter={() => setHoveredIndex(i)}
|
||||||
onMouseLeave={() => setHoveredIndex(null)}
|
onMouseLeave={() => setHoveredIndex(null)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right stats */}
|
{/* Divider lines */}
|
||||||
<div className="flex flex-col w-28 sm:w-32 shrink-0">
|
{bounds.slice(1, -1).map((b, i) => (
|
||||||
{steps.map((step, i) => (
|
<line
|
||||||
<div
|
key={`div-${i}`}
|
||||||
key={i}
|
x1={cx - b.hw}
|
||||||
className={cn(
|
y1={b.y}
|
||||||
'flex-1 flex items-center transition-opacity duration-200',
|
x2={cx + b.hw}
|
||||||
hoveredIndex !== null && hoveredIndex !== i && 'opacity-30',
|
y2={b.y}
|
||||||
)}
|
stroke="rgba(255,255,255,0.3)"
|
||||||
>
|
strokeWidth="1.5"
|
||||||
<div>
|
/>
|
||||||
<p className="text-sm font-bold text-brand-orange">
|
|
||||||
{formatNumber(step.visitors)}
|
|
||||||
</p>
|
|
||||||
{i > 0 ? (
|
|
||||||
<div className="flex items-center gap-1.5 text-[11px]">
|
|
||||||
<span className="text-red-500 dark:text-red-400 font-medium">
|
|
||||||
↓{Math.round(step.dropoff)}%
|
|
||||||
</span>
|
|
||||||
<span className="text-green-600 dark:text-green-400 font-medium">
|
|
||||||
{Math.round(step.conversion)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-[11px] text-neutral-400 dark:text-neutral-500">
|
|
||||||
baseline
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user