ui: improve data parsing, colors and drop sessions list

This commit is contained in:
nick-delirium 2025-05-02 17:28:47 +02:00
parent e81544b935
commit ab9b374b2b
No known key found for this signature in database
GPG key ID: 93ABD695DF5FDBA0
3 changed files with 196 additions and 532 deletions

View file

@ -0,0 +1,56 @@
import React from 'react'
import type { Data } from '../SankeyChart'
function DroppedSessionsList({ data, colorMap }: { data: Data, colorMap: Map<string, string> }) {
const dropsByUrl: Record<string, number> = {};
data.links.forEach(link => {
const targetNode = data.nodes.find(node => node.id === link.target)
const sourceNode = data.nodes.find(node => node.id === link.source)
if (!targetNode || !sourceNode) return;
const isDrop = targetNode.eventType === 'DROP'
if (!isDrop) return;
const sourceUrl = sourceNode.name;
if (sourceUrl) {
dropsByUrl[sourceUrl] = (dropsByUrl[sourceUrl] || 0) + link.sessionsCount;
}
});
const totalDropSessions = Object.values(dropsByUrl).reduce((sum, count) => sum + count, 0);
const sortedDrops = Object.entries(dropsByUrl)
.map(([url, count]) => ({
url,
count,
percentage: Math.round((count / totalDropSessions) * 100)
}))
.sort((a, b) => b.count - a.count);
return (
<div className="mt-6 bg-white rounded-lg shadow p-4 max-w-md">
<h3 className="text-lg font-medium mb-3">Droppe Sessions by Page</h3>
<div className="space-y-1.5">
{sortedDrops.length > 0 ? (
sortedDrops.map((item, index) => (
<div
key={index}
className="py-1.5 px-2 hover:bg-gray-50 rounded transition-colors flex justify-between gap-2 relative"
>
<div style={{ background: colorMap.get(item.url), width: 2, height: 32, position: 'absolute', top: 0, left: -3, borderRadius: 3, }} />
<span className="truncate flex-1">{item.url}</span>
<span className="ml-2"> {item.count}</span>
<span className="text-gray-400">({item.percentage}%)</span>
</div>
))
) : (
<div className="text-gray-500 py-2">No drop sessions found</div>
)}
</div>
</div>
);
};
export default DroppedSessionsList;

View file

