openreplay/frontend/app/components/Charts/SankeyChart.tsx
2025-02-10 09:54:15 +01:00

437 lines
12 KiB
TypeScript

import React from 'react';
import { echarts, defaultOptions } from './init';
import { SankeyChart } from 'echarts/charts';
import { sankeyTooltip, getEventPriority, getNodeName } from './sankeyUtils';
import { NoContent } from 'App/components/ui';
import { InfoCircleOutlined } from '@ant-design/icons';
import { X } from 'lucide-react';
echarts.use([SankeyChart]);
interface SankeyNode {
name: string | null;
eventType?: string;
depth?: number;
id?: string | number;
}
interface SankeyLink {
source: number | string;
target: number | string;
value: number;
sessionsCount: number;
eventType?: string;
}
interface Data {
nodes: SankeyNode[];
links: SankeyLink[];
}
interface Props {
data: Data;
height?: number;
onChartClick?: (filters: any[]) => void;
isUngrouped?: boolean;
}
function buildSubgraph(
startNodeId: string | number,
nodes: SankeyNode[],
links: SankeyLink[]
) {
const visited = new Set<string | number>();
const queue = [startNodeId];
visited.add(startNodeId);
const adjacency: Record<string | number, Array<string | number>> = {};
links.forEach((link) => {
if (!adjacency[link.source]) {
adjacency[link.source] = [];
}
adjacency[link.source].push(link.target);
});
while (queue.length > 0) {
const current = queue.shift()!;
const neighbors = adjacency[current] || [];
neighbors.forEach((nbr) => {
if (!visited.has(nbr)) {
visited.add(nbr);
queue.push(nbr);
}
});
}
const subNodes = nodes.filter((n) => visited.has(n.id));
const subLinks = links.filter(
(l) => visited.has(l.source) && visited.has(l.target)
);
return { subNodes, subLinks };
}
const EChartsSankey: React.FC<Props> = (props) => {
const { data, height = 240, onChartClick, isUngrouped } = props;
const chartRef = React.useRef<HTMLDivElement>(null);
if (data.nodes.length === 0 || data.links.length === 0) {
return (
<NoContent
style={{ minHeight: height }}
title={
<div className="flex items-center relative">
<InfoCircleOutlined className="hidden md:inline-block mr-1" />
Set a start or end point to visualize the journey. If set, try
adjusting filters.
</div>
}
show={true}
/>
);
}
const [finalNodeCount, setFinalNodeCount] = React.useState(data.nodes.length);
React.useEffect(() => {
if (!chartRef.current) return;
const startNodes = data.nodes.filter((n) => n.depth === 0);
let finalNodes = data.nodes;
let finalLinks = data.links;
if (startNodes.length > 1) {
const chosenStartNode = startNodes[0];
const { subNodes, subLinks } = buildSubgraph(
chosenStartNode.id!,
data.nodes,
data.links
);
finalNodes = subNodes;
finalLinks = subLinks;
}
const chart = echarts.init(chartRef.current);
const maxDepth = 4;
const filteredNodes = finalNodes.filter((n) => (n.depth ?? 0) <= maxDepth);
const filteredLinks = finalLinks.filter((l) => {
const sourceNode = finalNodes.find((n) => n.id === l.source);
const targetNode = finalNodes.find((n) => n.id === l.target);
return (
(sourceNode?.depth ?? 0) <= maxDepth &&
(targetNode?.depth ?? 0) <= maxDepth
);
});
setFinalNodeCount(filteredNodes.length);
const echartNodes = filteredNodes
.map((n) => {
let computedName = getNodeName(n.eventType || 'Minor Paths', n.name);
if (computedName === 'Other') {
computedName = 'Others';
}
const itemColor =
computedName === 'Others'
? 'rgba(34,44,154,.9)'
: n.eventType === 'DROP'
? '#B5B7C8'
: '#394eff';
return {
name: computedName,
depth: n.depth,
type: n.eventType,
id: n.id,
draggable: false,
itemStyle: { color: itemColor },
};
})
.sort((a, b) => {
if (a.depth === b.depth) {
return (
getEventPriority(a.type || '') - getEventPriority(b.type || '')
);
} else {
return (a.depth as number) - (b.depth as number);
}
});
const echartLinks = filteredLinks.map((l) => ({
source: echartNodes.findIndex((n) => n.id === l.source),
target: echartNodes.findIndex((n) => n.id === l.target),
value: l.sessionsCount,
percentage: l.value,
lineStyle: { opacity: 0.1 },
}));
if (echartNodes.length === 0) return;
const startNodeValue = echartLinks
.filter((link) => link.source === 0)
.reduce((sum, link) => sum + link.value, 0);
const option = {
...defaultOptions,
tooltip: {
trigger: 'item',
},
toolbox: {
feature: {
saveAsImage: { show: false },
},
},
series: [
{
layoutIterations: 0,
type: 'sankey',
data: echartNodes,
links: echartLinks,
emphasis: {
focus: 'none',
lineStyle: {
opacity: 0.5,
},
},
label: {
show: true,
position: 'top',
textShadowColor: 'transparent',
textBorderColor: 'transparent',
align: 'left',
overflow: 'truncate',
maxWidth: 30,
distance: 3,
offset: [-20, 0],
formatter: function (params: any) {
const nodeVal = params.value;
const percentage = startNodeValue
? ((nodeVal / startNodeValue) * 100).toFixed(1) + '%'
: '0%';
return (
`{header|${params.name}}\n` +
`{body|}{percentage|${percentage}} {sessions|${nodeVal}}`
);
},
rich: {
header: {
fontWeight: '600',
fontSize: 12,
color: '#333',
overflow: 'truncate',
paddingBottom: '.5rem',
},
body: {
fontSize: 12,
color: '#000',
},
percentage: {
fontSize: 12,
color: '#454545',
},
sessions: {
fontSize: 12,
fontFamily: "mono, 'monospace', sans-serif",
color: '#999999',
},
},
},
tooltip: {
formatter: sankeyTooltip(echartNodes, []),
},
nodeAlign: 'left',
nodeWidth: 40,
nodeGap: 40,
lineStyle: {
color: 'source',
curveness: 0.6,
opacity: 0.1,
},
itemStyle: {
color: '#394eff',
borderRadius: 7,
},
},
],
};
chart.setOption(option);
function getUpstreamNodes(nodeIdx: number, visited = new Set<number>()) {
if (visited.has(nodeIdx)) return;
visited.add(nodeIdx);
echartLinks.forEach((link) => {
if (link.target === nodeIdx && !visited.has(link.source)) {
getUpstreamNodes(link.source, visited);
}
});
return visited;
}
function getDownstreamNodes(nodeIdx: number, visited = new Set<number>()) {
if (visited.has(nodeIdx)) return;
visited.add(nodeIdx);
echartLinks.forEach((link) => {
if (link.source === nodeIdx && !visited.has(link.target)) {
getDownstreamNodes(link.target, visited);
}
});
return visited;
}
function getConnectedChain(nodeIdx: number): Set<number> {
const upstream = getUpstreamNodes(nodeIdx) || new Set<number>();
const downstream = getDownstreamNodes(nodeIdx) || new Set<number>();
return new Set<number>([...upstream, ...downstream]);
}
const originalNodes = [...echartNodes];
const originalLinks = [...echartLinks];
chart.on('mouseover', function (params: any) {
if (params.dataType === 'node') {
const hoveredIndex = params.dataIndex;
const connectedChain = getConnectedChain(hoveredIndex);
const updatedNodes = echartNodes.map((node, idx) => {
const baseOpacity = connectedChain.has(idx) ? 1 : 0.35;
const extraStyle =
idx === hoveredIndex
? { borderColor: '#000', borderWidth: 1, borderType: 'dotted' }
: {};
return {
...node,
itemStyle: {
...node.itemStyle,
opacity: baseOpacity,
...extraStyle,
},
};
});
const updatedLinks = echartLinks.map((link) => ({
...link,
lineStyle: {
...link.lineStyle,
opacity:
connectedChain.has(link.source) && connectedChain.has(link.target)
? 0.5
: 0.1,
},
}));
chart.setOption({
series: [
{
data: updatedNodes,
links: updatedLinks,
},
],
});
}
});
chart.on('mouseout', function (params: any) {
if (params.dataType === 'node') {
chart.setOption({
series: [
{
data: originalNodes,
links: originalLinks,
},
],
});
}
});
chart.on('click', function (params: any) {
if (!onChartClick) return;
const unsupported = ['other', 'drop'];
if (params.dataType === 'node') {
const node = params.data;
const filters = []
if (node && node.type) {
const type = node.type.toLowerCase();
if (unsupported.includes(type)) {
return
}
filters.push({
operator: 'is',
type: type,
value: [node.name],
isEvent: true,
});
}
onChartClick?.(filters);
} else if (params.dataType === 'edge') {
const linkIndex = params.dataIndex;
const link = filteredLinks[linkIndex];
const firstNode = data.nodes.find(n => n.id === link.source)
const lastNode = data.nodes.find(n => n.id === link.target)
const firstNodeType = firstNode?.eventType?.toLowerCase() ?? 'location';
const lastNodeType = lastNode?.eventType?.toLowerCase() ?? 'location';
if (unsupported.includes(firstNodeType) || unsupported.includes(lastNodeType)) {
return
}
const filters = [];
if (firstNode) {
filters.push({
operator: 'is',
type: firstNodeType,
value: [firstNode.name],
isEvent: true
});
}
if (lastNode) {
filters.push({
operator: 'is',
type: lastNodeType,
value: [lastNode.name],
isEvent: true
});
}
onChartClick?.(filters);
}
});
const ro = new ResizeObserver(() => chart.resize());
ro.observe(chartRef.current);
return () => {
chart.dispose();
ro.disconnect();
};
}, [data, height, onChartClick]);
let containerStyle: React.CSSProperties;
if (isUngrouped) {
const dynamicMinHeight = finalNodeCount * 15;
containerStyle = {
width: '100%',
minHeight: dynamicMinHeight,
height: '100%',
overflowY: 'auto',
};
} else {
containerStyle = {
width: '100%',
height,
};
}
return (
<div
ref={chartRef}
style={containerStyle}
className="min-w-[600px] overflow-scroll"
/>
);
};
export default EChartsSankey;