diff --git a/frontend/app/components/Charts/SankeyChart.tsx b/frontend/app/components/Charts/SankeyChart.tsx new file mode 100644 index 000000000..361fd121a --- /dev/null +++ b/frontend/app/components/Charts/SankeyChart.tsx @@ -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) => { + const { data, height = 240, onChartClick } = props; + const chartRef = React.useRef(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(); + 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
; +}; + +export default EChartsSankey; +// END GEN diff --git a/frontend/app/components/Dashboard/components/MetricListItem/MetricListItem.tsx b/frontend/app/components/Dashboard/components/MetricListItem/MetricListItem.tsx index 567236851..8cb522b24 100644 --- a/frontend/app/components/Dashboard/components/MetricListItem/MetricListItem.tsx +++ b/frontend/app/components/Dashboard/components/MetricListItem/MetricListItem.tsx @@ -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'; diff --git a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx index 8c93c147e..1beaa99db 100644 --- a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx +++ b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx @@ -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) { { dashboardStore.drillDownFilter.merge({ filters, page: 1 }); }} @@ -526,6 +526,7 @@ function WidgetChart(props: Props) { return ; } } + console.log('Unknown metric type', metricType); return
Unknown metric type
; }, [data, compData, enabledRows, _metric]); diff --git a/frontend/app/components/shared/Insights/SankeyChart/NodeButton.tsx b/frontend/app/components/shared/Insights/SankeyChart/NodeButton.tsx index 615b38912..07c242a91 100644 --- a/frontend/app/components/shared/Insights/SankeyChart/NodeButton.tsx +++ b/frontend/app/components/shared/Insights/SankeyChart/NodeButton.tsx @@ -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 = diff --git a/frontend/app/components/shared/Insights/SankeyChart/SankeyChart.tsx b/frontend/app/components/shared/Insights/SankeyChart/SankeyChart.tsx index e74505840..a045d16d4 100644 --- a/frontend/app/components/shared/Insights/SankeyChart/SankeyChart.tsx +++ b/frontend/app/components/shared/Insights/SankeyChart/SankeyChart.tsx @@ -29,10 +29,9 @@ interface Props { nodeWidth?: number; height?: number; onChartClick?: (filters: any[]) => void; - iterations: number } -const SankeyChart: React.FC = ({data, height = 240, onChartClick, iterations}: Props) => { +const SankeyChart: React.FC = ({data, height = 240, onChartClick}: Props) => { const [highlightedLinks, setHighlightedLinks] = useState([]); const [hoveredLinks, setHoveredLinks] = useState([]); @@ -116,7 +115,6 @@ const SankeyChart: React.FC = ({data, height = 240, onChartClick, iterati nodePadding={20} sort={true} nodeWidth={4} - iterations={iterations} // linkCurvature={0.9} onClick={clickHandler} link={({source, target, id, ...linkProps}, index) => (