From d6e4162a3be3cd8cad98497d111662093ad6d529 Mon Sep 17 00:00:00 2001 From: nick-delirium Date: Tue, 21 Jan 2025 16:44:01 +0100 Subject: [PATCH] ui: moving snakey to echarts --- .../app/components/Charts/SankeyChart.tsx | 215 +++++++++--------- frontend/app/components/Charts/sankeyUtils.ts | 60 +++++ frontend/app/services/MetricService.ts | 2 +- 3 files changed, 172 insertions(+), 105 deletions(-) create mode 100644 frontend/app/components/Charts/sankeyUtils.ts diff --git a/frontend/app/components/Charts/SankeyChart.tsx b/frontend/app/components/Charts/SankeyChart.tsx index 361fd121a..a86251092 100644 --- a/frontend/app/components/Charts/SankeyChart.tsx +++ b/frontend/app/components/Charts/SankeyChart.tsx @@ -2,18 +2,18 @@ import React from 'react'; import { echarts, defaultOptions } from './init'; import { SankeyChart } from 'echarts/charts'; - +import { sankeyTooltip, getEventPriority, getNodeName } from './sankeyUtils' echarts.use([SankeyChart]); interface SankeyNode { name: string | null; // e.g. "/en/deployment/", or null - eventType?: string; // e.g. "LOCATION" (not strictly needed by ECharts) + 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 + value: number; // percentage sessionsCount: number; eventType?: string; // optional } @@ -29,10 +29,8 @@ interface Props { onChartClick?: (filters: any[]) => void; } -// Your existing function +// Not working properly 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; @@ -69,36 +67,57 @@ const EChartsSankey: React.FC = (props) => { 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, + const nodeValues = new Array(data.nodes.length).fill(0); + const echartNodes = data.nodes + .map((n, i) => ({ + name: getNodeName(n.eventType || 'Other', n.name), + depth: n.depth, + type: n.eventType, + id: n.id, + })) + .sort((a, b) => { + if (a.depth === b.depth) { + return getEventPriority(a.type || '') - getEventPriority(b.type || '') + } else { + return a.depth - b.depth; + } + }); + const echartLinks = data.links.map((l, i) => ({ + source: echartNodes.findIndex((n) => n.id === l.source), + target: echartNodes.findIndex((n) => n.id === l.target), value: l.sessionsCount, percentage: l.value, })); + nodeValues.forEach((v, i) => { + const outgoingValues = echartLinks + .filter((l) => l.source === i) + .reduce((p, c) => p + c.value, 0); + const incomingValues = echartLinks + .filter((l) => l.target === i) + .reduce((p, c) => p + c.value, 0); + nodeValues[i] = Math.max(outgoingValues, incomingValues); + }) const option = { ...defaultOptions, tooltip: { - trigger: 'item' + trigger: 'item', }, series: [ { + layoutIterations: 0, type: 'sankey', data: echartNodes, links: echartLinks, emphasis: { focus: 'adjacency', - blurScope: 'global' + blurScope: 'global', + }, + label: { + formatter: '{b} - {c}' + }, + tooltip: { + formatter: sankeyTooltip(echartNodes, nodeValues) }, nodeAlign: 'right', nodeWidth: 10, @@ -106,53 +125,42 @@ const EChartsSankey: React.FC = (props) => { lineStyle: { color: 'source', curveness: 0.5, - opacity: 0.3 + opacity: 0.3, }, itemStyle: { color: '#394eff', - borderRadius: 4 - } - } - ] + 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 + dataIndex: nodeIdx, }); } - - // Helper: highlight a given link index function highlightLink(linkIdx: number) { chart.dispatchAction({ type: 'highlight', seriesIndex, dataType: 'edge', - dataIndex: linkIdx + dataIndex: linkIdx, }); } - - // Helper: downplay (reset) everything function resetHighlight() { chart.dispatchAction({ type: 'downplay', - seriesIndex + 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; @@ -167,69 +175,69 @@ const EChartsSankey: React.FC = (props) => { } }); - 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(); - }); + // 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); @@ -244,4 +252,3 @@ const EChartsSankey: React.FC = (props) => { }; export default EChartsSankey; -// END GEN diff --git a/frontend/app/components/Charts/sankeyUtils.ts b/frontend/app/components/Charts/sankeyUtils.ts new file mode 100644 index 000000000..2459f3b51 --- /dev/null +++ b/frontend/app/components/Charts/sankeyUtils.ts @@ -0,0 +1,60 @@ +export function sankeyTooltip(echartNodes, nodeValues) { + return (params) => { + if ('source' in params.data && 'target' in params.data) { + const sourceName = echartNodes[params.data.source].name; + const targetName = echartNodes[params.data.target].name; + const sourceValue = nodeValues[params.data.source]; + return ` +
+
+
+
+ +
+
+
+
${sourceName}
+
${sourceValue}
+
${targetName}
+
+ ${params.data.value} + ${params.data.percentage.toFixed( + 2 + )}% +
+
+
+ `; + //${sourceName} -> ${targetName}: ${params.data.value} sessions (${params.data.percentage.toFixed(2)}%) + } + if ('name' in params.data) { + return ` +
+
${params.data.name}
+
${params.value} sessions
+
+ `; + } + }; +} + + +export const getEventPriority = (type: string) => { + switch (type) { + case 'DROP': + return 3; + case 'OTHER': + return 2; + default: + return 1; + } +}; + +export const getNodeName = (eventType: string, nodeName: string | null) => { + if (!nodeName) { + // only capitalize first + return eventType.charAt(0) + eventType.slice(1).toLowerCase(); + } + return nodeName; +} + diff --git a/frontend/app/services/MetricService.ts b/frontend/app/services/MetricService.ts index 4e90ccb4d..0a24c0e4e 100644 --- a/frontend/app/services/MetricService.ts +++ b/frontend/app/services/MetricService.ts @@ -82,7 +82,7 @@ export default class MetricService { } const path = isSaved ? `/cards/${metric.metricId}/chart` : `/cards/try`; if (metric.metricType === USER_PATH) { - data.density = 5; + data.density = 3; data.metricOf = 'sessionCount'; } try {