ui: work on new sankey chart

This commit is contained in:
nick-delirium 2025-01-21 11:24:37 +01:00
parent 2b7e79eabd
commit 148ae757d3
No known key found for this signature in database
GPG key ID: 93ABD695DF5FDBA0
5 changed files with 253 additions and 10 deletions

View file

@ -0,0 +1,247 @@
// START GEN
import React from 'react';
import { echarts, defaultOptions } from './init';
import { SankeyChart } from 'echarts/charts';
echarts.use([SankeyChart]);
interface SankeyNode {
name: string | null; // e.g. "/en/deployment/", or null
eventType?: string; // e.g. "LOCATION" (not strictly needed by ECharts)
}
interface SankeyLink {
source: number; // index of source node
target: number; // index of target node
value: number; // percentage
sessionsCount: number;
eventType?: string; // optional
}
interface Data {
nodes: SankeyNode[];
links: SankeyLink[];
}
interface Props {
data: Data;
height?: number;
onChartClick?: (filters: any[]) => void;
}
// Your existing function
function findHighestContributors(nodeIndex: number, links: SankeyLink[]) {
// The old code used nodeName, but we actually have nodeIndex in this approach.
// We'll adapt. We'll treat "target === nodeIndex" as the link leading into it.
const contributors: SankeyLink[] = [];
let currentNode = nodeIndex;
while (true) {
let maxContribution = -Infinity;
let primaryLink: SankeyLink | null = null;
for (const link of links) {
if (link.target === currentNode) {
if (link.value > maxContribution) {
maxContribution = link.value;
primaryLink = link;
}
}
}
if (primaryLink) {
contributors.push(primaryLink);
currentNode = primaryLink.source;
} else {
break;
}
}
return contributors;
}
const EChartsSankey: React.FC<Props> = (props) => {
const { data, height = 240, onChartClick } = props;
const chartRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!chartRef.current) return;
const chart = echarts.init(chartRef.current);
let dropDepth = 1;
let othertsDepth = 1;
// 1) Build node/link arrays for ECharts
// We'll store them so we can do highlight/downplay by index.
const echartNodes = data.nodes.map((n, i) => ({
name: n.name ? `${n.name} ${i}` : `${n.eventType} ${i}`,
depth: n.eventType === 'DROP' ? dropDepth++
: n.eventType === "OTHERS" ? othertsDepth++ : undefined
}));
const echartLinks = data.links.map((l) => ({
source: echartNodes[l.source].name,
target: echartNodes[l.target].name,
value: l.sessionsCount,
percentage: l.value,
}));
const option = {
...defaultOptions,
tooltip: {
trigger: 'item'
},
series: [
{
type: 'sankey',
data: echartNodes,
links: echartLinks,
emphasis: {
focus: 'adjacency',
blurScope: 'global'
},
nodeAlign: 'right',
nodeWidth: 10,
nodeGap: 8,
lineStyle: {
color: 'source',
curveness: 0.5,
opacity: 0.3
},
itemStyle: {
color: '#394eff',
borderRadius: 4
}
}
]
};
chart.setOption(option);
// We'll assume there's only one sankey series => seriesIndex=0
const seriesIndex = 0;
// Helper: highlight a given node index
function highlightNode(nodeIdx: number) {
chart.dispatchAction({
type: 'highlight',
seriesIndex,
dataType: 'node',
dataIndex: nodeIdx
});
}
// Helper: highlight a given link index
function highlightLink(linkIdx: number) {
chart.dispatchAction({
type: 'highlight',
seriesIndex,
dataType: 'edge',
dataIndex: linkIdx
});
}
// Helper: downplay (reset) everything
function resetHighlight() {
chart.dispatchAction({
type: 'downplay',
seriesIndex
});
}
// Because ECharts sankey "name" in tooltip is e.g. "NodeName i" => we
// find that index by parsing the last digits or we rely on dataIndex from event
// We'll rely on dataIndex from event which is more direct.
chart.on('click', function (params) {
if (!onChartClick) return;
if (params.dataType === 'node') {
const nodeIndex = params.dataIndex;
const node = data.nodes[nodeIndex];
onChartClick([{ node }]);
} else if (params.dataType === 'edge') {
const linkIndex = params.dataIndex;
const link = data.links[linkIndex];
onChartClick([{ link }]);
}
});
chart.on('mouseover', function (params) {
if (params.seriesIndex !== seriesIndex) return; // ignore if not sankey
resetHighlight(); // dim everything first
if (params.dataType === 'node') {
const hoveredNodeIndex = params.dataIndex;
// find outgoing links
const outgoingLinks: number[] = [];
data.links.forEach((link, linkIdx) => {
if (link.source === hoveredNodeIndex) {
outgoingLinks.push(linkIdx);
}
});
// find incoming highest contributors
const highestContribLinks = findHighestContributors(hoveredNodeIndex, data.links);
// highlight outgoing links
outgoingLinks.forEach((linkIdx) => highlightLink(linkIdx));
// highlight the "highest path" of incoming links
highestContribLinks.forEach((lk) => {
// We need to find which link index in data.links => lk
const linkIndex = data.links.indexOf(lk);
if (linkIndex >= 0) {
highlightLink(linkIndex);
}
});
// highlight the node itself
highlightNode(hoveredNodeIndex);
// highlight the nodes that are "source/target" of the highlighted links
const highlightNodeSet = new Set<number>();
outgoingLinks.forEach((lIdx) => {
highlightNodeSet.add(data.links[lIdx].target);
highlightNodeSet.add(data.links[lIdx].source);
});
highestContribLinks.forEach((lk) => {
highlightNodeSet.add(lk.source);
highlightNodeSet.add(lk.target);
});
// also add the hovered node
highlightNodeSet.add(hoveredNodeIndex);
// highlight those nodes
highlightNodeSet.forEach((nIdx) => highlightNode(nIdx));
} else if (params.dataType === 'edge') {
const hoveredLinkIndex = params.dataIndex;
// highlight just that edge
highlightLink(hoveredLinkIndex);
// highlight source & target node
const link = data.links[hoveredLinkIndex];
highlightNode(link.source);
highlightNode(link.target);
}
});
chart.on('mouseout', function () {
// revert to normal
resetHighlight();
});
const ro = new ResizeObserver(() => chart.resize());
ro.observe(chartRef.current);
return () => {
chart.dispose();
ro.disconnect();
};
}, [data, height, onChartClick]);
return <div ref={chartRef} style={{ width: '100%', height }} />;
};
export default EChartsSankey;
// END GEN

