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:
Usman Baig
2026-03-16 21:56:22 +01:00
parent 4007056e44
commit 17f2bdc9e9
3 changed files with 914 additions and 553 deletions

View File

@@ -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} &middot;{' '}
{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)} &rarr;{' '}
{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

File diff suppressed because it is too large Load Diff

View File

@@ -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",