From 71f922976dc3a5dd29b07329a21870966b10f70b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 14:00:12 +0100 Subject: [PATCH] feat: add SankeyJourney component with data transformation and interactivity --- components/journeys/SankeyJourney.tsx | 347 ++++++++++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 components/journeys/SankeyJourney.tsx diff --git a/components/journeys/SankeyJourney.tsx b/components/journeys/SankeyJourney.tsx new file mode 100644 index 0000000..aa0ff92 --- /dev/null +++ b/components/journeys/SankeyJourney.tsx @@ -0,0 +1,347 @@ +'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, + }, + }, + }} + /> +
+
+ ) +}