ui: moving snakey to echarts

This commit is contained in:
nick-delirium 2025-01-21 16:44:01 +01:00
parent cc829263b3
commit d6e4162a3b
No known key found for this signature in database
GPG key ID: 93ABD695DF5FDBA0
3 changed files with 172 additions and 105 deletions

View file

@ -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> = (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> = (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> = (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<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();
});
// 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);
@ -244,4 +252,3 @@ const EChartsSankey: React.FC<Props> = (props) => {
};
export default EChartsSankey;
// END GEN

View file

@ -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 `
<div class="flex gap-2 w-fit px-2 bg-white items-center">
<div class="flex flex-col">
<div class="border-t border-l rounded-tl border-dotted border-gray-500" style="width: 8px; height: 30px"></div>
<div class="border-b border-l rounded-bl border-dotted border-gray-500 relative" style="width: 8px; height: 30px">
<div class="w-0 h-0 border-l-4 border-l-gray-500 border-y-4 border-y-transparent border-r-0 absolute -right-1 -bottom-1.5"></div>
</div>
</div>
<div class="flex flex-col">
<div class="font-semibold">${sourceName}</div>
<div>${sourceValue}</div>
<div class="font-semibold mt-2">${targetName}</div>
<div>
<span>${params.data.value}</span>
<span class="text-disabled-text">${params.data.percentage.toFixed(
2
)}%</span>
</div>
</div>
</div>
`;
//${sourceName} -> ${targetName}: ${params.data.value} sessions (${params.data.percentage.toFixed(2)}%)
}
if ('name' in params.data) {
return `
<div class="flex flex-col bg-white">
<div class="font-semibold">${params.data.name}</div>
<div>${params.value} sessions</div>
</div>
`;
}
};
}
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;
}

View file

@ -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 {