View file

@ -1,14 +1,12 @@
import React, { useEffect, useState } from 'react';
import { Icon, Modal } from 'UI';
import { Icon } from 'UI';
import {
Tooltip,
Input,
Button,
Dropdown,
Menu,
Tag,
Modal as AntdModal,
Form,
Avatar,
} from 'antd';
import {
@ -16,7 +14,6 @@ import {
LockOutlined,
EditOutlined,
DeleteOutlined,
MoreOutlined,
} from '@ant-design/icons';
import { RouteComponentProps } from 'react-router-dom';
import { withSiteId } from 'App/routes';

View file

@ -3,6 +3,8 @@ import LineChart from 'App/components/Charts/LineChart'
import BarChart from 'App/components/Charts/BarChart'
import PieChart from 'App/components/Charts/PieChart'
import ColumnChart from 'App/components/Charts/ColumnChart'
import SankeyChart from 'Components/Charts/SankeyChart';
import CustomMetricPercentage from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPercentage';
import { Styles } from 'App/components/Dashboard/Widgets/common';
import { observer } from 'mobx-react-lite';
@ -31,7 +33,6 @@ import CustomMetricTableSessions from 'App/components/Dashboard/Widgets/CustomMe
import CustomMetricTableErrors from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableErrors';
import ClickMapCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard';
import InsightsCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/InsightsCard';
import SankeyChart from 'Shared/Insights/SankeyChart';
import { filterMinorPaths } from 'Shared/Insights/SankeyChart/utils'
import CohortCard from '../../Widgets/CustomMetricsWidgets/CohortCard';
import SessionsBy from 'Components/Dashboard/Widgets/CustomMetricsWidgets/SessionsBy';
@ -504,7 +505,6 @@ function WidgetChart(props: Props) {
<SankeyChart
height={props.isPreview ? 500 : 240}
data={usedData}
iterations={_metric.hideExcess ? 0 : 128}
onChartClick={(filters: any) => {
dashboardStore.drillDownFilter.merge({ filters, page: 1 });
}}
@ -526,6 +526,7 @@ function WidgetChart(props: Props) {
return <CohortCard data={data[0]} />;
}
}
console.log('Unknown metric type', metricType);
return <div>Unknown metric type</div>;
}, [data, compData, enabledRows, _metric]);

View file

@ -11,7 +11,7 @@ interface Props {
function NodeButton(props: Props) {
const { payload } = props;
const payloadStr = payload.name;
const payloadStr = payload.name ?? payload.eventType;
// we need to only trim the middle, so its readable
const safePName =

View file

@ -29,10 +29,9 @@ interface Props {
nodeWidth?: number;
height?: number;
onChartClick?: (filters: any[]) => void;
iterations: number
}
const SankeyChart: React.FC<Props> = ({data, height = 240, onChartClick, iterations}: Props) => {
const SankeyChart: React.FC<Props> = ({data, height = 240, onChartClick}: Props) => {
const [highlightedLinks, setHighlightedLinks] = useState<string[]>([]);
const [hoveredLinks, setHoveredLinks] = useState<string[]>([]);
@ -116,7 +115,6 @@ const SankeyChart: React.FC<Props> = ({data, height = 240, onChartClick, iterati
nodePadding={20}
sort={true}
nodeWidth={4}
iterations={iterations}
// linkCurvature={0.9}
onClick={clickHandler}
link={({source, target, id, ...linkProps}, index) => (