@ -1,564 +1,76 @@
import React from 'react';
import { SunburstChart } from 'echarts/charts';
import { NoContent } from 'App/components/ui';
import { InfoCircleOutlined } from '@ant-design/icons';
// import { sankeyTooltip, getEventPriority, getNodeName } from './sankeyUtils';
import { echarts, defaultOptions } from '../init';
import type { Data } from '../SankeyChart'
import DroppedSessionsList from './DroppedSessions';
import { convertSankeyToSunburst, sunburstTooltip } from './sunburstUtils';
echarts.use([SunburstChart]);
interface SunburstNode {
name: string | null;
eventType?: string;
depth?: number;
id?: string | number;
}
interface SunburstLink {
source: number | string;
target: number | string;
value: number;
sessionsCount: number;
eventType?: string;
}
interface Data {
nodes: SunburstNode[];
links: SunburstLink[];
}
interface DropoffPage {
url: string;
percentage: number;
sessions: number;
}
interface Props {
data: Data;
height?: number;
onChartClick?: (filters: any[]) => void;
isUngrouped?: boolean;
inGrid?: boolean;
}
const EChartsSunburst: React.FC<Props> = (props) => {
const { data, height = 500, onChartClick, isUngrouped } = props;
const chartRef = React.useRef<HTMLDivElement>(null);
const dropoffContainerRef = React.useRef<HTMLDivElement>(null);
const [tooltipVisible, setTooltipVisible] = React.useState(false);
const [tooltipContent, setTooltipContent] = React.useState('');
const [tooltipPosition, setTooltipPosition] = React.useState({ x: 0, y: 0 });
const [dropoffPages, setDropoffPages] = React.useState<DropoffPage[]>([]);
// Generate a consistent color for each URL
const colorMap = React.useRef(new Map<string, string>());
const generateColor = (url: string): string => {
if (!url) return '#cccccc'; // Default for null/empty
if (colorMap.current.has(url)) {
return colorMap.current.get(url)!;
}
// Generate a pastel-ish color that's visually distinct
const hue = Math.floor(Math.random() * 360);
const saturation = 70 + Math.floor(Math.random() * 20); // 70-90%
const lightness = 50 + Math.floor(Math.random() * 10); // 50-60%
// Convert HSL to RGB
const hslToRgb = (h: number, s: number, l: number): number[] => {
h /= 360;
s /= 100;
l /= 100;
let r, g, b;
if (s === 0) {
r = g = b = l; // achromatic
} else {
const hue2rgb = (p: number, q: number, t: number): number => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
};
const color = `rgb(${hslToRgb(hue, saturation, lightness).join(',')})`;
colorMap.current.set(url, color);
return color;
};
// Process data for sunburst chart
const processData = React.useCallback(() => {
if (!data || !data.nodes || !data.links || data.nodes.length === 0 || data.links.length === 0) {
return { sunburstData: null, dropoffData: [] };
}
// Find entry points (depth 0)
const entryNodes = data.nodes.filter(node => node.depth === 0);
if (entryNodes.length === 0) return { sunburstData: null, dropoffData: [] };
const hasMultipleStartPoints = entryNodes.length > 1;
// Create nodes map for quick access
const nodesMap = new Map<number | string, SunburstNode>();
data.nodes.forEach(node => {
nodesMap.set(node.id!, node);
});
// Calculate total sessions
const totalSessions = data.links.reduce(
(sum, link) => sum + link.sessionsCount,
0
);
// Root for sunburst
const root = {
name: 'root',
children: [],
value: 0,
itemStyle: { color: 'rgba(255, 255, 255, 0)' } // Transparent center
};
// Create hierarchical structure for sunburst
const processedPaths = new Set<string>();
const skipEventTypes = ['OTHER'];
// First pass: extract all paths
const allPaths: { path: any[], value: number }[] = [];
data.links.forEach(link => {
const sourceNode = nodesMap.get(link.source);
const targetNode = nodesMap.get(link.target);
// Skip if nodes don't exist or if target eventType should be skipped
if (!sourceNode || !targetNode || skipEventTypes.includes(targetNode.eventType || '')) {
return;
}
// Skip dropoff -> dropoff chains
if (sourceNode.eventType === 'DROP' && targetNode.eventType === 'DROP') {
return;
}
// Create path segment
const pathSegment = [];
// If single start point with depth 0, we hide it in the center
if (!hasMultipleStartPoints && sourceNode.depth === 0) {
// Start from the target for single start point
if (targetNode.eventType !== 'DROP' && targetNode.name) {
pathSegment.push({
name: targetNode.name || 'Unknown',
originId: targetNode.id,
eventType: targetNode.eventType,
itemStyle: {
color: targetNode.name ? generateColor(targetNode.name) : '#cccccc'
}
});
}
} else {
// Include source and target for multiple start points
if (sourceNode.eventType !== 'DROP' && sourceNode.name) {
pathSegment.push({
name: sourceNode.name || 'Unknown',
originId: sourceNode.id,
eventType: sourceNode.eventType,
itemStyle: {
color: sourceNode.name ? generateColor(sourceNode.name) : '#cccccc'
}
});
}
if (targetNode.eventType !== 'DROP' && targetNode.name) {
pathSegment.push({
name: targetNode.name || 'Unknown',
originId: targetNode.id,
eventType: targetNode.eventType,
itemStyle: {
color: targetNode.name ? generateColor(targetNode.name) : '#cccccc'
}
});
}
}
if (pathSegment.length > 0) {
allPaths.push({
path: pathSegment,
value: link.sessionsCount
});
}
});
// Find longer paths by following connections
const extendedPaths: { path: any[], value: number }[] = [];
allPaths.forEach(initialPath => {
if (initialPath.path.length === 0) return;
// Current working path
let currentPath = [...initialPath.path];
let currentValue = initialPath.value;
let lastNode = currentPath[currentPath.length - 1];
// Try to extend the path
let keepExtending = true;
let maxDepth = 5; // Prevent infinite loops
while (keepExtending && maxDepth > 0) {
keepExtending = false;
maxDepth--;
// Find links from last node
const continuationLinks = data.links.filter(link => {
const sourceId = link.source;
return sourceId === lastNode.originId;
});
if (continuationLinks.length === 1) {
// Found a single continuation - extend the path
const nextLink = continuationLinks[0];
const nextNode = nodesMap.get(nextLink.target);
if (nextNode && nextNode.eventType !== 'DROP' && !skipEventTypes.includes(nextNode.eventType || '') && nextNode.name) {
const nextNodeData = {
name: nextNode.name || 'Unknown',
originId: nextNode.id,
eventType: nextNode.eventType,
itemStyle: {
color: nextNode.name ? generateColor(nextNode.name) : '#cccccc'
}
};
currentPath.push(nextNodeData);
currentValue = nextLink.sessionsCount; // Update value based on link
lastNode = nextNodeData;
keepExtending = true;
}
}
}
// Only add paths that have been extended
if (currentPath.length > initialPath.path.length) {
extendedPaths.push({
path: currentPath,
value: currentValue
});
} else {
extendedPaths.push(initialPath);
}
});
// Build the final sunburst structure
extendedPaths.forEach(pathData => {
const { path, value } = pathData;
// Skip empty paths
if (path.length === 0) return;
// Generate unique path ID to prevent duplicates
const pathId = path.map(p => p.name).join('|');
if (processedPaths.has(pathId)) return;
processedPaths.add(pathId);
// Add path to sunburst
let current = root;
path.forEach(item => {
const existing = current.children.find((child: any) => child.name === item.name);
if (existing) {
// Node already exists, add to value
existing.value += value;
current = existing;
} else {
// Create new node
const newNode = {
name: item.name,
value: value,
eventType: item.eventType,
itemStyle: item.itemStyle,
children: [] as any[]
};
current.children.push(newNode);
current = newNode;
}
});
});
// Process dropoff data
const dropoffData: DropoffPage[] = [];
data.links.forEach(link => {
const sourceNode = nodesMap.get(link.source);
const targetNode = nodesMap.get(link.target);
if (targetNode && targetNode.eventType === 'DROP' && sourceNode &&
sourceNode.eventType !== 'DROP' && sourceNode.name) {
dropoffData.push({
url: sourceNode.name,
percentage: (link.sessionsCount / totalSessions) * 100,
sessions: link.sessionsCount
});
}
});
// Sort by highest exit percentage
dropoffData.sort((a, b) => b.percentage - a.percentage);
return { sunburstData: root, dropoffData };
}, [data]);
const EChartsSunburst = (props: Props) => {
const { data, height = 240, } = props;
const chartRef = React.useRef<HTMLDListElement>(null);
const [colors, setColors] = React.useState<Map<string, string>>(new Map())
React.useEffect(() => {
if (!chartRef.current) return;
if (!chartRef.current || data.nodes.length === 0 || data.links.length === 0) return;
const { sunburstData, dropoffData } = processData();
setDropoffPages(dropoffData);
const chart = echarts.init(chartRef.current)
const { tree, colors } = convertSankeyToSunburst(data);
if (!sunburstData || sunburstData.children.length === 0) {
return;
}
const chart = echarts.init(chartRef.current);
const option = {
tree.value = 100;
const options = {
...defaultOptions,
tooltip: {
trigger: 'item',
formatter: (params: any) => {
// Skip the root node
if (params.name === 'root') return '';
const value = params.value || 0;
// Calculate total from entry node or use a fallback
const entryNode = data.nodes.find(node => node.depth === 0);
let totalSessions = 0;
if (entryNode) {
data.links.forEach(link => {
if (link.source === entryNode.id) {
totalSessions += link.sessionsCount;
}
});
}
if (totalSessions === 0) {
totalSessions = data.links.reduce((sum, link) => sum + link.sessionsCount, 0);
}
const percentage = totalSessions > 0 ? ((value / totalSessions) * 100).toFixed(1) : '0.0';
return `<div style="padding: 3px;">
<div style="font-weight: bold;">${params.name}</div>
<div>Sessions: ${value}</div>
<div>${percentage}% of total</div>
</div>`;
}
},
series: {
type: 'sunburst',
highlightPolicy: 'ancestor',
data: [sunburstData],
radius: ['25%', '90%'],
sort: null,
emphasis: {
focus: 'ancestor'
data: tree.children,
radius: [30, '90%'],
itemStyle: {
borderRadius: 6,
borderWidth: 2,
},
levels: [
{},
{
r0: '25%',
r: '45%',
itemStyle: {
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.5)'
},
label: {
rotate: 'tangential',
minAngle: 10,
fontSize: 11
}
},
{
r0: '45%',
r: '65%',
label: {
align: 'left',
rotate: 'tangential',
fontSize: 10
}
},
{
r0: '65%',
r: '80%',
label: {
rotate: 'tangential',
fontSize: 9
}
},
{
r0: '80%',
r: '90%',
label: {
position: 'outside',
padding: 3,
silent: false,
fontSize: 8
},
itemStyle: {
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.5)'
}
}
],
animation: true,
}
};
chart.setOption(option);
// Handle chart click events
if (onChartClick) {
chart.on('click', (params: any) => {
if (params.name === 'root') return;
const filters = [];
if (params.data && params.data.eventType) {
const unsupported = ['other', 'drop'];
const type = params.data.eventType.toLowerCase();
if (unsupported.includes(type)) {
return;
}
filters.push({
operator: 'is',
type,
value: [params.name],
isEvent: true,
});
onChartClick(filters);
center: ['50%', '50%'],
clockwise: true,
label: {
show: false,
},
tooltip: {
formatter: sunburstTooltip(colors)
}
});
},
}
// Handle resize
chart.setOption(options)
const ro = new ResizeObserver(() => chart.resize());
ro.observe(chartRef.current);
setColors(colors);
return () => {
chart.dispose();
ro.disconnect();
};
}, [data, height, onChartClick, processData]);
}
}, [data, height])
// Handle tooltip for dropoff list
const handleMouseEnter = (e: React.MouseEvent, url: string) => {
setTooltipContent(url);
setTooltipPosition({ x: e.clientX, y: e.clientY });
setTooltipVisible(true);
};
const handleMouseLeave = () => {
setTooltipVisible(false);
};
// Truncate URL for display
const truncateUrl = (url: string, maxLength = 30) => {
if (!url) return 'Unknown';
if (url.length <= maxLength) return url;
const start = url.substring(0, maxLength/2 - 3);
const end = url.substring(url.length - maxLength/2 + 3);
return `${start}...${end}`;
};
if (data.nodes.length === 0 || data.links.length === 0) {
return (
<NoContent
style={{ minHeight: height }}
title={
<div className="flex items-center relative">
<InfoCircleOutlined className="hidden md:inline-block mr-1" />
Set a start or end point to visualize the journey. If set, try
adjusting filters.
</div>
}
show={true}
/>
);
}
let containerStyle: React.CSSProperties = {
const containerStyle = {
width: '100%',
height,
position: 'relative',
minHeight: 240,
};
if (isUngrouped) {
containerStyle = {
...containerStyle,
minHeight: 550,
height: '100%',
};
}
return (
<div className="flex flex-col lg:flex-row w-full gap-4">
<div className="w-full lg:w-3/4">
<div
ref={chartRef}
style={containerStyle}
className="min-w-[500px]"
/>
</div>
<div className="w-full lg:w-1/4">
<div ref={dropoffContainerRef} className="bg-white p-4 rounded-lg shadow">
<h3 className="text-lg font-medium mb-3">Dropoff Pages</h3>
<div className="overflow-auto max-h-96">
{dropoffPages.map((page, index) => (
<div
key={index}
className="border-b border-gray-100 py-2"
onMouseEnter={(e) => handleMouseEnter(e, page.url)}
onMouseLeave={handleMouseLeave}
>
<div className="text-sm font-medium">{truncateUrl(page.url)}</div>
<div className="flex justify-between text-xs text-gray-500">
<span>{page.percentage.toFixed(1)}%</span>
<span>{page.sessions} sessions</span>
</div>
</div>
))}
{dropoffPages.length === 0 && (
<div className="text-sm text-gray-500 py-2">No dropoff data available</div>
)}
</div>
</div>
</div>
{tooltipVisible && (
<div
className="fixed bg-gray-900 text-white p-2 rounded text-sm z-50 pointer-events-none"
style={{
left: `${tooltipPosition.x + 10}px`,
top: `${tooltipPosition.y + 10}px`,
}}
>
{tooltipContent}
</div>
)}
</div>
);
};
return <div
style={{
maxHeight: 620,
overflow: 'auto',
maxWidth: 1240,
minHeight: 240,
display: 'flex',
}}
>
<div ref={chartRef} style={containerStyle} className="min-w-[600px] relative" />
<DroppedSessionsList colorMap={colors} data={data} />
</div>;
}
export default EChartsSunburst;

