'use client' import { useCallback, 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, selections: Map, ): Column[] { const numCols = depth + 1 const columns: Column[] = [] // Build a filtered transitions set based on selections // For each column N with a selection, only keep transitions at step_index=N // where from_path matches the selection let filteredTransitions = transitions for (let col = 0; col < numCols - 1; col++) { const selected = selections.get(col) if (selected) { filteredTransitions = filteredTransitions.filter( (t) => t.step_index !== col || t.from_path === selected ) } } for (let col = 0; col < numCols; col++) { const pageMap = new Map() if (col === 0) { // Column 0: aggregate from_path across step_index=0 for (const t of filteredTransitions) { if (t.step_index === 0) { pageMap.set(t.from_path, (pageMap.get(t.from_path) ?? 0) + t.session_count) } } } else { // Column N: aggregate to_path across step_index=N-1 for (const t of filteredTransitions) { if (t.step_index === col - 1) { pageMap.set(t.to_path, (pageMap.get(t.to_path) ?? 0) + t.session_count) } } } // Sort descending by count let pages = Array.from(pageMap.entries()) .map(([path, sessionCount]) => ({ path, sessionCount })) .sort((a, b) => b.sessionCount - a.sessionCount) // Cap and merge into (other) 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 }) } 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 ─────────────────────────────────────────────── function ConnectionLines({ containerRef, selections, columns, transitions, }: { containerRef: React.RefObject selections: Map columns: Column[] transitions: PathTransition[] }) { const [lines, setLines] = useState<(LineDef & { color: string })[]>([]) const [dimensions, setDimensions] = useState({ width: 0, height: 0 }) useLayoutEffect(() => { const container = containerRef.current if (!container || selections.size === 0) { setLines([]) return } const containerRect = container.getBoundingClientRect() setDimensions({ width: container.scrollWidth, height: container.scrollHeight, }) const newLines: (LineDef & { color: string })[] = [] for (const [colIdx, selectedPath] of selections) { const nextCol = columns[colIdx + 1] if (!nextCol) continue // Find the source row element 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 // Find matching transitions const relevantTransitions = transitions.filter( (t) => t.step_index === colIdx && t.from_path === selectedPath ) const color = colorForColumn(colIdx) 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 maxCount = Math.max(...relevantTransitions.map((rt) => rt.session_count)) const weight = Math.max(1, Math.min(4, (t.session_count / maxCount) * 4)) newLines.push({ sourceY, destY, sourceX, destX, weight, color }) } } setLines(newLines) }, [selections, columns, transitions, containerRef]) if (lines.length === 0) return null return ( {lines.map((line, i) => { const midX = (line.sourceX + line.destX) / 2 return ( ) })} ) } // ─── Main Component ───────────────────────────────────────────────── export default function ColumnJourney({ transitions, totalSessions, depth, onNodeClick, }: ColumnJourneyProps) { const [selections, setSelections] = useState>(new Map()) 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, selections), [transitions, depth, selections] ) const handleSelect = useCallback( (colIndex: number, path: string) => { // Column 0 click → set entry path filter (API-level) if (colIndex === 0 && onNodeClick) { onNodeClick(path) return } setSelections((prev) => { const next = new Map(prev) // Toggle: click same page deselects if (next.get(colIndex) === path) { next.delete(colIndex) } else { next.set(colIndex, path) } // Clear all selections after this column 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)} /> ))}
) }