feat: replace MUI X Charts Pro with d3-sankey custom Sankey
Remove paid MUI dependency. Use d3-sankey (MIT, ~5KB) for layout algorithm + custom SVG rendering. Same visual quality: smooth bezier ribbon links, proper node spacing via sankeyJustify, label backgrounds, hover dimming, exit nodes.
This commit is contained in:
@@ -1,18 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useTheme } from '@ciphera-net/ui'
|
import { useTheme } from '@ciphera-net/ui'
|
||||||
import { TreeStructure } from '@phosphor-icons/react'
|
import { TreeStructure } from '@phosphor-icons/react'
|
||||||
import { createTheme, ThemeProvider } from '@mui/material/styles'
|
import { sankey, sankeyJustify } from 'd3-sankey'
|
||||||
import {
|
import type {
|
||||||
SankeyDataProvider,
|
SankeyNode as D3SankeyNode,
|
||||||
SankeyLinkPlot,
|
SankeyLink as D3SankeyLink,
|
||||||
SankeyNodePlot,
|
SankeyExtraProperties,
|
||||||
SankeyNodeLabelPlot,
|
} from 'd3-sankey'
|
||||||
SankeyTooltip,
|
|
||||||
} from '@mui/x-charts-pro/SankeyChart'
|
|
||||||
import { ChartsWrapper } from '@mui/x-charts-pro/ChartsWrapper'
|
|
||||||
import { ChartsSurface } from '@mui/x-charts-pro/ChartsSurface'
|
|
||||||
import type { PathTransition } from '@/lib/api/journeys'
|
import type { PathTransition } from '@/lib/api/journeys'
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────
|
||||||
@@ -24,55 +20,70 @@ interface SankeyDiagramProps {
|
|||||||
onNodeClick?: (path: string) => void
|
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 BRAND_ORANGE = '#FD5E0F'
|
||||||
|
const EXIT_GREY = '#595b63'
|
||||||
|
const SVG_W = 1000
|
||||||
|
const SVG_H = 500
|
||||||
|
const MARGIN = { top: 10, right: 130, bottom: 10, left: 10 }
|
||||||
|
|
||||||
// ─── Data transformation ────────────────────────────────────────────
|
// ─── Data transformation ────────────────────────────────────────────
|
||||||
|
|
||||||
const NODE_COLOR = '#FD5E0F'
|
function buildSankeyData(transitions: PathTransition[], depth: number) {
|
||||||
const EXIT_COLOR = '#595b63'
|
const numCols = depth + 1
|
||||||
|
const nodeMap = new Map<string, NodeExtra>()
|
||||||
function transformToSankeyData(transitions: PathTransition[], depth: number) {
|
const links: Array<{ source: string; target: string; value: number }> = []
|
||||||
const numColumns = depth + 1
|
|
||||||
const nodeMap = new Map<string, { id: string; label: string; color: string }>()
|
|
||||||
const links: { source: string; target: string; value: number }[] = []
|
|
||||||
|
|
||||||
// Track flow in/out per node to compute exits
|
|
||||||
const flowIn = new Map<string, number>()
|
|
||||||
const flowOut = new Map<string, number>()
|
const flowOut = new Map<string, number>()
|
||||||
|
const flowIn = new Map<string, number>()
|
||||||
|
|
||||||
for (const t of transitions) {
|
for (const t of transitions) {
|
||||||
if (t.step_index >= numColumns || t.step_index + 1 >= numColumns) continue
|
if (t.step_index >= numCols || t.step_index + 1 >= numCols) continue
|
||||||
|
|
||||||
const fromId = `${t.step_index}:${t.from_path}`
|
const fromId = `${t.step_index}:${t.from_path}`
|
||||||
const toId = `${t.step_index + 1}:${t.to_path}`
|
const toId = `${t.step_index + 1}:${t.to_path}`
|
||||||
|
|
||||||
if (!nodeMap.has(fromId)) {
|
if (!nodeMap.has(fromId)) {
|
||||||
nodeMap.set(fromId, { id: fromId, label: t.from_path, color: NODE_COLOR })
|
nodeMap.set(fromId, { id: fromId, label: t.from_path, color: BRAND_ORANGE })
|
||||||
}
|
}
|
||||||
if (!nodeMap.has(toId)) {
|
if (!nodeMap.has(toId)) {
|
||||||
nodeMap.set(toId, { id: toId, label: t.to_path, color: NODE_COLOR })
|
nodeMap.set(toId, { id: toId, label: t.to_path, color: BRAND_ORANGE })
|
||||||
}
|
}
|
||||||
|
|
||||||
links.push({ source: fromId, target: toId, value: t.session_count })
|
links.push({ source: fromId, target: toId, value: t.session_count })
|
||||||
|
|
||||||
flowOut.set(fromId, (flowOut.get(fromId) ?? 0) + t.session_count)
|
flowOut.set(fromId, (flowOut.get(fromId) ?? 0) + t.session_count)
|
||||||
flowIn.set(toId, (flowIn.get(toId) ?? 0) + t.session_count)
|
flowIn.set(toId, (flowIn.get(toId) ?? 0) + t.session_count)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add exit nodes for flows that don't continue
|
// Add exit nodes for flows that don't continue to the next step
|
||||||
for (const [nodeId, node] of nodeMap) {
|
for (const [nodeId] of nodeMap) {
|
||||||
|
const col = parseInt(nodeId.split(':')[0], 10)
|
||||||
|
if (col >= numCols - 1) continue // last column — all exit implicitly
|
||||||
|
|
||||||
const totalIn = flowIn.get(nodeId) ?? 0
|
const totalIn = flowIn.get(nodeId) ?? 0
|
||||||
const totalOut = flowOut.get(nodeId) ?? 0
|
const totalOut = flowOut.get(nodeId) ?? 0
|
||||||
const flow = Math.max(totalIn, totalOut)
|
const flow = Math.max(totalIn, totalOut)
|
||||||
const exitCount = flow - totalOut
|
const exitCount = flow - totalOut
|
||||||
|
|
||||||
if (exitCount > 0) {
|
if (exitCount > 0) {
|
||||||
const col = parseInt(nodeId.split(':')[0], 10)
|
const exitId = `exit-${col + 1}`
|
||||||
if (col < numColumns - 1) {
|
if (!nodeMap.has(exitId)) {
|
||||||
const exitId = `${col + 1}:(exit)`
|
nodeMap.set(exitId, { id: exitId, label: '(exit)', color: EXIT_GREY })
|
||||||
if (!nodeMap.has(exitId)) {
|
|
||||||
nodeMap.set(exitId, { id: exitId, label: '(exit)', color: EXIT_COLOR })
|
|
||||||
}
|
|
||||||
links.push({ source: nodeId, target: exitId, value: exitCount })
|
|
||||||
}
|
}
|
||||||
|
links.push({ source: nodeId, target: exitId, value: exitCount })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,11 +93,36 @@ function transformToSankeyData(transitions: PathTransition[], depth: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const valueFormatter = (value: number, context: { type: string }) => {
|
// ─── SVG path for a link ribbon ─────────────────────────────────────
|
||||||
if (context.type === 'link') {
|
|
||||||
return `${value.toLocaleString()} sessions`
|
function ribbonPath(link: LayoutLink): string {
|
||||||
}
|
const src = link.source as LayoutNode
|
||||||
return `${value.toLocaleString()} sessions total`
|
const tgt = link.target as LayoutNode
|
||||||
|
const sx = src.x1!
|
||||||
|
const tx = tgt.x0!
|
||||||
|
const sy = link.y0!
|
||||||
|
const ty = link.y1!
|
||||||
|
const w = link.width!
|
||||||
|
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(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Label helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function truncateLabel(s: string, max: number) {
|
||||||
|
return s.length > max ? s.slice(0, max - 1) + '\u2026' : s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approximate text width at 11px system font (~6.5px per char)
|
||||||
|
function estimateTextWidth(s: string) {
|
||||||
|
return s.length * 6.5
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Component ──────────────────────────────────────────────────────
|
// ─── Component ──────────────────────────────────────────────────────
|
||||||
@@ -99,21 +135,34 @@ export default function SankeyDiagram({
|
|||||||
}: SankeyDiagramProps) {
|
}: SankeyDiagramProps) {
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
const isDark = resolvedTheme === 'dark'
|
const isDark = resolvedTheme === 'dark'
|
||||||
|
const [hovered, setHovered] = useState<string | null>(null)
|
||||||
const muiTheme = useMemo(
|
|
||||||
() =>
|
|
||||||
createTheme({
|
|
||||||
palette: { mode: isDark ? 'dark' : 'light' },
|
|
||||||
}),
|
|
||||||
[isDark],
|
|
||||||
)
|
|
||||||
|
|
||||||
const data = useMemo(
|
const data = useMemo(
|
||||||
() => transformToSankeyData(transitions, depth),
|
() => buildSankeyData(transitions, depth),
|
||||||
[transitions, depth],
|
[transitions, depth],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!transitions.length || !data.links.length) {
|
const layout = useMemo(() => {
|
||||||
|
if (!data.links.length) return null
|
||||||
|
|
||||||
|
const generator = sankey<NodeExtra, LinkExtra>()
|
||||||
|
.nodeId((d) => d.id)
|
||||||
|
.nodeWidth(9)
|
||||||
|
.nodePadding(20)
|
||||||
|
.nodeAlign(sankeyJustify)
|
||||||
|
.extent([
|
||||||
|
[MARGIN.left, MARGIN.top],
|
||||||
|
[SVG_W - MARGIN.right, SVG_H - MARGIN.bottom],
|
||||||
|
])
|
||||||
|
|
||||||
|
return generator({
|
||||||
|
nodes: data.nodes.map((d) => ({ ...d })),
|
||||||
|
links: data.links.map((d) => ({ ...d })),
|
||||||
|
})
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
// ─── Empty state ────────────────────────────────────────────────
|
||||||
|
if (!transitions.length || !layout) {
|
||||||
return (
|
return (
|
||||||
<div className="h-[400px] flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
<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">
|
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||||
@@ -129,40 +178,143 @@ export default function SankeyDiagram({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Colors ─────────────────────────────────────────────────────
|
||||||
|
const labelColor = isDark ? '#d4d4d4' : '#525252'
|
||||||
|
const labelBg = isDark ? 'rgba(23, 23, 23, 0.85)' : 'rgba(255, 255, 255, 0.85)'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={muiTheme}>
|
<svg
|
||||||
<div style={{ width: '100%', height: 500 }}>
|
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
|
||||||
<SankeyDataProvider
|
preserveAspectRatio="xMidYMid meet"
|
||||||
series={[
|
className="w-full"
|
||||||
{
|
role="img"
|
||||||
type: 'sankey' as const,
|
aria-label="User journey Sankey diagram"
|
||||||
data,
|
>
|
||||||
valueFormatter,
|
{/* Links */}
|
||||||
nodeOptions: {
|
<g>
|
||||||
sort: 'auto',
|
{layout.links.map((link, i) => {
|
||||||
padding: 20,
|
const src = link.source as LayoutNode
|
||||||
width: 9,
|
const tgt = link.target as LayoutNode
|
||||||
showLabels: true,
|
const linkId = `${src.id}->${tgt.id}`
|
||||||
},
|
const isHovered = hovered === linkId
|
||||||
linkOptions: {
|
const someHovered = hovered !== null
|
||||||
color: 'source',
|
|
||||||
opacity: 0.6,
|
let opacity = 0.6
|
||||||
curveCorrection: 0,
|
if (isHovered) opacity = 0.8
|
||||||
},
|
else if (someHovered) opacity = 0.15
|
||||||
},
|
|
||||||
]}
|
return (
|
||||||
margin={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
<path
|
||||||
>
|
key={i}
|
||||||
<ChartsWrapper>
|
d={ribbonPath(link)}
|
||||||
<ChartsSurface>
|
fill={src.color}
|
||||||
<SankeyNodePlot />
|
opacity={opacity}
|
||||||
<SankeyLinkPlot />
|
style={{ transition: 'opacity 0.15s ease' }}
|
||||||
<SankeyNodeLabelPlot />
|
onMouseEnter={() => setHovered(linkId)}
|
||||||
</ChartsSurface>
|
onMouseLeave={() => setHovered(null)}
|
||||||
<SankeyTooltip trigger="item" />
|
>
|
||||||
</ChartsWrapper>
|
<title>
|
||||||
</SankeyDataProvider>
|
{src.label} → {tgt.label}:{' '}
|
||||||
</div>
|
{(link.value as number).toLocaleString()} sessions
|
||||||
</ThemeProvider>
|
</title>
|
||||||
|
</path>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Nodes */}
|
||||||
|
<g>
|
||||||
|
{layout.nodes.map((node) => {
|
||||||
|
const isExit = node.id!.toString().startsWith('exit-')
|
||||||
|
const w = (node.x1 ?? 0) - (node.x0 ?? 0)
|
||||||
|
const h = (node.y1 ?? 0) - (node.y0 ?? 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
key={node.id}
|
||||||
|
x={node.x0}
|
||||||
|
y={node.y0}
|
||||||
|
width={w}
|
||||||
|
height={h}
|
||||||
|
fill={node.color}
|
||||||
|
rx={2}
|
||||||
|
className={
|
||||||
|
onNodeClick && !isExit ? 'cursor-pointer' : 'cursor-default'
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (onNodeClick && !isExit) onNodeClick(node.label)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<title>
|
||||||
|
{node.label} — {(node.value ?? 0).toLocaleString()} sessions
|
||||||
|
</title>
|
||||||
|
</rect>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Labels with background */}
|
||||||
|
<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 < 14) return null
|
||||||
|
|
||||||
|
const label = truncateLabel(node.label, 28)
|
||||||
|
const textW = estimateTextWidth(label)
|
||||||
|
const padX = 4
|
||||||
|
const padY = 2
|
||||||
|
const rectW = textW + padX * 2
|
||||||
|
const rectH = 16
|
||||||
|
|
||||||
|
// Labels go right of node; last-column labels go left
|
||||||
|
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
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={`label-${node.id}`}>
|
||||||
|
<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={11}
|
||||||
|
fontFamily="system-ui, -apple-system, sans-serif"
|
||||||
|
className={
|
||||||
|
onNodeClick && !node.id!.toString().startsWith('exit-')
|
||||||
|
? 'cursor-pointer'
|
||||||
|
: 'cursor-default'
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
onNodeClick &&
|
||||||
|
!node.id!.toString().startsWith('exit-')
|
||||||
|
)
|
||||||
|
onNodeClick(node.label)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
1076
package-lock.json
generated
1076
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,10 +14,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ciphera-net/ui": "^0.2.5",
|
"@ciphera-net/ui": "^0.2.5",
|
||||||
"@ducanh2912/next-pwa": "^10.2.9",
|
"@ducanh2912/next-pwa": "^10.2.9",
|
||||||
"@emotion/react": "^11.14.0",
|
|
||||||
"@emotion/styled": "^11.14.1",
|
|
||||||
"@mui/material": "^7.3.9",
|
|
||||||
"@mui/x-charts-pro": "^8.27.5",
|
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
"@stripe/react-stripe-js": "^5.6.0",
|
"@stripe/react-stripe-js": "^5.6.0",
|
||||||
@@ -27,6 +23,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cobe": "^0.6.5",
|
"cobe": "^0.6.5",
|
||||||
"country-flag-icons": "^1.6.4",
|
"country-flag-icons": "^1.6.4",
|
||||||
|
"d3-sankey": "^0.12.3",
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
@@ -55,6 +52,7 @@
|
|||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/d3-sankey": "^0.12.5",
|
||||||
"@types/d3-scale": "^4.0.9",
|
"@types/d3-scale": "^4.0.9",
|
||||||
"@types/node": "^20.14.12",
|
"@types/node": "^20.14.12",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
|
|||||||
Reference in New Issue
Block a user