ui: work on new sankey chart
This commit is contained in:
parent
2b7e79eabd
commit
148ae757d3
5 changed files with 253 additions and 10 deletions
247
frontend/app/components/Charts/SankeyChart.tsx
Normal file
247
frontend/app/components/Charts/SankeyChart.tsx
Normal 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
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue