458 lines
15 KiB
TypeScript
458 lines
15 KiB
TypeScript
'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<NodeExtra, LinkExtra>
|
|
type LayoutLink = D3SankeyLink<NodeExtra, LinkExtra>
|
|
|
|
// ─── 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<string, NodeExtra>()
|
|
const links: Array<{ source: string; target: string; value: number }> = []
|
|
const flowOut = new Map<string, number>()
|
|
const flowIn = new Map<string, number>()
|
|
|
|
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<number, string[]>()
|
|
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<string, { source: string; target: string; value: number }>()
|
|
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<SVGSVGElement>(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<number, number>()
|
|
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<NodeExtra, LinkExtra>()
|
|
.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<SVGSVGElement>) => {
|
|
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 (
|
|
<div className="h-[400px] flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
|
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
|
<TreeStructure className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
|
</div>
|
|
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
|
No journey data yet
|
|
</h4>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
|
Navigation flows will appear here as visitors browse through your site.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── 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 (
|
|
<svg
|
|
ref={svgRef}
|
|
viewBox={`0 0 ${SVG_W} ${svgH}`}
|
|
preserveAspectRatio="xMidYMid meet"
|
|
className="w-full"
|
|
role="img"
|
|
aria-label="User journey Sankey diagram"
|
|
onMouseMove={handleMouseOver}
|
|
onMouseLeave={handleMouseLeave}
|
|
>
|
|
{/* Links */}
|
|
<g>
|
|
{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 (
|
|
<path
|
|
key={i}
|
|
d={ribbonPath(link)}
|
|
fill={src.color}
|
|
opacity={opacity}
|
|
style={{ transition: 'opacity 0.15s ease' }}
|
|
data-link-id={linkId}
|
|
>
|
|
<title>
|
|
{src.label} → {tgt.label}:{' '}
|
|
{(link.value as number).toLocaleString()} sessions
|
|
</title>
|
|
</path>
|
|
)
|
|
})}
|
|
</g>
|
|
|
|
{/* Nodes */}
|
|
<g>
|
|
{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 (
|
|
<rect
|
|
key={nodeId}
|
|
x={x}
|
|
y={node.y0}
|
|
width={w}
|
|
height={h}
|
|
fill={node.color}
|
|
stroke={nodeStroke}
|
|
strokeWidth={1}
|
|
rx={2}
|
|
className={
|
|
onNodeClick && !isExit ? 'cursor-pointer' : 'cursor-default'
|
|
}
|
|
data-node-id={nodeId}
|
|
onClick={() => {
|
|
if (onNodeClick && !isExit) onNodeClick(node.label)
|
|
}}
|
|
>
|
|
<title>
|
|
{node.label} — {(node.value ?? 0).toLocaleString()} sessions
|
|
</title>
|
|
</rect>
|
|
)
|
|
})}
|
|
</g>
|
|
|
|
{/* Labels — only for nodes tall enough to avoid overlap */}
|
|
<g>
|
|
{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 (
|
|
<g key={`label-${nodeId}`} data-node-id={nodeId}>
|
|
<rect
|
|
x={bgX}
|
|
y={bgY}
|
|
width={rectW}
|
|
height={rectH}
|
|
rx={3}
|
|
fill={labelBg}
|
|
/>
|
|
<text
|
|
x={textX}
|
|
y={textY}
|
|
dy="0.35em"
|
|
textAnchor={anchor}
|
|
fill={labelColor}
|
|
fontSize={12}
|
|
fontFamily="system-ui, -apple-system, sans-serif"
|
|
className={
|
|
onNodeClick && !isExit ? 'cursor-pointer' : 'cursor-default'
|
|
}
|
|
onClick={() => {
|
|
if (onNodeClick && !isExit) onNodeClick(node.label)
|
|
}}
|
|
>
|
|
{label}
|
|
</text>
|
|
</g>
|
|
)
|
|
})}
|
|
</g>
|
|
</svg>
|
|
)
|
|
}
|