View file

@ -0,0 +1,96 @@
import { colors } from '../utils'
import type { Data } from '../SankeyChart'
import { toJS } from 'mobx'
export interface SunburstChild {
name: string;
value: number;
children?: SunburstChild[]
itemStyle?: any;
}
const colorMap = new Map();
export function convertSankeyToSunburst(data: Data): { tree: SunburstChild, colors: Map<string, string> } {
const nodesCopy: any = data.nodes.map(node => ({
...node,
children: [],
childrenIds: new Set(),
value: 0
}));
const nodesById: Record<number, typeof nodesCopy[number]> = {};
nodesCopy.forEach((node) => {
nodesById[node.id as number] = node;
});
data.links.forEach(link => {
const sourceNode = nodesById[link.source as number];
const targetNode = nodesById[link.target as number];
if (sourceNode && targetNode) {
if ((targetNode.depth) === (sourceNode.depth) + 1 && !sourceNode.childrenIds.has(targetNode.id)) {
const specificId = `${link.source}${link.target}`
const fakeNode = {
...targetNode, id: specificId, value: link.sessionsCount
}
sourceNode.children.push(fakeNode);
sourceNode.childrenIds.add(specificId);
}
}
});
const rootNode = nodesById[0];
function buildSunburstNode(node: SunburstChild): SunburstChild | null {
if (!node) return null;
if (!node.name) {
// eventType = DROP
return null
}
let color = colorMap.get(node.name)
if (!color) {
color = randomColor(colorMap.size)
colorMap.set(node.name, color)
}
const result: SunburstChild = {
name: node.name,
value: node.value || 0,
itemStyle: {
color,
}
};
if (node.children && node.children.length > 0) {
result.children = node.children.map(child => buildSunburstNode(child)).filter(Boolean) as SunburstChild[];
}
return result;
}
return { tree: buildSunburstNode(rootNode) as SunburstChild, colors: colorMap }
}
function randomColor(mapSize: number) {
const pointer = mapSize
if (pointer > colors.length) {
colors.push(`#${Math.floor(Math.random() * 16777215).toString(16)}`);
}
return colors[pointer];
}
export function sunburstTooltip(colorMap: Map<string, string>) {
return (params: any) => {
if ('name' in params.data) {
const color = colorMap.get(params.data.name);
return `
<div class="flex flex-col bg-white p-2 rounded shadow border">
<div class="font-semibold text-sm flex gap-1 items-center">
<span class="text-base" style="color:${color}; font-family: sans-serif;">&#9632;&#xFE0E;</span>
${params.data.name}
</div>
<div class="text-black text-sm">${params.value} <span class="text-disabled-text">Sessions</span></div>
</div>
`;
}
}
}