'use client' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { TreeStructure } from '@phosphor-icons/react' import type { PathTransition } from '@/lib/api/journeys' // ─── Types ────────────────────────────────────────────────────────── interface ColumnJourneyProps { transitions: PathTransition[] totalSessions: number depth: number onNodeClick?: (path: string) => void } interface ColumnPage { path: string sessionCount: number } interface Column { index: number totalSessions: number dropOffPercent: number pages: ColumnPage[] } interface LineDef { sourceY: number destY: number sourceX: number destX: number weight: number } // ─── Constants ────────────────────────────────────────────────────── const COLUMN_COLORS = [ '#FD5E0F', '#3B82F6', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4', '#EF4444', '#84CC16', '#F97316', '#6366F1', ] const MAX_NODES_PER_COLUMN = 10 function colorForColumn(col: number): string { return COLUMN_COLORS[col % COLUMN_COLORS.length] } // ─── Helpers ──────────────────────────────────────────────────────── function smartLabel(path: string): string { if (path === '/' || path === '(other)') return path const segments = path.replace(/\/$/, '').split('/') if (segments.length <= 2) return path return `…/${segments[segments.length - 1]}` } // ─── Data transformation ──────────────────────────────────────────── function buildColumns( transitions: PathTransition[], depth: number, ): Column[] { const numCols = depth + 1 const columns: Column[] = [] for (let col = 0; col < numCols; col++) { const pageMap = new Map() if (col === 0) { for (const t of transitions) { if (t.step_index === 0) { pageMap.set(t.from_path, (pageMap.get(t.from_path) ?? 0) + t.session_count) } } } else { for (const t of transitions) { if (t.step_index === col - 1) { pageMap.set(t.to_path, (pageMap.get(t.to_path) ?? 0) + t.session_count) } } } let pages = Array.from(pageMap.entries()) .map(([path, sessionCount]) => ({ path, sessionCount })) .sort((a, b) => b.sessionCount - a.sessionCount) if (pages.length > MAX_NODES_PER_COLUMN) { const kept = pages.slice(0, MAX_NODES_PER_COLUMN) const otherCount = pages .slice(MAX_NODES_PER_COLUMN) .reduce((sum, p) => sum + p.sessionCount, 0) kept.push({ path: '(other)', sessionCount: otherCount }) pages = kept } const totalSessions = pages.reduce((sum, p) => sum + p.sessionCount, 0) const prevTotal = col > 0 ? columns[col - 1].totalSessions : totalSessions const dropOffPercent = col === 0 || prevTotal === 0 ? 0 : Math.round(((totalSessions - prevTotal) / prevTotal) * 100) columns.push({ index: col, totalSessions, dropOffPercent, pages }) } // Trim empty trailing columns while (columns.length > 1 && columns[columns.length - 1].pages.length === 0) { columns.pop() } return columns } // ─── Sub-components ───────────────────────────────────────────────── function ColumnHeader({ column, color, }: { column: Column color: string }) { return (
{column.index + 1}
{column.totalSessions.toLocaleString()} visitors {column.dropOffPercent !== 0 && ( {column.dropOffPercent > 0 ? '+' : ''} {column.dropOffPercent}% )}
) } function PageRow({ page, colIndex, columnTotal, isSelected, isOther, onClick, }: { page: ColumnPage colIndex: number columnTotal: number isSelected: boolean isOther: boolean onClick: () => void }) { const pct = columnTotal > 0 ? Math.round((page.sessionCount / columnTotal) * 100) : 0 return ( ) } function JourneyColumn({ column, color, selectedPath, onSelect, }: { column: Column color: string selectedPath: string | undefined onSelect: (path: string) => void }) { if (column.pages.length === 0) { return (
No onward traffic
) } return (
{column.pages.map((page) => { const isOther = page.path === '(other)' return ( { if (!isOther) onSelect(page.path) }} /> ) })}
) } // ─── Connection Lines ─────────────────────────────────────────────── interface ExitLabel { x: number y: number count: number color: string } function ConnectionLines({ containerRef, selections, columns, transitions, }: { containerRef: React.RefObject selections: Map columns: Column[] transitions: PathTransition[] }) { const [lines, setLines] = useState<(LineDef & { color: string })[]>([]) const [exits, setExits] = useState([]) const [dimensions, setDimensions] = useState({ width: 0, height: 0 }) useLayoutEffect(() => { const container = containerRef.current if (!container || selections.size === 0) { setLines([]) setExits([]) return } const containerRect = container.getBoundingClientRect() setDimensions({ width: container.scrollWidth, height: container.scrollHeight, }) const newLines: (LineDef & { color: string })[] = [] const newExits: ExitLabel[] = [] for (const [colIdx, selectedPath] of selections) { const nextCol = columns[colIdx + 1] const sourceEl = container.querySelector( `[data-col="${colIdx}"][data-path="${CSS.escape(selectedPath)}"]` ) as HTMLElement | null if (!sourceEl) continue const sourceRect = sourceEl.getBoundingClientRect() const sourceY = sourceRect.top + sourceRect.height / 2 - containerRect.top + container.scrollTop const sourceX = sourceRect.right - containerRect.left + container.scrollLeft const relevantTransitions = transitions.filter( (t) => t.step_index === colIdx && t.from_path === selectedPath ) const color = colorForColumn(colIdx) // Find total sessions for this page const col = columns[colIdx] const page = col?.pages.find((p) => p.path === selectedPath) const pageCount = page?.sessionCount ?? 0 const outboundCount = relevantTransitions.reduce((sum, t) => sum + t.session_count, 0) const exitCount = pageCount - outboundCount if (nextCol) { const maxCount = Math.max( ...relevantTransitions.map((rt) => rt.session_count), exitCount > 0 ? exitCount : 0 ) for (const t of relevantTransitions) { const destEl = container.querySelector( `[data-col="${colIdx + 1}"][data-path="${CSS.escape(t.to_path)}"]` ) as HTMLElement | null if (!destEl) continue const destRect = destEl.getBoundingClientRect() const destY = destRect.top + destRect.height / 2 - containerRect.top + container.scrollTop const destX = destRect.left - containerRect.left + container.scrollLeft const weight = maxCount > 0 ? Math.max(1, Math.min(4, (t.session_count / maxCount) * 4)) : 1 newLines.push({ sourceY, destY, sourceX, destX, weight, color }) } } // Show exit if any visitors dropped off if (exitCount > 0) { // Position the exit label below the last destination or below the source const lastDestY = newLines.length > 0 ? Math.max(...newLines.filter((l) => l.sourceX === sourceX).map((l) => l.destY)) : sourceY const exitY = lastDestY + 30 const exitX = nextCol ? ((): number => { // Find the left edge of the next column const nextColEl = container.querySelector(`[data-col="${colIdx + 1}"]`) as HTMLElement | null if (nextColEl) { const nextRect = nextColEl.getBoundingClientRect() return nextRect.left - containerRect.left + container.scrollLeft } return sourceX + 100 })() : sourceX + 100 newLines.push({ sourceY, destY: exitY, sourceX, destX: exitX, weight: 1, color: '#52525b', // EXIT_GREY }) newExits.push({ x: exitX, y: exitY, count: exitCount, color: '#52525b' }) } } setLines(newLines) setExits(newExits) }, [selections, columns, transitions, containerRef]) if (lines.length === 0 && exits.length === 0) return null return ( {lines.map((line, i) => { const midX = (line.sourceX + line.destX) / 2 return ( ) })} {exits.map((exit, i) => ( (exit) {exit.count} ))} ) } // ─── Main Component ───────────────────────────────────────────────── export default function ColumnJourney({ transitions, totalSessions, depth, onNodeClick, }: ColumnJourneyProps) { const [selections, setSelections] = useState>(new Map()) const [canScrollRight, setCanScrollRight] = useState(false) const containerRef = useRef(null) // Clear selections when data changes const transitionsKey = useMemo( () => transitions.length + '-' + depth, [transitions.length, depth] ) const prevKeyRef = useRef(transitionsKey) if (prevKeyRef.current !== transitionsKey) { prevKeyRef.current = transitionsKey if (selections.size > 0) setSelections(new Map()) } const columns = useMemo( () => buildColumns(transitions, depth), [transitions, depth] ) // Check if there's scrollable content to the right useEffect(() => { const el = containerRef.current if (!el) return function check() { if (!el) return setCanScrollRight(el.scrollWidth - el.scrollLeft - el.clientWidth > 1) } check() el.addEventListener('scroll', check, { passive: true }) const ro = new ResizeObserver(check) ro.observe(el) return () => { el.removeEventListener('scroll', check) ro.disconnect() } }, [columns]) const handleSelect = useCallback( (colIndex: number, path: string) => { if (colIndex === 0 && onNodeClick) { onNodeClick(path) return } setSelections((prev) => { const next = new Map(prev) if (next.get(colIndex) === path) { next.delete(colIndex) } else { next.set(colIndex, path) } for (const key of Array.from(next.keys())) { if (key > colIndex) next.delete(key) } return next }) }, [onNodeClick] ) // ─── Empty state ──────────────────────────────────────────────── if (!transitions.length) { return (

No journey data yet

Navigation flows will appear here as visitors browse through your site.

) } return (
{columns.map((col) => ( handleSelect(col.index, path)} /> ))}
{/* Scroll fade indicator */} {canScrollRight && (
)}
) }