'use client' import { useCallback, useEffect, useMemo, useState } from 'react' import { ResponsiveSankey } from '@nivo/sankey' import { TreeStructure, X } from '@phosphor-icons/react' import type { PathTransition } from '@/lib/api/journeys' // ─── Types ────────────────────────────────────────────────────────── interface SankeyJourneyProps { transitions: PathTransition[] totalSessions: number depth: number } interface SankeyNode { id: string stepIndex: number } interface SankeyLink { source: string target: string value: number } interface SankeyData { nodes: SankeyNode[] links: SankeyLink[] } // ─── Constants ────────────────────────────────────────────────────── const COLUMN_COLORS = [ '#FD5E0F', '#3B82F6', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4', '#EF4444', '#84CC16', '#F97316', '#6366F1', ] const MAX_NODES_PER_STEP = 15 // ─── 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]}` } /** Extract the original path from a step-prefixed node id like "0:/blog" */ function pathFromId(id: string): string { const idx = id.indexOf(':') return idx >= 0 ? id.slice(idx + 1) : id } /** Extract the step index from a step-prefixed node id */ function stepFromId(id: string): number { const idx = id.indexOf(':') return idx >= 0 ? parseInt(id.slice(0, idx), 10) : 0 } // ─── Data Transformation ──────────────────────────────────────────── function buildSankeyData( transitions: PathTransition[], filterPath?: string, ): SankeyData { if (transitions.length === 0) return { nodes: [], links: [] } // Group transitions by step and count sessions per path at each step const stepPaths = new Map>() for (const t of transitions) { // from_path at step_index if (!stepPaths.has(t.step_index)) stepPaths.set(t.step_index, new Map()) const fromMap = stepPaths.get(t.step_index)! fromMap.set(t.from_path, (fromMap.get(t.from_path) ?? 0) + t.session_count) // to_path at step_index + 1 const nextStep = t.step_index + 1 if (!stepPaths.has(nextStep)) stepPaths.set(nextStep, new Map()) const toMap = stepPaths.get(nextStep)! toMap.set(t.to_path, (toMap.get(t.to_path) ?? 0) + t.session_count) } // For each step, keep top N paths, group rest into (other) const topPathsPerStep = new Map>() for (const [step, pathMap] of stepPaths) { const sorted = Array.from(pathMap.entries()).sort((a, b) => b[1] - a[1]) const kept = new Set(sorted.slice(0, MAX_NODES_PER_STEP).map(([p]) => p)) topPathsPerStep.set(step, kept) } // Build links with capping const linkMap = new Map() for (const t of transitions) { const fromStep = t.step_index const toStep = t.step_index + 1 const fromTop = topPathsPerStep.get(fromStep)! const toTop = topPathsPerStep.get(toStep)! const fromPath = fromTop.has(t.from_path) ? t.from_path : '(other)' const toPath = toTop.has(t.to_path) ? t.to_path : '(other)' // Skip self-links where both collapse to (other) if (fromPath === '(other)' && toPath === '(other)') continue const sourceId = `${fromStep}:${fromPath}` const targetId = `${toStep}:${toPath}` const key = `${sourceId}|${targetId}` linkMap.set(key, (linkMap.get(key) ?? 0) + t.session_count) } let links: SankeyLink[] = Array.from(linkMap.entries()).map(([key, value]) => { const [source, target] = key.split('|') return { source, target, value } }) // Collect all node ids referenced by links const nodeIdSet = new Set() for (const link of links) { nodeIdSet.add(link.source) nodeIdSet.add(link.target) } let nodes: SankeyNode[] = Array.from(nodeIdSet).map((id) => ({ id, stepIndex: stepFromId(id), })) // ─── Filter by path (BFS forward + backward) ──────────────────── if (filterPath) { const matchingNodeIds = nodes .filter((n) => pathFromId(n.id) === filterPath) .map((n) => n.id) if (matchingNodeIds.length === 0) return { nodes: [], links: [] } // Build adjacency const forwardAdj = new Map>() const backwardAdj = new Map>() for (const link of links) { if (!forwardAdj.has(link.source)) forwardAdj.set(link.source, new Set()) forwardAdj.get(link.source)!.add(link.target) if (!backwardAdj.has(link.target)) backwardAdj.set(link.target, new Set()) backwardAdj.get(link.target)!.add(link.source) } const reachable = new Set(matchingNodeIds) // BFS forward let queue = [...matchingNodeIds] while (queue.length > 0) { const next: string[] = [] for (const nodeId of queue) { for (const neighbor of forwardAdj.get(nodeId) ?? []) { if (!reachable.has(neighbor)) { reachable.add(neighbor) next.push(neighbor) } } } queue = next } // BFS backward queue = [...matchingNodeIds] while (queue.length > 0) { const next: string[] = [] for (const nodeId of queue) { for (const neighbor of backwardAdj.get(nodeId) ?? []) { if (!reachable.has(neighbor)) { reachable.add(neighbor) next.push(neighbor) } } } queue = next } links = links.filter( (l) => reachable.has(l.source) && reachable.has(l.target), ) const filteredNodeIds = new Set() for (const link of links) { filteredNodeIds.add(link.source) filteredNodeIds.add(link.target) } nodes = nodes.filter((n) => filteredNodeIds.has(n.id)) } return { nodes, links } } // ─── Component ────────────────────────────────────────────────────── export default function SankeyJourney({ transitions, totalSessions, depth, }: SankeyJourneyProps) { const [filterPath, setFilterPath] = useState(null) const [isDark, setIsDark] = useState(false) // Reactively detect dark mode via MutationObserver useEffect(() => { const el = document.documentElement setIsDark(el.classList.contains('dark')) const observer = new MutationObserver(() => { setIsDark(el.classList.contains('dark')) }) observer.observe(el, { attributes: true, attributeFilter: ['class'] }) return () => observer.disconnect() }, []) const data = useMemo( () => buildSankeyData(transitions, filterPath ?? undefined), [transitions, filterPath], ) const handleClick = useCallback( // eslint-disable-next-line @typescript-eslint/no-explicit-any (item: any) => { if (!item.id || typeof item.id !== 'string') return // link click, ignore const path = pathFromId(item.id) if (path === '(other)') return setFilterPath((prev) => (prev === path ? null : path)) }, [], ) // Clear filter when data changes const transitionsKey = transitions.length + '-' + depth const [prevKey, setPrevKey] = useState(transitionsKey) if (prevKey !== transitionsKey) { setPrevKey(transitionsKey) if (filterPath !== null) setFilterPath(null) } // ─── Empty state ──────────────────────────────────────────────── if (!transitions.length || data.nodes.length === 0) { return (

