From ab9b374b2b9544e3287b9297260b53a6c139c3af Mon Sep 17 00:00:00 2001 From: nick-delirium Date: Fri, 2 May 2025 17:28:47 +0200 Subject: [PATCH] ui: improve data parsing, colors and drop sessions list --- .../Charts/SunburstChart/DroppedSessions.tsx | 56 ++ .../Charts/SunburstChart/Sunburst.tsx | 576 ++---------------- .../Charts/SunburstChart/sunburstUtils.ts | 96 +++ 3 files changed, 196 insertions(+), 532 deletions(-) create mode 100644 frontend/app/components/Charts/SunburstChart/DroppedSessions.tsx create mode 100644 frontend/app/components/Charts/SunburstChart/sunburstUtils.ts diff --git a/frontend/app/components/Charts/SunburstChart/DroppedSessions.tsx b/frontend/app/components/Charts/SunburstChart/DroppedSessions.tsx new file mode 100644 index 000000000..112aa86cf --- /dev/null +++ b/frontend/app/components/Charts/SunburstChart/DroppedSessions.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import type { Data } from '../SankeyChart' + +function DroppedSessionsList({ data, colorMap }: { data: Data, colorMap: Map }) { + const dropsByUrl: Record = {}; + + 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 ( +
+

Droppe Sessions by Page

+
+ {sortedDrops.length > 0 ? ( + sortedDrops.map((item, index) => ( +
+
+ {item.url} + {item.count} + ({item.percentage}%) +
+ )) + ) : ( +
No drop sessions found
+ )} +
+
+ ); +}; + +export default DroppedSessionsList; diff --git a/frontend/app/components/Charts/SunburstChart/Sunburst.tsx b/frontend/app/components/Charts/SunburstChart/Sunburst.tsx index f12f180ab..e6b3a624c 100644 --- a/frontend/app/components/Charts/SunburstChart/Sunburst.tsx +++ b/frontend/app/components/Charts/SunburstChart/Sunburst.tsx @@ -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) => { - const { data, height = 500, onChartClick, isUngrouped } = props; - const chartRef = React.useRef(null); - const dropoffContainerRef = React.useRef(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([]); - - // Generate a consistent color for each URL - const colorMap = React.useRef(new Map()); - 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(); - 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(); - 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(null); + const [colors, setColors] = React.useState>(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 `
-
${params.name}
-
Sessions: ${value}
-
${percentage}% of total
-
`; - } - }, 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 ( - - - Set a start or end point to visualize the journey. If set, try - adjusting filters. -
- } - show={true} - /> - ); - } - - let containerStyle: React.CSSProperties = { + const containerStyle = { width: '100%', height, - position: 'relative', - minHeight: 240, }; - - if (isUngrouped) { - containerStyle = { - ...containerStyle, - minHeight: 550, - height: '100%', - }; - } - - return ( -
-
-
-
- -
-
-

Dropoff Pages

-
- {dropoffPages.map((page, index) => ( -
handleMouseEnter(e, page.url)} - onMouseLeave={handleMouseLeave} - > -
{truncateUrl(page.url)}
-
- {page.percentage.toFixed(1)}% - {page.sessions} sessions -
-
- ))} - {dropoffPages.length === 0 && ( -
No dropoff data available
- )} -
-
-
- - {tooltipVisible && ( -
- {tooltipContent} -
- )} -
- ); -}; + return
+
+ +
; +} export default EChartsSunburst; diff --git a/frontend/app/components/Charts/SunburstChart/sunburstUtils.ts b/frontend/app/components/Charts/SunburstChart/sunburstUtils.ts new file mode 100644 index 000000000..146abaf3d --- /dev/null +++ b/frontend/app/components/Charts/SunburstChart/sunburstUtils.ts @@ -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 } { + const nodesCopy: any = data.nodes.map(node => ({ + ...node, + children: [], + childrenIds: new Set(), + value: 0 + })); + + const nodesById: Record = {}; + 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) { + return (params: any) => { + if ('name' in params.data) { + const color = colorMap.get(params.data.name); + return ` +
+
+ ■︎ + ${params.data.name} +
+
${params.value} Sessions
+
+ `; + } + } +}