From c669035718ae4aa4d59a310fb9194e4280184fda Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Mar 2026 21:29:45 +0100 Subject: [PATCH] feat(journeys): add Sankey diagram SVG component --- components/journeys/SankeyDiagram.tsx | 398 ++++++++++++++++++++++++++ 1 file changed, 398 insertions(+) create mode 100644 components/journeys/SankeyDiagram.tsx diff --git a/components/journeys/SankeyDiagram.tsx b/components/journeys/SankeyDiagram.tsx new file mode 100644 index 0000000..4baa053 --- /dev/null +++ b/components/journeys/SankeyDiagram.tsx @@ -0,0 +1,398 @@ +'use client' + +import { useMemo, useState } from 'react' +import { useTheme } from '@ciphera-net/ui' +import type { PathTransition } from '@/lib/api/journeys' + +// ─── Types ────────────────────────────────────────────────────────── + +interface SankeyDiagramProps { + transitions: PathTransition[] + totalSessions: number + depth: number + onNodeClick?: (path: string) => void +} + +interface PositionedNode { + id: string // "col:path" + path: string + column: number + flow: number + x: number + y: number + height: number +} + +interface PositionedLink { + id: string + fromNode: PositionedNode + toNode: PositionedNode + sessionCount: number + sourceY: number + targetY: number + width: number +} + +// ─── Layout constants ─────────────────────────────────────────────── + +const PADDING_X = 60 +const PADDING_Y = 40 +const NODE_WIDTH = 12 +const NODE_GAP = 4 +const MIN_NODE_HEIGHT = 4 +const LABEL_MAX_LENGTH = 24 +const EXIT_LABEL = '(exit)' + +// ─── Helpers ──────────────────────────────────────────────────────── + +function truncatePath(path: string, maxLen: number): string { + if (path.length <= maxLen) return path + return path.slice(0, maxLen - 1) + '\u2026' +} + +function buildSankeyLayout( + transitions: PathTransition[], + depth: number, + svgWidth: number, + svgHeight: number, +) { + if (!transitions.length) return { nodes: [], links: [] } + + // ── 1. Build columns ────────────────────────────────────────────── + // columns[colIndex] = Map + const numColumns = depth + 1 + const columns: Map[] = Array.from( + { length: numColumns }, + () => new Map(), + ) + + for (const t of transitions) { + const fromCol = t.step_index + const toCol = t.step_index + 1 + if (fromCol >= numColumns || toCol >= numColumns) continue + + // from node + const fromEntry = columns[fromCol].get(t.from_path) ?? { inFlow: 0, outFlow: 0 } + fromEntry.outFlow += t.session_count + columns[fromCol].set(t.from_path, fromEntry) + + // to node + const toEntry = columns[toCol].get(t.to_path) ?? { inFlow: 0, outFlow: 0 } + toEntry.inFlow += t.session_count + columns[toCol].set(t.to_path, toEntry) + } + + // For column 0, nodes that have no inFlow — use outFlow as total flow + // For other columns, use max(inFlow, outFlow) + // Also ensure column 0 nodes get their inFlow from the fact they are entry points + + // ── 2. Add exit nodes ───────────────────────────────────────────── + // For each node, exitCount = inFlow - outFlow (if positive) + // For column 0, exitCount = outFlow - outFlow = handled differently: + // column 0 nodes: flow = outFlow, and if they also appear as to_path, inFlow is set + // Actually for column 0 the total flow IS outFlow (they are entry points) + + // Build exit transitions for each column (except last, which is all exit) + const exitTransitions: { fromCol: number; fromPath: string; exitCount: number }[] = [] + for (let col = 0; col < numColumns; col++) { + for (const [path, entry] of columns[col]) { + const totalFlow = col === 0 ? entry.outFlow : Math.max(entry.inFlow, entry.outFlow) + const exitCount = totalFlow - entry.outFlow + if (exitCount > 0) { + exitTransitions.push({ fromCol: col, fromPath: path, exitCount }) + } + } + } + + // For the last column, ALL flow is exit (no outgoing transitions) + // We don't add extra exit nodes for the last column since those nodes are already endpoints + + // Add exit nodes to columns (they sit in the same column, below the real nodes, + // or we add them as virtual nodes in col+1). Actually per spec: "Add virtual (exit) nodes + // at the right end of flows that don't continue" — this means we add them as targets in + // the next column. But we only do this for non-last columns. + const exitLinks: { fromCol: number; fromPath: string; exitCount: number }[] = [] + for (const et of exitTransitions) { + if (et.fromCol < numColumns - 1) { + const exitCol = et.fromCol + 1 + const exitEntry = columns[exitCol].get(EXIT_LABEL) ?? { inFlow: 0, outFlow: 0 } + exitEntry.inFlow += et.exitCount + columns[exitCol].set(EXIT_LABEL, exitEntry) + exitLinks.push(et) + } + } + + // ── 3. Sort nodes per column and assign positions ───────────────── + const availableWidth = svgWidth - PADDING_X * 2 + const availableHeight = svgHeight - PADDING_Y * 2 + const colSpacing = numColumns > 1 ? availableWidth / (numColumns - 1) : 0 + + const positionedNodes: Map = new Map() + + for (let col = 0; col < numColumns; col++) { + const entries = Array.from(columns[col].entries()).map(([path, entry]) => ({ + path, + flow: col === 0 ? entry.outFlow : Math.max(entry.inFlow, entry.outFlow), + })) + + // Sort by flow descending, but keep (exit) at bottom + entries.sort((a, b) => { + if (a.path === EXIT_LABEL) return 1 + if (b.path === EXIT_LABEL) return -1 + return b.flow - a.flow + }) + + const totalFlow = entries.reduce((sum, e) => sum + e.flow, 0) + const totalGaps = Math.max(0, entries.length - 1) * NODE_GAP + const usableHeight = availableHeight - totalGaps + + let y = PADDING_Y + const x = PADDING_X + col * colSpacing + + for (const entry of entries) { + const proportion = totalFlow > 0 ? entry.flow / totalFlow : 1 / entries.length + const nodeHeight = Math.max(MIN_NODE_HEIGHT, proportion * usableHeight) + const id = `${col}:${entry.path}` + + positionedNodes.set(id, { + id, + path: entry.path, + column: col, + flow: entry.flow, + x, + y, + height: nodeHeight, + }) + + y += nodeHeight + NODE_GAP + } + } + + // ── 4. Build positioned links ───────────────────────────────────── + // Track how much vertical space has been used at each node's source/target side + const sourceOffsets: Map = new Map() + const targetOffsets: Map = new Map() + + const allLinks: { + fromId: string + toId: string + sessionCount: number + }[] = [] + + // Regular transitions + for (const t of transitions) { + const fromCol = t.step_index + const toCol = t.step_index + 1 + if (fromCol >= numColumns || toCol >= numColumns) continue + allLinks.push({ + fromId: `${fromCol}:${t.from_path}`, + toId: `${toCol}:${t.to_path}`, + sessionCount: t.session_count, + }) + } + + // Exit links + for (const et of exitLinks) { + allLinks.push({ + fromId: `${et.fromCol}:${et.fromPath}`, + toId: `${et.fromCol + 1}:${EXIT_LABEL}`, + sessionCount: et.exitCount, + }) + } + + // Sort links by session count descending for better visual stacking + allLinks.sort((a, b) => b.sessionCount - a.sessionCount) + + const positionedLinks: PositionedLink[] = [] + + for (const link of allLinks) { + const fromNode = positionedNodes.get(link.fromId) + const toNode = positionedNodes.get(link.toId) + if (!fromNode || !toNode) continue + + const linkWidth = Math.max( + 1, + fromNode.flow > 0 ? (link.sessionCount / fromNode.flow) * fromNode.height : 1, + ) + + const sourceOffset = sourceOffsets.get(link.fromId) ?? 0 + const targetOffset = targetOffsets.get(link.toId) ?? 0 + + positionedLinks.push({ + id: `${link.fromId}->${link.toId}`, + fromNode, + toNode, + sessionCount: link.sessionCount, + sourceY: fromNode.y + sourceOffset, + targetY: toNode.y + targetOffset, + width: linkWidth, + }) + + sourceOffsets.set(link.fromId, sourceOffset + linkWidth) + targetOffsets.set(link.toId, targetOffset + linkWidth) + } + + return { + nodes: Array.from(positionedNodes.values()), + links: positionedLinks, + } +} + +function buildLinkPath(link: PositionedLink): string { + const sx = link.fromNode.x + NODE_WIDTH + const sy = link.sourceY + const tx = link.toNode.x + const ty = link.targetY + const w = link.width + const midX = (sx + tx) / 2 + + return [ + `M ${sx},${sy}`, + `C ${midX},${sy} ${midX},${ty} ${tx},${ty}`, + `L ${tx},${ty + w}`, + `C ${midX},${ty + w} ${midX},${sy + w} ${sx},${sy + w}`, + 'Z', + ].join(' ') +} + +// ─── Component ────────────────────────────────────────────────────── + +export default function SankeyDiagram({ + transitions, + totalSessions, + depth, + onNodeClick, +}: SankeyDiagramProps) { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + const [hoveredLink, setHoveredLink] = useState(null) + + const svgWidth = 1000 + const svgHeight = 500 + + const { nodes, links } = useMemo( + () => buildSankeyLayout(transitions, depth, svgWidth, svgHeight), + [transitions, depth], + ) + + if (!transitions.length || !links.length) { + return ( +
+ No journey data available +
+ ) + } + + const numColumns = depth + 1 + const isLastColumn = (col: number) => col === numColumns - 1 + + // Colors + const nodeFill = isDark ? '#a3a3a3' : '#525252' + const labelFill = isDark ? '#a3a3a3' : '#737373' + const linkDefault = isDark ? 'rgba(163, 163, 163, 0.1)' : 'rgba(163, 163, 163, 0.15)' + const linkHover = 'rgba(249, 115, 22, 0.4)' + const linkDimmed = isDark ? 'rgba(163, 163, 163, 0.04)' : 'rgba(163, 163, 163, 0.06)' + const exitNodeFill = isDark ? '#525252' : '#a3a3a3' + + return ( + + {/* Links */} + + {links.map((link) => { + const isHovered = hoveredLink === link.id + const hasSomeHovered = hoveredLink !== null + const pct = totalSessions > 0 + ? ((link.sessionCount / totalSessions) * 100).toFixed(1) + : '0' + + let fill: string + if (isHovered) fill = linkHover + else if (hasSomeHovered) fill = linkDimmed + else fill = linkDefault + + return ( + setHoveredLink(link.id)} + onMouseLeave={() => setHoveredLink(null)} + className="cursor-default" + > + + {link.fromNode.path} → {link.toNode.path}: {link.sessionCount.toLocaleString()} sessions ({pct}%) + + + ) + })} + + + {/* Nodes */} + + {nodes.map((node) => { + const isExit = node.path === EXIT_LABEL + + return ( + { + if (onNodeClick && !isExit) onNodeClick(node.path) + }} + > + {node.path} — {node.flow.toLocaleString()} sessions + + ) + })} + + + {/* Labels */} + + {nodes.map((node) => { + const isLast = isLastColumn(node.column) + const labelX = isLast ? node.x - 6 : node.x + NODE_WIDTH + 6 + const labelY = node.y + node.height / 2 + const anchor = isLast ? 'end' : 'start' + const displayLabel = truncatePath(node.path, LABEL_MAX_LENGTH) + + // Only show labels for nodes tall enough to fit text + if (node.height < 10) return null + + return ( + { + if (onNodeClick && node.path !== EXIT_LABEL) onNodeClick(node.path) + }} + > + {displayLabel} + {node.path} + + ) + })} + + + ) +}