172 lines
4.4 KiB
TypeScript
172 lines
4.4 KiB
TypeScript
'use client'
|
|
|
|
import { useMemo } from 'react'
|
|
import {
|
|
sankey as d3Sankey,
|
|
sankeyLinkHorizontal,
|
|
SankeyNode,
|
|
SankeyLink,
|
|
} from 'd3-sankey'
|
|
import { FlowData } from '@/lib/types'
|
|
import AnimatedPipe from './AnimatedPipe'
|
|
import FlowParticles from './FlowParticles'
|
|
import WaterDistortion from './WaterDistortion'
|
|
|
|
interface SankeyDiagramProps {
|
|
data: FlowData
|
|
width?: number
|
|
height?: number
|
|
showLabels?: boolean
|
|
speed?: 'slow' | 'normal' | 'fast'
|
|
showParticles?: boolean
|
|
showDistortion?: boolean
|
|
maxParticles?: number
|
|
className?: string
|
|
}
|
|
|
|
interface SNode {
|
|
id: string
|
|
label?: string
|
|
color?: string
|
|
}
|
|
|
|
interface SLink {
|
|
source: string
|
|
target: string
|
|
value: number
|
|
color?: string
|
|
}
|
|
|
|
export default function SankeyDiagram({
|
|
data,
|
|
width = 800,
|
|
height = 400,
|
|
showLabels = false,
|
|
speed = 'normal',
|
|
showParticles = true,
|
|
showDistortion = false,
|
|
maxParticles = 6,
|
|
className = '',
|
|
}: SankeyDiagramProps) {
|
|
const { nodes, links } = useMemo(() => {
|
|
const sankeyNodes = data.nodes.map(n => ({ ...n }))
|
|
const sankeyLinks = data.links.map(l => ({
|
|
source: l.source,
|
|
target: l.target,
|
|
value: l.value,
|
|
color: l.color,
|
|
}))
|
|
|
|
const generator = (d3Sankey as any)()
|
|
.nodeId((d: any) => d.id)
|
|
.nodeWidth(12)
|
|
.nodePadding(16)
|
|
.extent([[1, 1], [width - 1, height - 1]])
|
|
|
|
const result = generator({
|
|
nodes: sankeyNodes,
|
|
links: sankeyLinks,
|
|
})
|
|
|
|
return {
|
|
nodes: result.nodes as any[],
|
|
links: result.links as any[],
|
|
}
|
|
}, [data, width, height])
|
|
|
|
const linkPath = sankeyLinkHorizontal()
|
|
const filterId = `distortion-${Math.random().toString(36).slice(2, 7)}`
|
|
|
|
let particleCount = 0
|
|
|
|
return (
|
|
<svg
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
className={`w-full h-auto ${className}`}
|
|
preserveAspectRatio="xMidYMid meet"
|
|
>
|
|
<defs>
|
|
{showDistortion && <WaterDistortion id={filterId} />}
|
|
{/* Define paths for particles */}
|
|
{links.map((link, i) => {
|
|
const d = linkPath(link as any)
|
|
return d ? (
|
|
<path key={`path-${i}`} id={`flow-path-${i}`} d={d} fill="none" />
|
|
) : null
|
|
})}
|
|
</defs>
|
|
|
|
<g
|
|
className={showDistortion ? 'water-distortion' : ''}
|
|
filter={showDistortion ? `url(#${filterId})` : undefined}
|
|
>
|
|
{/* Links */}
|
|
{links.map((link, i) => {
|
|
const d = linkPath(link as any)
|
|
if (!d) return null
|
|
const linkWidth = Math.max(2, (link as any).width || 4)
|
|
return (
|
|
<AnimatedPipe
|
|
key={`link-${i}`}
|
|
d={d}
|
|
width={linkWidth}
|
|
color={link.color || '#2dd4bf'}
|
|
speed={speed}
|
|
opacity={0.6}
|
|
/>
|
|
)
|
|
})}
|
|
|
|
{/* Particles */}
|
|
{showParticles && links.map((link, i) => {
|
|
if (particleCount >= maxParticles) return null
|
|
const pCount = Math.min(2, maxParticles - particleCount)
|
|
particleCount += pCount
|
|
return (
|
|
<FlowParticles
|
|
key={`particles-${i}`}
|
|
pathId={`flow-path-${i}`}
|
|
count={pCount}
|
|
color={link.color?.replace(/[0-9a-f]{2}$/i, 'ff') || '#2dd4bf'}
|
|
size={2}
|
|
duration={speed === 'fast' ? 2 : speed === 'slow' ? 5 : 3}
|
|
/>
|
|
)
|
|
})}
|
|
|
|
{/* Nodes */}
|
|
{nodes.map((node, i) => (
|
|
<g key={`node-${i}`}>
|
|
<rect
|
|
x={node.x0}
|
|
y={node.y0}
|
|
width={(node.x1 ?? 0) - (node.x0 ?? 0)}
|
|
height={(node.y1 ?? 0) - (node.y0 ?? 0)}
|
|
fill={node.color || '#2dd4bf'}
|
|
rx={3}
|
|
opacity={0.8}
|
|
/>
|
|
{showLabels && node.label && (
|
|
<text
|
|
x={(node.x0 ?? 0) < width / 2
|
|
? (node.x1 ?? 0) + 8
|
|
: (node.x0 ?? 0) - 8
|
|
}
|
|
y={((node.y0 ?? 0) + (node.y1 ?? 0)) / 2}
|
|
textAnchor={(node.x0 ?? 0) < width / 2 ? 'start' : 'end'}
|
|
dominantBaseline="middle"
|
|
fill="#f5f5dc"
|
|
fontSize={12}
|
|
fontFamily="'Courier New', monospace"
|
|
opacity={0.7}
|
|
>
|
|
{node.label}
|
|
</text>
|
|
)}
|
|
</g>
|
|
))}
|
|
</g>
|
|
</svg>
|
|
)
|
|
}
|