"use client"; import { useEffect, useId, useRef, useState } from "react"; import "./flow-funnel-stats.css"; /** * Flow Funnel Stats * A funnel you can follow. Each source keeps its colour from the first * visit to the last booking, so the bands narrowing between stages are the * people who went on — and how much each one narrows is where they left. * Point at a source and its path lights end to end with its conversion; * point at a stage and the step into it is read out. */ export type Source = { name: string; values: number[] }; type Props = { stages: string[]; sources: Source[]; title?: string; theme?: "paper" | "night"; motion?: "full" | "reduced"; className?: string; }; const W = 760, H = 360; const TOP = 50, BOT = 16; const NODE = 14; // stage bar width const GAP = 3; // between source segments in a stage const fmt = (n: number) => Math.round(n).toLocaleString("en-US"); const pct = (a: number, b: number) => `${((a / b) * 100).toFixed(a / b < 0.1 ? 1 : 0)}%`; export function FlowFunnelStats({ stages, sources, title = "Conversion funnel", theme = "paper", motion = "full", className = "" }: Props) { const id = useId().replace(/:/g, ""); const root = useRef(null); const [on, setOn] = useState(false); const [hot, setHot] = useState(null); const [pinned, setPinned] = useState(null); const [stageHot, setStageHot] = useState(null); const focus = pinned ?? hot; useEffect(() => { const el = root.current; if (!el) return; const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setOn(true); io.disconnect(); } }, { threshold: 0.3 }); io.observe(el); return () => io.disconnect(); }, []); const S = stages.length; const totals = stages.map((_, s) => sources.reduce((a, src) => a + src.values[s], 0)); // One scale for every stage, so a band's width is always the same number of people. const k = (H - TOP - BOT - GAP * (sources.length - 1)) / totals[0]; const xs = stages.map((_, s) => 120 + (s * (W - 120 - 120)) / (S - 1)); // Each stage's stack is centred on the chart's middle, so the funnel narrows toward its axis. const mid = TOP + (H - TOP - BOT) / 2; const seg = stages.map((_, s) => { const hs = sources.map((src) => src.values[s] * k); const gap = s === 0 ? GAP * 2.5 : GAP; const total = hs.reduce((a, b) => a + b, 0) + gap * (sources.length - 1); let y = mid - total / 2; return hs.map((h) => { const r = { y, h }; y += h + gap; return r; }); }); const band = (s: number, i: number) => { const a = seg[s][i], b = seg[s + 1][i]; const x0 = xs[s] + NODE / 2, x1 = xs[s + 1] - NODE / 2, c = (x1 - x0) * 0.5; return `M${x0} ${a.y} C${x0 + c} ${a.y} ${x1 - c} ${b.y} ${x1} ${b.y} L${x1} ${b.y + b.h} C${x1 - c} ${b.y + b.h} ${x0 + c} ${a.y + a.h} ${x0} ${a.y + a.h} Z`; }; // End labels: at their segments when there's room, otherwise pushed apart (14px) and re-centred. const endY = (() => { const want = seg[S - 1].map((r) => r.y + r.h / 2); const out = want.slice(); for (let i = 1; i < out.length; i++) out[i] = Math.max(out[i], out[i - 1] + 14); const shift = (want.reduce((a, b) => a + b, 0) - out.reduce((a, b) => a + b, 0)) / out.length; return out.map((y) => y + shift); })(); const say = focus !== null ? `${sources[focus].name}: ${fmt(sources[focus].values[0])} ${stages[0].toLowerCase()} → ${fmt(sources[focus].values[S - 1])} ${stages[S - 1].toLowerCase()} · ${pct(sources[focus].values[S - 1], sources[focus].values[0])} convert` : stageHot !== null && stageHot > 0 ? `${stages[stageHot - 1]} → ${stages[stageHot]}: ${pct(totals[stageHot], totals[stageHot - 1])} carry on (${fmt(totals[stageHot])} of ${fmt(totals[stageHot - 1])})` : `${pct(totals[S - 1], totals[0])} of ${fmt(totals[0])} visits end in a booking`; return (

{fmt(totals[S - 1])} {stages[S - 1].toLowerCase()} from {fmt(totals[0])} visits

{say}

{sources.map((src, i) => ( ))}
{`${title}: ${stages.map((st, s) => `${fmt(totals[s])} ${st.toLowerCase()}`).join(", then ")}.`} {/* Bands are revealed left to right, stage by stage. */} {stages.map((st, s) => ( setStageHot(s)} onPointerLeave={() => setStageHot(null)}> {st} {fmt(totals[s])} ))} {stages.slice(0, -1).map((_, s) => sources.map((src, i) => ( )), )} {stages.map((st, s) => sources.map((src, i) => ( )), )} {/* Direct labels on the first column, values on the last. */} {sources.map((src, i) => ( {src.name} {fmt(src.values[0])} ))} {sources.map((src, i) => { const r = seg[S - 1][i]; return ( {Math.abs(endY[i] - (r.y + r.h / 2)) > 1 && ( )} {fmt(src.values[S - 1])} ); })}
{/* Tables ignore overflow, so the hidden table sits inside a hidden box. */}
{stages.map((st) => )} {sources.map((src) => ( {src.values.map((v, s) => )} ))}
{title}
Source{st}
{src.name}{fmt(v)}
); }