No journey data yet

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

) } const labelColor = isDark ? '#a3a3a3' : '#525252' return (
{/* Filter reset bar */} {filterPath && (
Showing flows through{' '} {filterPath}
)}
data={data} margin={{ top: 16, right: 140, bottom: 16, left: 140 }} align="justify" sort="descending" colors={(node) => COLUMN_COLORS[node.stepIndex % COLUMN_COLORS.length] } nodeThickness={14} nodeSpacing={16} nodeInnerPadding={0} nodeBorderWidth={0} nodeBorderRadius={3} nodeOpacity={1} nodeHoverOpacity={1} nodeHoverOthersOpacity={0.3} linkOpacity={0.2} linkHoverOpacity={0.5} linkHoverOthersOpacity={0.05} linkContract={1} enableLinkGradient enableLabels label={(node) => smartLabel(pathFromId(node.id))} labelPosition="outside" labelPadding={12} labelTextColor={labelColor} isInteractive onClick={handleClick} nodeTooltip={({ node }) => (
{pathFromId(node.id)}
Step {node.stepIndex + 1} ·{' '} {node.value.toLocaleString()} sessions
)} linkTooltip={({ link }) => (
{pathFromId(link.source.id)} →{' '} {pathFromId(link.target.id)}
{link.value.toLocaleString()} sessions
)} theme={{ tooltip: { container: { background: 'transparent', boxShadow: 'none', padding: 0, }, }, }} />
) }