feat: rewrite sankey chart with D3 — thin bars, labels beside nodes, proper hover
Replace @nivo/sankey with custom D3 implementation: - 30px thin node bars with labels positioned beside them - Links at 0.3 opacity, 0.6 on hover with full path highlighting - Colors based on first URL segment for visual grouping - Dynamic height based on tallest column - Responsive width via ResizeObserver - Click nodes to filter, hover for tooltips - Invisible wider hit areas for easier link hovering - Remove @nivo/sankey dependency, add d3
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import * as d3 from 'd3'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Sankey } from '@nivo/sankey'
|
||||
import { TreeStructure, X } from '@phosphor-icons/react'
|
||||
import type { PathTransition } from '@/lib/api/journeys'
|
||||
|
||||
@@ -13,33 +13,59 @@ interface SankeyJourneyProps {
|
||||
depth: number
|
||||
}
|
||||
|
||||
interface SankeyNode {
|
||||
interface SNode {
|
||||
id: string
|
||||
stepIndex: number
|
||||
name: string
|
||||
step: number
|
||||
height: number
|
||||
x: number
|
||||
y: number
|
||||
count: number
|
||||
inLinks: SLink[]
|
||||
outLinks: SLink[]
|
||||
}
|
||||
|
||||
interface SankeyLink {
|
||||
interface SLink {
|
||||
source: string
|
||||
target: string
|
||||
value: number
|
||||
}
|
||||
|
||||
interface SankeyData {
|
||||
nodes: SankeyNode[]
|
||||
links: SankeyLink[]
|
||||
sourceY?: number
|
||||
targetY?: number
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────
|
||||
|
||||
const COLUMN_COLORS = [
|
||||
'#FD5E0F', '#3B82F6', '#10B981', '#F59E0B', '#8B5CF6',
|
||||
'#EC4899', '#06B6D4', '#EF4444', '#84CC16', '#F97316', '#6366F1',
|
||||
]
|
||||
|
||||
const NODE_WIDTH = 30
|
||||
const NODE_GAP = 20
|
||||
const MIN_NODE_HEIGHT = 2
|
||||
const MAX_LINK_HEIGHT = 100
|
||||
const LINK_OPACITY = 0.3
|
||||
const LINK_HOVER_OPACITY = 0.6
|
||||
const MAX_NODES_PER_STEP = 25
|
||||
|
||||
const COLOR_PALETTE = [
|
||||
'hsl(160, 45%, 40%)', 'hsl(220, 45%, 50%)', 'hsl(270, 40%, 50%)',
|
||||
'hsl(25, 50%, 50%)', 'hsl(340, 40%, 50%)', 'hsl(190, 45%, 45%)',
|
||||
'hsl(45, 45%, 50%)', 'hsl(0, 45%, 50%)',
|
||||
]
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function pathFromId(id: string): string {
|
||||
const idx = id.indexOf(':')
|
||||
return idx >= 0 ? id.slice(idx + 1) : id
|
||||
}
|
||||
|
||||
function stepFromId(id: string): number {
|
||||
const idx = id.indexOf(':')
|
||||
return idx >= 0 ? parseInt(id.slice(0, idx), 10) : 0
|
||||
}
|
||||
|
||||
function firstSegment(path: string): string {
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
return parts.length > 0 ? `/${parts[0]}` : path
|
||||
}
|
||||
|
||||
function smartLabel(path: string): string {
|
||||
if (path === '/' || path === '(other)') return path
|
||||
const segments = path.replace(/\/$/, '').split('/')
|
||||
@@ -47,147 +73,105 @@ function smartLabel(path: string): string {
|
||||
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(
|
||||
function buildData(
|
||||
transitions: PathTransition[],
|
||||
filterPath?: string,
|
||||
): SankeyData {
|
||||
): { nodes: SNode[]; links: SLink[] } {
|
||||
if (transitions.length === 0) return { nodes: [], links: [] }
|
||||
|
||||
// Group transitions by step and count sessions per path at each step
|
||||
// Group transitions by step, count per path per step
|
||||
const stepPaths = new Map<number, Map<string, number>>()
|
||||
|
||||
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<number, Set<string>>()
|
||||
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)
|
||||
// Keep top N per step, rest → (other)
|
||||
const topPaths = new Map<number, Set<string>>()
|
||||
for (const [step, pm] of stepPaths) {
|
||||
const sorted = Array.from(pm.entries()).sort((a, b) => b[1] - a[1])
|
||||
topPaths.set(step, new Set(sorted.slice(0, MAX_NODES_PER_STEP).map(([p]) => p)))
|
||||
}
|
||||
|
||||
// Build links with capping
|
||||
// Build links
|
||||
const linkMap = new Map<string, number>()
|
||||
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}`
|
||||
const fromTop = topPaths.get(t.step_index)!
|
||||
const toTop = topPaths.get(t.step_index + 1)!
|
||||
const fp = fromTop.has(t.from_path) ? t.from_path : '(other)'
|
||||
const tp = toTop.has(t.to_path) ? t.to_path : '(other)'
|
||||
if (fp === '(other)' && tp === '(other)') continue
|
||||
const src = `${t.step_index}:${fp}`
|
||||
const tgt = `${t.step_index + 1}:${tp}`
|
||||
const key = `${src}|${tgt}`
|
||||
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 }
|
||||
let links: SLink[] = Array.from(linkMap.entries()).map(([k, v]) => {
|
||||
const [source, target] = k.split('|')
|
||||
return { source, target, value: v }
|
||||
})
|
||||
|
||||
// Collect all node ids referenced by links
|
||||
// Collect node IDs
|
||||
const nodeIdSet = new Set<string>()
|
||||
for (const link of links) {
|
||||
nodeIdSet.add(link.source)
|
||||
nodeIdSet.add(link.target)
|
||||
}
|
||||
for (const l of links) { nodeIdSet.add(l.source); nodeIdSet.add(l.target) }
|
||||
|
||||
let nodes: SankeyNode[] = Array.from(nodeIdSet).map((id) => ({
|
||||
let nodes: SNode[] = Array.from(nodeIdSet).map((id) => ({
|
||||
id,
|
||||
stepIndex: stepFromId(id),
|
||||
name: pathFromId(id),
|
||||
step: stepFromId(id),
|
||||
height: 0, x: 0, y: 0, count: 0,
|
||||
inLinks: [], outLinks: [],
|
||||
}))
|
||||
|
||||
// ─── Filter by path (BFS forward + backward) ────────────────────
|
||||
// Filter by path (BFS forward + backward)
|
||||
if (filterPath) {
|
||||
const matchingNodeIds = nodes
|
||||
.filter((n) => pathFromId(n.id) === filterPath)
|
||||
.map((n) => n.id)
|
||||
const matchIds = nodes.filter((n) => n.name === filterPath).map((n) => n.id)
|
||||
if (matchIds.length === 0) return { nodes: [], links: [] }
|
||||
|
||||
if (matchingNodeIds.length === 0) return { nodes: [], links: [] }
|
||||
|
||||
// Build adjacency
|
||||
const forwardAdj = new Map<string, Set<string>>()
|
||||
const backwardAdj = new Map<string, Set<string>>()
|
||||
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 fwd = new Map<string, Set<string>>()
|
||||
const bwd = new Map<string, Set<string>>()
|
||||
for (const l of links) {
|
||||
if (!fwd.has(l.source)) fwd.set(l.source, new Set())
|
||||
fwd.get(l.source)!.add(l.target)
|
||||
if (!bwd.has(l.target)) bwd.set(l.target, new Set())
|
||||
bwd.get(l.target)!.add(l.source)
|
||||
}
|
||||
|
||||
const reachable = new Set<string>(matchingNodeIds)
|
||||
|
||||
// BFS forward
|
||||
let queue = [...matchingNodeIds]
|
||||
const reachable = new Set<string>(matchIds)
|
||||
let queue = [...matchIds]
|
||||
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)
|
||||
}
|
||||
for (const id of queue) {
|
||||
for (const nb of fwd.get(id) ?? []) {
|
||||
if (!reachable.has(nb)) { reachable.add(nb); next.push(nb) }
|
||||
}
|
||||
}
|
||||
queue = next
|
||||
}
|
||||
queue = [...matchIds]
|
||||
while (queue.length > 0) {
|
||||
const next: string[] = []
|
||||
for (const id of queue) {
|
||||
for (const nb of bwd.get(id) ?? []) {
|
||||
if (!reachable.has(nb)) { reachable.add(nb); next.push(nb) }
|
||||
}
|
||||
}
|
||||
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<string>()
|
||||
for (const link of links) {
|
||||
filteredNodeIds.add(link.source)
|
||||
filteredNodeIds.add(link.target)
|
||||
}
|
||||
nodes = nodes.filter((n) => filteredNodeIds.has(n.id))
|
||||
links = links.filter((l) => reachable.has(l.source) && reachable.has(l.target))
|
||||
const kept = new Set<string>()
|
||||
for (const l of links) { kept.add(l.source); kept.add(l.target) }
|
||||
nodes = nodes.filter((n) => kept.has(n.id))
|
||||
}
|
||||
|
||||
return { nodes, links }
|
||||
@@ -202,43 +186,325 @@ export default function SankeyJourney({
|
||||
}: SankeyJourneyProps) {
|
||||
const [filterPath, setFilterPath] = useState<string | null>(null)
|
||||
const [isDark, setIsDark] = useState(false)
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [containerWidth, setContainerWidth] = useState(900)
|
||||
|
||||
// Reactively detect dark mode via MutationObserver
|
||||
// Detect dark mode
|
||||
useEffect(() => {
|
||||
const el = document.documentElement
|
||||
setIsDark(el.classList.contains('dark'))
|
||||
const obs = new MutationObserver(() => setIsDark(el.classList.contains('dark')))
|
||||
obs.observe(el, { attributes: true, attributeFilter: ['class'] })
|
||||
return () => obs.disconnect()
|
||||
}, [])
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDark(el.classList.contains('dark'))
|
||||
})
|
||||
observer.observe(el, { attributes: true, attributeFilter: ['class'] })
|
||||
return () => observer.disconnect()
|
||||
// Measure container
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const measure = () => setContainerWidth(el.clientWidth)
|
||||
measure()
|
||||
const obs = new ResizeObserver(measure)
|
||||
obs.observe(el)
|
||||
return () => obs.disconnect()
|
||||
}, [])
|
||||
|
||||
const data = useMemo(
|
||||
() => buildSankeyData(transitions, filterPath ?? undefined),
|
||||
() => buildData(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)
|
||||
// Clear filter on data change
|
||||
const transKey = transitions.length + '-' + depth
|
||||
const [prevKey, setPrevKey] = useState(transKey)
|
||||
if (prevKey !== transKey) {
|
||||
setPrevKey(transKey)
|
||||
if (filterPath !== null) setFilterPath(null)
|
||||
}
|
||||
|
||||
const handleNodeClick = useCallback((path: string) => {
|
||||
if (path === '(other)') return
|
||||
setFilterPath((prev) => (prev === path ? null : path))
|
||||
}, [])
|
||||
|
||||
// ─── D3 Rendering ──────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || data.nodes.length === 0) return
|
||||
|
||||
const svg = d3.select(svgRef.current)
|
||||
svg.selectAll('*').remove()
|
||||
|
||||
const { nodes, links } = data
|
||||
|
||||
const linkColor = isDark ? 'rgba(163,163,163,0.5)' : 'rgba(82,82,82,0.5)'
|
||||
const textColor = isDark ? '#e5e5e5' : '#171717'
|
||||
|
||||
// Wire up node ↔ link references
|
||||
for (const n of nodes) { n.inLinks = []; n.outLinks = []; n.count = 0 }
|
||||
const nodeMap = new Map(nodes.map((n) => [n.id, n]))
|
||||
for (const l of links) {
|
||||
const src = nodeMap.get(l.source)
|
||||
const tgt = nodeMap.get(l.target)
|
||||
if (src) src.outLinks.push(l)
|
||||
if (tgt) tgt.inLinks.push(l)
|
||||
}
|
||||
for (const n of nodes) {
|
||||
const inVal = n.inLinks.reduce((s, l) => s + l.value, 0)
|
||||
const outVal = n.outLinks.reduce((s, l) => s + l.value, 0)
|
||||
n.count = n.step === 0 ? outVal : Math.max(inVal, outVal)
|
||||
}
|
||||
|
||||
// Calculate node heights (proportional to value)
|
||||
const maxVal = d3.max(links, (l) => l.value) || 1
|
||||
const heightScale = d3.scaleLinear().domain([0, maxVal]).range([0, MAX_LINK_HEIGHT])
|
||||
for (const n of nodes) {
|
||||
const inVal = n.inLinks.reduce((s, l) => s + l.value, 0)
|
||||
const outVal = n.outLinks.reduce((s, l) => s + l.value, 0)
|
||||
n.height = Math.max(heightScale(Math.max(inVal, outVal)), MIN_NODE_HEIGHT)
|
||||
}
|
||||
|
||||
// Group by step, determine layout
|
||||
const byStep = d3.group(nodes, (n) => n.step)
|
||||
const numSteps = byStep.size
|
||||
const width = containerWidth
|
||||
const stepWidth = width / numSteps
|
||||
|
||||
// Calculate chart height from tallest column
|
||||
const stepHeights = Array.from(byStep.values()).map(
|
||||
(ns) => ns.reduce((s, n) => s + n.height, 0) + (ns.length - 1) * NODE_GAP,
|
||||
)
|
||||
const height = Math.max(200, Math.max(...stepHeights) + 20)
|
||||
|
||||
// Position nodes in columns, aligned from top
|
||||
byStep.forEach((stepNodes, step) => {
|
||||
let cy = 0
|
||||
for (const n of stepNodes) {
|
||||
n.x = step * stepWidth
|
||||
n.y = cy + n.height / 2
|
||||
cy += n.height + NODE_GAP
|
||||
}
|
||||
})
|
||||
|
||||
// Calculate link y-positions (stacked within each node)
|
||||
for (const n of nodes) {
|
||||
n.outLinks.sort((a, b) => b.value - a.value)
|
||||
n.inLinks.sort((a, b) => b.value - a.value)
|
||||
|
||||
let outY = n.y - n.height / 2
|
||||
for (const l of n.outLinks) {
|
||||
const lh = heightScale(l.value)
|
||||
l.sourceY = outY + lh / 2
|
||||
outY += lh
|
||||
}
|
||||
|
||||
let inY = n.y - n.height / 2
|
||||
for (const l of n.inLinks) {
|
||||
const lh = heightScale(l.value)
|
||||
l.targetY = inY + lh / 2
|
||||
inY += lh
|
||||
}
|
||||
}
|
||||
|
||||
// Color by first path segment
|
||||
const segCounts = new Map<string, number>()
|
||||
for (const n of nodes) {
|
||||
const seg = firstSegment(n.name)
|
||||
segCounts.set(seg, (segCounts.get(seg) ?? 0) + 1)
|
||||
}
|
||||
const segColors = new Map<string, string>()
|
||||
let ci = 0
|
||||
segCounts.forEach((count, seg) => {
|
||||
if (count > 1) { segColors.set(seg, COLOR_PALETTE[ci % COLOR_PALETTE.length]); ci++ }
|
||||
})
|
||||
const defaultColor = isDark ? 'hsl(0, 0%, 50%)' : 'hsl(0, 0%, 45%)'
|
||||
const nodeColor = (n: SNode) => segColors.get(firstSegment(n.name)) ?? defaultColor
|
||||
const linkSourceColor = (l: SLink) => {
|
||||
const src = nodeMap.get(l.source)
|
||||
return src ? nodeColor(src) : linkColor
|
||||
}
|
||||
|
||||
// Link path generator
|
||||
const linkPath = (l: SLink) => {
|
||||
const src = nodeMap.get(l.source)
|
||||
const tgt = nodeMap.get(l.target)
|
||||
if (!src || !tgt) return ''
|
||||
const sy = l.sourceY ?? src.y
|
||||
const ty = l.targetY ?? tgt.y
|
||||
const sx = src.x + NODE_WIDTH
|
||||
const tx = tgt.x
|
||||
const gap = tx - sx
|
||||
const c1x = sx + gap / 3
|
||||
const c2x = tx - gap / 3
|
||||
return `M ${sx},${sy} C ${c1x},${sy} ${c2x},${ty} ${tx},${ty}`
|
||||
}
|
||||
|
||||
svg.attr('width', width).attr('height', height)
|
||||
const g = svg.append('g')
|
||||
|
||||
// ── Draw links ────────────────────────────────────────
|
||||
g.selectAll('.link')
|
||||
.data(links)
|
||||
.join('path')
|
||||
.attr('class', 'link')
|
||||
.attr('d', linkPath)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', (d) => linkSourceColor(d))
|
||||
.attr('stroke-width', (d) => heightScale(d.value))
|
||||
.attr('opacity', LINK_OPACITY)
|
||||
.attr('data-source', (d) => d.source)
|
||||
.attr('data-target', (d) => d.target)
|
||||
.style('pointer-events', 'none')
|
||||
|
||||
// ── Tooltip ───────────────────────────────────────────
|
||||
const tooltip = d3.select('body').append('div')
|
||||
.style('position', 'absolute')
|
||||
.style('visibility', 'hidden')
|
||||
.style('background', isDark ? '#262626' : '#f5f5f5')
|
||||
.style('border', `1px solid ${isDark ? '#404040' : '#d4d4d4'}`)
|
||||
.style('border-radius', '8px')
|
||||
.style('padding', '8px 12px')
|
||||
.style('font-size', '12px')
|
||||
.style('color', isDark ? '#fff' : '#171717')
|
||||
.style('pointer-events', 'none')
|
||||
.style('z-index', '9999')
|
||||
.style('box-shadow', '0 4px 12px rgba(0,0,0,0.15)')
|
||||
|
||||
// ── Draw nodes ────────────────────────────────────────
|
||||
const nodeGs = g.selectAll('.node')
|
||||
.data(nodes)
|
||||
.join('g')
|
||||
.attr('class', 'node')
|
||||
.attr('transform', (d) => `translate(${d.x},${d.y - d.height / 2})`)
|
||||
.style('cursor', 'pointer')
|
||||
|
||||
// Node bars
|
||||
nodeGs.append('rect')
|
||||
.attr('class', 'node-rect')
|
||||
.attr('width', NODE_WIDTH)
|
||||
.attr('height', (d) => d.height)
|
||||
.attr('fill', (d) => nodeColor(d))
|
||||
.attr('rx', 2)
|
||||
.attr('ry', 2)
|
||||
|
||||
// Node labels
|
||||
nodeGs.append('text')
|
||||
.attr('class', 'node-text')
|
||||
.attr('x', NODE_WIDTH + 6)
|
||||
.attr('y', (d) => d.height / 2 + 4)
|
||||
.text((d) => smartLabel(d.name))
|
||||
.attr('font-size', '12px')
|
||||
.attr('fill', textColor)
|
||||
.attr('text-anchor', 'start')
|
||||
|
||||
// ── Hover: find all connected paths ───────────────────
|
||||
const findConnected = (startLink: SLink, dir: 'fwd' | 'bwd') => {
|
||||
const result: SLink[] = []
|
||||
const visited = new Set<string>()
|
||||
const queue = [startLink]
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.shift()!
|
||||
const lid = `${cur.source}|${cur.target}`
|
||||
if (visited.has(lid)) continue
|
||||
visited.add(lid)
|
||||
result.push(cur)
|
||||
if (dir === 'fwd') {
|
||||
const tgt = nodeMap.get(cur.target)
|
||||
if (tgt) tgt.outLinks.forEach((l) => queue.push(l))
|
||||
} else {
|
||||
const src = nodeMap.get(cur.source)
|
||||
if (src) src.inLinks.forEach((l) => queue.push(l))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const highlightPaths = (nodeId: string) => {
|
||||
const connectedLinks: SLink[] = []
|
||||
const connectedNodes = new Set<string>([nodeId])
|
||||
const directLinks = links.filter((l) => l.source === nodeId || l.target === nodeId)
|
||||
for (const dl of directLinks) {
|
||||
connectedLinks.push(dl, ...findConnected(dl, 'fwd'), ...findConnected(dl, 'bwd'))
|
||||
}
|
||||
const connectedLinkIds = new Set(connectedLinks.map((l) => `${l.source}|${l.target}`))
|
||||
connectedLinks.forEach((l) => { connectedNodes.add(l.source); connectedNodes.add(l.target) })
|
||||
|
||||
g.selectAll<SVGPathElement, SLink>('.link')
|
||||
.attr('opacity', function () {
|
||||
const s = d3.select(this).attr('data-source')
|
||||
const t = d3.select(this).attr('data-target')
|
||||
return connectedLinkIds.has(`${s}|${t}`) ? LINK_HOVER_OPACITY : 0.05
|
||||
})
|
||||
g.selectAll<SVGRectElement, SNode>('.node-rect')
|
||||
.attr('opacity', (d) => connectedNodes.has(d.id) ? 1 : 0.15)
|
||||
g.selectAll<SVGTextElement, SNode>('.node-text')
|
||||
.attr('opacity', (d) => connectedNodes.has(d.id) ? 1 : 0.2)
|
||||
}
|
||||
|
||||
const resetHighlight = () => {
|
||||
g.selectAll('.link').attr('opacity', LINK_OPACITY)
|
||||
.attr('stroke', (d: unknown) => linkSourceColor(d as SLink))
|
||||
g.selectAll('.node-rect').attr('opacity', 1)
|
||||
g.selectAll('.node-text').attr('opacity', 1)
|
||||
tooltip.style('visibility', 'hidden')
|
||||
}
|
||||
|
||||
// Node hover
|
||||
nodeGs
|
||||
.on('mouseenter', function (event, d) {
|
||||
tooltip.style('visibility', 'visible')
|
||||
.html(`<div style="font-weight:600;margin-bottom:2px">${d.name}</div><div style="opacity:0.7">${d.count.toLocaleString()} sessions</div>`)
|
||||
.style('top', `${event.pageY - 10}px`).style('left', `${event.pageX + 12}px`)
|
||||
highlightPaths(d.id)
|
||||
})
|
||||
.on('mousemove', (event) => {
|
||||
tooltip.style('top', `${event.pageY - 10}px`).style('left', `${event.pageX + 12}px`)
|
||||
})
|
||||
.on('mouseleave', resetHighlight)
|
||||
.on('click', (_, d) => handleNodeClick(d.name))
|
||||
|
||||
// Link hit areas (wider invisible paths for easier hovering)
|
||||
g.selectAll('.link-hit')
|
||||
.data(links)
|
||||
.join('path')
|
||||
.attr('class', 'link-hit')
|
||||
.attr('d', linkPath)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', 'transparent')
|
||||
.attr('stroke-width', (d) => Math.max(heightScale(d.value), 14))
|
||||
.attr('data-source', (d) => d.source)
|
||||
.attr('data-target', (d) => d.target)
|
||||
.style('cursor', 'pointer')
|
||||
.on('mouseenter', function (event, d) {
|
||||
const src = nodeMap.get(d.source)
|
||||
const tgt = nodeMap.get(d.target)
|
||||
tooltip.style('visibility', 'visible')
|
||||
.html(`<div style="font-weight:600;margin-bottom:2px">${src?.name ?? '?'} → ${tgt?.name ?? '?'}</div><div style="opacity:0.7">${d.value.toLocaleString()} sessions</div>`)
|
||||
.style('top', `${event.pageY - 10}px`).style('left', `${event.pageX + 12}px`)
|
||||
// Highlight this link's connected paths
|
||||
const all = [d, ...findConnected(d, 'fwd'), ...findConnected(d, 'bwd')]
|
||||
const lids = new Set(all.map((l) => `${l.source}|${l.target}`))
|
||||
const nids = new Set<string>()
|
||||
all.forEach((l) => { nids.add(l.source); nids.add(l.target) })
|
||||
g.selectAll<SVGPathElement, SLink>('.link')
|
||||
.attr('opacity', function () {
|
||||
const s = d3.select(this).attr('data-source')
|
||||
const t = d3.select(this).attr('data-target')
|
||||
return lids.has(`${s}|${t}`) ? LINK_HOVER_OPACITY : 0.05
|
||||
})
|
||||
g.selectAll<SVGRectElement, SNode>('.node-rect')
|
||||
.attr('opacity', (nd) => nids.has(nd.id) ? 1 : 0.15)
|
||||
g.selectAll<SVGTextElement, SNode>('.node-text')
|
||||
.attr('opacity', (nd) => nids.has(nd.id) ? 1 : 0.2)
|
||||
})
|
||||
.on('mousemove', (event) => {
|
||||
tooltip.style('top', `${event.pageY - 10}px`).style('left', `${event.pageX + 12}px`)
|
||||
})
|
||||
.on('mouseleave', resetHighlight)
|
||||
|
||||
return () => { tooltip.remove() }
|
||||
}, [data, containerWidth, isDark, handleNodeClick])
|
||||
|
||||
// ─── Empty state ────────────────────────────────────────────────
|
||||
if (!transitions.length || data.nodes.length === 0) {
|
||||
return (
|
||||
@@ -256,34 +522,10 @@ export default function SankeyJourney({
|
||||
)
|
||||
}
|
||||
|
||||
const labelColor = isDark ? '#a3a3a3' : '#525252'
|
||||
|
||||
// * Scale height based on max nodes in any step so blocks aren't too compressed
|
||||
const stepCounts = new Map<number, number>()
|
||||
for (const n of data.nodes) {
|
||||
stepCounts.set(n.stepIndex, (stepCounts.get(n.stepIndex) ?? 0) + 1)
|
||||
}
|
||||
const maxNodesInStep = Math.max(1, ...Array.from(stepCounts.values()))
|
||||
const chartHeight = Math.max(400, Math.min(700, maxNodesInStep * 45))
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [chartWidth, setChartWidth] = useState(800)
|
||||
|
||||
// * Measure container width and resize chart to fit without horizontal scroll
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const measure = () => setChartWidth(el.clientWidth)
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Filter reset bar */}
|
||||
{filterPath && (
|
||||
<div className="flex items-center gap-2 mb-3 px-3 py-2 rounded-lg bg-brand-orange/10 dark:bg-brand-orange/10 text-sm">
|
||||
<div className="flex items-center gap-2 mb-3 px-3 py-2 rounded-lg bg-brand-orange/10 text-sm">
|
||||
<span className="text-neutral-700 dark:text-neutral-300">
|
||||
Showing flows through{' '}
|
||||
<span className="font-medium text-neutral-900 dark:text-white">
|
||||
@@ -301,71 +543,8 @@ export default function SankeyJourney({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={containerRef} style={{ height: chartHeight }}>
|
||||
<div style={{ width: chartWidth, height: chartHeight }}>
|
||||
<Sankey<SankeyNode, SankeyLink>
|
||||
data={data}
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
margin={{ top: 8, right: 16, bottom: 8, left: 16 }}
|
||||
align="justify"
|
||||
sort="descending"
|
||||
colors={(node) =>
|
||||
COLUMN_COLORS[node.stepIndex % COLUMN_COLORS.length]
|
||||
}
|
||||
nodeThickness={100}
|
||||
nodeSpacing={4}
|
||||
nodeInnerPadding={2}
|
||||
nodeBorderWidth={0}
|
||||
nodeBorderRadius={4}
|
||||
nodeOpacity={0.9}
|
||||
nodeHoverOpacity={1}
|
||||
nodeHoverOthersOpacity={0.3}
|
||||
linkOpacity={0.12}
|
||||
linkHoverOpacity={0.4}
|
||||
linkHoverOthersOpacity={0.02}
|
||||
linkContract={2}
|
||||
enableLinkGradient
|
||||
enableLabels
|
||||
label={(node) => smartLabel(pathFromId(node.id))}
|
||||
labelPosition="inside"
|
||||
labelPadding={8}
|
||||
labelTextColor="#ffffff"
|
||||
isInteractive
|
||||
onClick={handleClick}
|
||||
nodeTooltip={({ node }) => (
|
||||
<div className="rounded-lg px-3 py-2 text-sm shadow-lg bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700">
|
||||
<div className="font-medium text-neutral-900 dark:text-white">
|
||||
{pathFromId(node.id)}
|
||||
</div>
|
||||
<div className="text-neutral-500 dark:text-neutral-400 text-xs mt-0.5">
|
||||
Step {node.stepIndex + 1} ·{' '}
|
||||
{node.value.toLocaleString()} sessions
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
linkTooltip={({ link }) => (
|
||||
<div className="rounded-lg px-3 py-2 text-sm shadow-lg bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700">
|
||||
<div className="font-medium text-neutral-900 dark:text-white">
|
||||
{pathFromId(link.source.id)} →{' '}
|
||||
{pathFromId(link.target.id)}
|
||||
</div>
|
||||
<div className="text-neutral-500 dark:text-neutral-400 text-xs mt-0.5">
|
||||
{link.value.toLocaleString()} sessions
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
theme={{
|
||||
tooltip: {
|
||||
container: {
|
||||
background: 'transparent',
|
||||
boxShadow: 'none',
|
||||
padding: 0,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div ref={containerRef} className="w-full overflow-hidden">
|
||||
<svg ref={svgRef} className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
835
package-lock.json
generated
835
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,15 +14,16 @@
|
||||
"dependencies": {
|
||||
"@ciphera-net/ui": "^0.2.7",
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"@nivo/sankey": "^0.99.0",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@stripe/react-stripe-js": "^5.6.0",
|
||||
"@stripe/stripe-js": "^8.7.0",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@types/d3": "^7.4.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cobe": "^0.6.5",
|
||||
"country-flag-icons": "^1.6.4",
|
||||
"d3": "^7.9.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"framer-motion": "^12.23.26",
|
||||
"html-to-image": "^1.11.13",
|
||||
|
||||
Reference in New Issue
Block a user