'use client' import { useCallback, useMemo, useRef, useState } from 'react' import { useTheme } from '@ciphera-net/ui' import { TreeStructure } from '@phosphor-icons/react' import { sankey, sankeyJustify } from 'd3-sankey' import type { SankeyNode as D3SankeyNode, SankeyLink as D3SankeyLink, SankeyExtraProperties, } from 'd3-sankey' import type { PathTransition } from '@/lib/api/journeys' // ─── Types ────────────────────────────────────────────────────────── interface SankeyDiagramProps { transitions: PathTransition[] totalSessions: number depth: number onNodeClick?: (path: string) => void } interface NodeExtra extends SankeyExtraProperties { id: string label: string color: string } interface LinkExtra extends SankeyExtraProperties { value: number } type LayoutNode = D3SankeyNode type LayoutLink = D3SankeyLink // ─── Constants ────────────────────────────────────────────────────── const COLUMN_COLORS = [ '#FD5E0F', // brand orange (entry) '#3B82F6', // blue '#10B981', // emerald '#F59E0B', // amber '#8B5CF6', // violet '#EC4899', // pink '#06B6D4', // cyan '#EF4444', // red '#84CC16', // lime '#F97316', // orange again '#6366F1', // indigo ] const EXIT_GREY = '#52525b' const SVG_W = 1100 const MARGIN = { top: 24, right: 140, bottom: 24, left: 10 } const MAX_NODES_PER_COLUMN = 5 function colorForColumn(col: number): string { return COLUMN_COLORS[col % COLUMN_COLORS.length] } // ─── Smart label: show last meaningful path segment ───────────────── function smartLabel(path: string): string { if (path === '/' || path === '(exit)') return path // Remove trailing slash, split, take last 2 segments const segments = path.replace(/\/$/, '').split('/') if (segments.length <= 2) return path // Show /last-segment for short paths, or …/last-segment for deep ones const last = segments[segments.length - 1] return `…/${last}` } function truncateLabel(s: string, max: number) { return s.length > max ? s.slice(0, max - 1) + '\u2026' : s } function estimateTextWidth(s: string) { return s.length * 7 } // ─── Data transformation ──────────────────────────────────────────── function buildSankeyData(transitions: PathTransition[], depth: number) { const numCols = depth + 1 const nodeMap = new Map() const links: Array<{ source: string; target: string; value: number }> = [] const flowOut = new Map() const flowIn = new Map() for (const t of transitions) { if (t.step_index >= numCols || t.step_index + 1 >= numCols) continue const fromId = `${t.step_index}:${t.from_path}` const toId = `${t.step_index + 1}:${t.to_path}` if (!nodeMap.has(fromId)) { nodeMap.set(fromId, { id: fromId, label: t.from_path, color: colorForColumn(t.step_index) }) } if (!nodeMap.has(toId)) { nodeMap.set(toId, { id: toId, label: t.to_path, color: colorForColumn(t.step_index + 1) }) } links.push({ source: fromId, target: toId, value: t.session_count }) flowOut.set(fromId, (flowOut.get(fromId) ?? 0) + t.session_count) flowIn.set(toId, (flowIn.get(toId) ?? 0) + t.session_count) } // ─── Cap nodes per column: keep top N by flow, merge rest into (other) ── const columns = new Map() for (const [nodeId] of nodeMap) { if (nodeId === 'exit') continue const col = parseInt(nodeId.split(':')[0], 10) if (!columns.has(col)) columns.set(col, []) columns.get(col)!.push(nodeId) } for (const [col, nodeIds] of columns) { if (nodeIds.length <= MAX_NODES_PER_COLUMN) continue // Sort by total flow (max of in/out) descending nodeIds.sort((a, b) => { const flowA = Math.max(flowIn.get(a) ?? 0, flowOut.get(a) ?? 0) const flowB = Math.max(flowIn.get(b) ?? 0, flowOut.get(b) ?? 0) return flowB - flowA }) const keep = new Set(nodeIds.slice(0, MAX_NODES_PER_COLUMN)) const otherId = `${col}:(other)` nodeMap.set(otherId, { id: otherId, label: '(other)', color: colorForColumn(col) }) // Redirect links from/to pruned nodes to (other) for (let i = 0; i < links.length; i++) { const l = links[i] if (!keep.has(l.source) && nodeIds.includes(l.source)) { links[i] = { ...l, source: otherId } } if (!keep.has(l.target) && nodeIds.includes(l.target)) { links[i] = { ...l, target: otherId } } } // Remove pruned nodes for (const id of nodeIds) { if (!keep.has(id)) nodeMap.delete(id) } } // Deduplicate links after merging (same source→target pairs) const linkMap = new Map() for (const l of links) { const key = `${l.source}->${l.target}` const existing = linkMap.get(key) if (existing) { existing.value += l.value } else { linkMap.set(key, { ...l }) } } // Recalculate flowOut/flowIn after merge flowOut.clear() flowIn.clear() for (const l of linkMap.values()) { flowOut.set(l.source, (flowOut.get(l.source) ?? 0) + l.value) flowIn.set(l.target, (flowIn.get(l.target) ?? 0) + l.value) } // Add exit nodes for flows that don't continue for (const [nodeId] of nodeMap) { if (nodeId === 'exit') continue const col = parseInt(nodeId.split(':')[0], 10) if (col >= numCols - 1) continue const totalIn = flowIn.get(nodeId) ?? 0 const totalOut = flowOut.get(nodeId) ?? 0 const flow = Math.max(totalIn, totalOut) const exitCount = flow - totalOut if (exitCount > 0) { const exitId = 'exit' if (!nodeMap.has(exitId)) { nodeMap.set(exitId, { id: exitId, label: '(exit)', color: EXIT_GREY }) } const key = `${nodeId}->exit` const existing = linkMap.get(key) if (existing) { existing.value += exitCount } else { linkMap.set(key, { source: nodeId, target: exitId, value: exitCount }) } } } return { nodes: Array.from(nodeMap.values()), links: Array.from(linkMap.values()), } } // ─── SVG path for a link ribbon ───────────────────────────────────── function ribbonPath(link: LayoutLink): string { const src = link.source as LayoutNode const tgt = link.target as LayoutNode const sx = src.x1! const tx = tgt.x0! const w = link.width! // d3-sankey y0/y1 are the CENTER of the link band, not the top const sy = link.y0! - w / 2 const ty = link.y1! - w / 2 const mx = (sx + tx) / 2 return [ `M${sx},${sy}`, `C${mx},${sy} ${mx},${ty} ${tx},${ty}`, `L${tx},${ty + w}`, `C${mx},${ty + w} ${mx},${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 [hovered, setHovered] = useState<{ type: 'link' | 'node'; id: string } | null>(null) const svgRef = useRef(null) const data = useMemo( () => buildSankeyData(transitions, depth), [transitions, depth], ) // Dynamic SVG height based on max nodes in any column const svgH = useMemo(() => { const columns = new Map() for (const node of data.nodes) { if (node.id === 'exit') continue const col = parseInt(node.id.split(':')[0], 10) columns.set(col, (columns.get(col) ?? 0) + 1) } const maxNodes = Math.max(1, ...columns.values()) // Base 400 + 50px per node beyond 4 return Math.max(400, Math.min(800, 400 + Math.max(0, maxNodes - 4) * 50)) }, [data]) const layout = useMemo(() => { if (!data.links.length) return null const generator = sankey() .nodeId((d) => d.id) .nodeWidth(18) .nodePadding(16) .nodeAlign(sankeyJustify) .extent([ [MARGIN.left, MARGIN.top], [SVG_W - MARGIN.right, svgH - MARGIN.bottom], ]) return generator({ nodes: data.nodes.map((d) => ({ ...d })), links: data.links.map((d) => ({ ...d })), }) }, [data, svgH]) // Single event handler on SVG — reads data-* attrs from e.target const handleMouseOver = useCallback((e: React.MouseEvent) => { const target = e.target as SVGElement const el = target.closest('[data-node-id], [data-link-id]') as SVGElement | null if (!el) return const nodeId = el.getAttribute('data-node-id') const linkId = el.getAttribute('data-link-id') if (nodeId) { setHovered((prev) => (prev?.type === 'node' && prev.id === nodeId) ? prev : { type: 'node', id: nodeId }) } else if (linkId) { setHovered((prev) => (prev?.type === 'link' && prev.id === linkId) ? prev : { type: 'link', id: linkId }) } }, []) const handleMouseLeave = useCallback(() => { setHovered(null) }, []) // ─── Empty state ──────────────────────────────────────────────── if (!transitions.length || !layout) { return (

No journey data yet

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

) } // ─── Colors ───────────────────────────────────────────────────── const labelColor = isDark ? '#e5e5e5' : '#404040' const labelBg = isDark ? 'rgba(23, 23, 23, 0.9)' : 'rgba(255, 255, 255, 0.9)' const nodeStroke = isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)' return ( {/* Links */} {layout.links.map((link, i) => { const src = link.source as LayoutNode const tgt = link.target as LayoutNode const srcId = String(src.id) const tgtId = String(tgt.id) const linkId = `${srcId}->${tgtId}` let isHighlighted = false if (hovered?.type === 'link') { isHighlighted = hovered.id === linkId } else if (hovered?.type === 'node') { isHighlighted = srcId === hovered.id || tgtId === hovered.id } let opacity = isDark ? 0.45 : 0.5 if (hovered) { opacity = isHighlighted ? 0.75 : 0.08 } return ( {src.label} → {tgt.label}:{' '} {(link.value as number).toLocaleString()} sessions ) })} {/* Nodes */} {layout.nodes.map((node) => { const nodeId = String(node.id) const isExit = nodeId === 'exit' const w = isExit ? 8 : (node.x1 ?? 0) - (node.x0 ?? 0) const h = (node.y1 ?? 0) - (node.y0 ?? 0) const x = isExit ? (node.x0 ?? 0) + 5 : (node.x0 ?? 0) return ( { if (onNodeClick && !isExit) onNodeClick(node.label) }} > {node.label} — {(node.value ?? 0).toLocaleString()} sessions ) })} {/* Labels — only for nodes tall enough to avoid overlap */} {layout.nodes.map((node) => { const x0 = node.x0 ?? 0 const x1 = node.x1 ?? 0 const y0 = node.y0 ?? 0 const y1 = node.y1 ?? 0 const nodeH = y1 - y0 if (nodeH < 36) return null // hide labels for small nodes — hover for details const rawLabel = smartLabel(node.label) const label = truncateLabel(rawLabel, 24) const textW = estimateTextWidth(label) const padX = 6 const rectW = textW + padX * 2 const rectH = 20 const isRight = x1 > SVG_W - MARGIN.right - 60 const textX = isRight ? x0 - 6 : x1 + 6 const textY = y0 + nodeH / 2 const anchor = isRight ? 'end' : 'start' const bgX = isRight ? textX - textW - padX : textX - padX const bgY = textY - rectH / 2 const nodeId = String(node.id) const isExit = nodeId === 'exit' return ( { if (onNodeClick && !isExit) onNodeClick(node.label) }} > {label} ) })} ) }