From e81544b935e965009e032a1feff4dbbee4f55e31 Mon Sep 17 00:00:00 2001 From: nick-delirium Date: Fri, 2 May 2025 14:05:32 +0200 Subject: [PATCH 1/4] ui: testing --- .../app/components/Charts/SankeyChart.tsx | 2 +- .../Charts/SunburstChart/Sunburst.tsx | 564 ++++++++++++++++++ .../components/WidgetChart/WidgetChart.tsx | 17 +- 3 files changed, 579 insertions(+), 4 deletions(-) create mode 100644 frontend/app/components/Charts/SunburstChart/Sunburst.tsx diff --git a/frontend/app/components/Charts/SankeyChart.tsx b/frontend/app/components/Charts/SankeyChart.tsx index 9f2ca31c4..192867735 100644 --- a/frontend/app/components/Charts/SankeyChart.tsx +++ b/frontend/app/components/Charts/SankeyChart.tsx @@ -23,7 +23,7 @@ interface SankeyLink { eventType?: string; } -interface Data { +export interface Data { nodes: SankeyNode[]; links: SankeyLink[]; } diff --git a/frontend/app/components/Charts/SunburstChart/Sunburst.tsx b/frontend/app/components/Charts/SunburstChart/Sunburst.tsx new file mode 100644 index 000000000..f12f180ab --- /dev/null +++ b/frontend/app/components/Charts/SunburstChart/Sunburst.tsx @@ -0,0 +1,564 @@ +import React from 'react'; +import { SunburstChart } from 'echarts/charts'; +import { NoContent } from 'App/components/ui'; +import { InfoCircleOutlined } from '@ant-design/icons'; +import { echarts, defaultOptions } from '../init'; + +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]); + + React.useEffect(() => { + if (!chartRef.current) return; + + const { sunburstData, dropoffData } = processData(); + setDropoffPages(dropoffData); + + if (!sunburstData || sunburstData.children.length === 0) { + return; + } + + const chart = echarts.init(chartRef.current); + + const option = { + ...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' + }, + 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); + } + }); + } + + // Handle resize + const ro = new ResizeObserver(() => chart.resize()); + ro.observe(chartRef.current); + + return () => { + chart.dispose(); + ro.disconnect(); + }; + }, [data, height, onChartClick, processData]); + + // 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 = { + 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} +
+ )} +
+ ); +}; + +export default EChartsSunburst; diff --git a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx index 4b0313ef2..fb9aedfc4 100644 --- a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx +++ b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx @@ -4,7 +4,7 @@ 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 SunBurstChart from 'Components/Charts/SunburstChart/Sunburst' import CustomMetricPercentage from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPercentage'; import { Styles } from 'App/components/Dashboard/Widgets/common'; import { observer } from 'mobx-react-lite'; @@ -530,7 +530,17 @@ function WidgetChart(props: Props) { : false; const height = props.isPreview ? 550 : 240; return ( - + { + dashboardStore.drillDownFilter.merge({ filters, page: 1 }); + }} + isUngrouped={isUngrouped} + /> + - ); +
+ ) } if (metricType === RETENTION) { From ab9b374b2b9544e3287b9297260b53a6c139c3af Mon Sep 17 00:00:00 2001 From: nick-delirium Date: Fri, 2 May 2025 17:28:47 +0200 Subject: [PATCH 2/4] 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
+
+ `; + } + } +} From c82820dbbb058dc32ebb98069367d7bab4854d7c Mon Sep 17 00:00:00 2001 From: nick-delirium Date: Mon, 5 May 2025 12:49:39 +0200 Subject: [PATCH 3/4] ui: more details for sunburst --- .../Charts/SunburstChart/DroppedSessions.tsx | 48 ++++++--- .../Charts/SunburstChart/Sunburst.tsx | 70 +++++++------ .../Charts/SunburstChart/sunburstUtils.ts | 97 ++++++++++++++----- .../components/WidgetChart/WidgetChart.tsx | 44 +++++---- .../Dashboard/components/WidgetOptions.tsx | 60 ++++++++---- 5 files changed, 213 insertions(+), 106 deletions(-) diff --git a/frontend/app/components/Charts/SunburstChart/DroppedSessions.tsx b/frontend/app/components/Charts/SunburstChart/DroppedSessions.tsx index 112aa86cf..08474e55e 100644 --- a/frontend/app/components/Charts/SunburstChart/DroppedSessions.tsx +++ b/frontend/app/components/Charts/SunburstChart/DroppedSessions.tsx @@ -1,37 +1,45 @@ -import React from 'react' -import type { Data } from '../SankeyChart' +import React from 'react'; +import type { Data } from '../SankeyChart'; -function DroppedSessionsList({ data, colorMap }: { data: Data, colorMap: Map }) { +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) + 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' + const isDrop = targetNode.eventType === 'DROP'; if (!isDrop) return; const sourceUrl = sourceNode.name; - + console.log(link, sourceUrl, dropsByUrl); if (sourceUrl) { dropsByUrl[sourceUrl] = (dropsByUrl[sourceUrl] || 0) + link.sessionsCount; } }); - const totalDropSessions = Object.values(dropsByUrl).reduce((sum, count) => sum + count, 0); + 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) + percentage: Math.round((count / totalDropSessions) * 100), })) .sort((a, b) => b.count - a.count); - return ( -
-

Droppe Sessions by Page

+
+

Sessions Drop by Page

{sortedDrops.length > 0 ? ( sortedDrops.map((item, index) => ( @@ -39,7 +47,17 @@ function DroppedSessionsList({ data, colorMap }: { data: Data, colorMap: Map -
+
{item.url} {item.count} ({item.percentage}%) @@ -51,6 +69,6 @@ function DroppedSessionsList({ data, colorMap }: { data: Data, colorMap: Map
); -}; +} export default DroppedSessionsList; diff --git a/frontend/app/components/Charts/SunburstChart/Sunburst.tsx b/frontend/app/components/Charts/SunburstChart/Sunburst.tsx index e6b3a624c..e7df0f4c9 100644 --- a/frontend/app/components/Charts/SunburstChart/Sunburst.tsx +++ b/frontend/app/components/Charts/SunburstChart/Sunburst.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { SunburstChart } from 'echarts/charts'; -// import { sankeyTooltip, getEventPriority, getNodeName } from './sankeyUtils'; import { echarts, defaultOptions } from '../init'; -import type { Data } from '../SankeyChart' +import type { Data } from '../SankeyChart'; import DroppedSessionsList from './DroppedSessions'; import { convertSankeyToSunburst, sunburstTooltip } from './sunburstUtils'; @@ -14,22 +13,28 @@ interface Props { } const EChartsSunburst = (props: Props) => { - const { data, height = 240, } = props; + const { data, height = 240 } = props; const chartRef = React.useRef(null); - const [colors, setColors] = React.useState>(new Map()) + const [colors, setColors] = React.useState>(new Map()); React.useEffect(() => { - if (!chartRef.current || data.nodes.length === 0 || data.links.length === 0) return; + if (!chartRef.current || data.nodes.length === 0 || data.links.length === 0) + return; - const chart = echarts.init(chartRef.current) + const chart = echarts.init(chartRef.current); const { tree, colors } = convertSankeyToSunburst(data); - - tree.value = 100; + const singleRoot = + data.nodes.reduce((acc, node) => { + if (node.depth === 0) { + acc++; + } + return acc; + }, 0) === 1; const options = { ...defaultOptions, series: { type: 'sunburst', - data: tree.children, + data: singleRoot ? tree.children : [tree], radius: [30, '90%'], itemStyle: { borderRadius: 6, @@ -41,36 +46,45 @@ const EChartsSunburst = (props: Props) => { show: false, }, tooltip: { - formatter: sunburstTooltip(colors) - } + formatter: sunburstTooltip(colors), + }, }, - } - chart.setOption(options) + }; + chart.setOption(options); const ro = new ResizeObserver(() => chart.resize()); ro.observe(chartRef.current); setColors(colors); return () => { chart.dispose(); ro.disconnect(); - } - }, [data, height]) + }; + }, [data, height]); const containerStyle = { width: '100%', height, + flex: 1, }; - return
-
- -
; -} + return ( +
+
+ +
+ ); +}; export default EChartsSunburst; diff --git a/frontend/app/components/Charts/SunburstChart/sunburstUtils.ts b/frontend/app/components/Charts/SunburstChart/sunburstUtils.ts index 146abaf3d..ca6ca99e7 100644 --- a/frontend/app/components/Charts/SunburstChart/sunburstUtils.ts +++ b/frontend/app/components/Charts/SunburstChart/sunburstUtils.ts @@ -1,38 +1,64 @@ -import { colors } from '../utils' -import type { Data } from '../SankeyChart' -import { toJS } from 'mobx' +import { colors } from '../utils'; +import type { Data } from '../SankeyChart'; export interface SunburstChild { name: string; value: number; - children?: SunburstChild[] + children?: SunburstChild[]; itemStyle?: any; + dataIndex: number; } const colorMap = new Map(); -export function convertSankeyToSunburst(data: Data): { tree: SunburstChild, colors: Map } { - const nodesCopy: any = data.nodes.map(node => ({ +export function convertSankeyToSunburst(data: Data): { + tree: SunburstChild; + colors: Map; +} { + const dataLinks = data.links.filter((link) => { + const sourceNode = data.nodes.find((node) => node.id === link.source); + const targetNode = data.nodes.find((node) => node.id === link.target); + return ( + sourceNode && + targetNode && + ![sourceNode.eventType, targetNode.eventType].includes('OTHER') + ); + }); + const dataNodes = data.nodes.filter((node) => node.eventType !== 'OTHER'); + + const nodesCopy: any = dataNodes.map((node) => ({ ...node, children: [], childrenIds: new Set(), - value: 0 + value: 0, })); - const nodesById: Record = {}; + const nodesById: Record = {}; nodesCopy.forEach((node) => { nodesById[node.id as number] = node; }); - data.links.forEach(link => { + dataLinks.forEach((link) => { const sourceNode = nodesById[link.source as number]; const targetNode = nodesById[link.target as number]; + if (link.source === 0) { + if (sourceNode.value) { + sourceNode.value += link.sessionsCount; + } else { + sourceNode.value = link.sessionsCount; + } + } if (sourceNode && targetNode) { - if ((targetNode.depth) === (sourceNode.depth) + 1 && !sourceNode.childrenIds.has(targetNode.id)) { - const specificId = `${link.source}${link.target}` + 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 - } + ...targetNode, + id: specificId, + value: link.sessionsCount, + }; sourceNode.children.push(fakeNode); sourceNode.childrenIds.add(specificId); } @@ -40,38 +66,54 @@ export function convertSankeyToSunburst(data: Data): { tree: SunburstChild, colo }); const rootNode = nodesById[0]; - + const nameCount: Record = {}; + let dataIndex = 0; function buildSunburstNode(node: SunburstChild): SunburstChild | null { if (!node) return null; + // eventType === DROP if (!node.name) { - // eventType = DROP - return null + // node.name = `DROP` + // colorMap.set('DROP', 'black') + return null; } - let color = colorMap.get(node.name) + let color = colorMap.get(node.name); if (!color) { - color = randomColor(colorMap.size) - colorMap.set(node.name, color) + color = randomColor(colorMap.size); + colorMap.set(node.name, color); + } + let nodeName; + if (nameCount[node.name]) { + nodeName = `${node.name}_$$$_${nameCount[node.name]++}`; + } else { + nodeName = node.name; + nameCount[node.name] = 1; } const result: SunburstChild = { - name: node.name, + name: nodeName, value: node.value || 0, + dataIndex: dataIndex++, itemStyle: { color, - } + }, }; if (node.children && node.children.length > 0) { - result.children = node.children.map(child => buildSunburstNode(child)).filter(Boolean) as SunburstChild[]; + result.children = node.children + .map((child) => buildSunburstNode(child)) + .filter(Boolean) as SunburstChild[]; } return result; } - return { tree: buildSunburstNode(rootNode) as SunburstChild, colors: colorMap } + return { + tree: buildSunburstNode(rootNode) as SunburstChild, + colors: colorMap, + }; } function randomColor(mapSize: number) { - const pointer = mapSize + const pointer = mapSize; if (pointer > colors.length) { colors.push(`#${Math.floor(Math.random() * 16777215).toString(16)}`); } @@ -82,15 +124,18 @@ export function sunburstTooltip(colorMap: Map) { return (params: any) => { if ('name' in params.data) { const color = colorMap.get(params.data.name); + const clearName = params.data.name + ? params.data.name.split('_$$$_')[0] + : 'Total'; return `
■︎ - ${params.data.name} + ${clearName}
${params.value} Sessions
`; } - } + }; } diff --git a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx index fb9aedfc4..811a3c0ba 100644 --- a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx +++ b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx @@ -525,13 +525,13 @@ function WidgetChart(props: Props) { } if (metricType === USER_PATH && data && data.links) { - const isUngrouped = props.isPreview - ? !(_metric.hideExcess ?? true) - : false; - const height = props.isPreview ? 550 : 240; - return ( -
- - { - dashboardStore.drillDownFilter.merge({ filters, page: 1 }); - }} - isUngrouped={isUngrouped} - /> -
- ) + ); + } + if (viewType === 'chart') { + const isUngrouped = props.isPreview + ? !(_metric.hideExcess ?? true) + : false; + const height = props.isPreview ? 550 : 240; + return ( + { + dashboardStore.drillDownFilter.merge({ filters, page: 1 }); + }} + isUngrouped={isUngrouped} + /> + ) + } } if (metricType === RETENTION) { diff --git a/frontend/app/components/Dashboard/components/WidgetOptions.tsx b/frontend/app/components/Dashboard/components/WidgetOptions.tsx index 18f489615..6bcc5c277 100644 --- a/frontend/app/components/Dashboard/components/WidgetOptions.tsx +++ b/frontend/app/components/Dashboard/components/WidgetOptions.tsx @@ -6,7 +6,7 @@ import { TIMESERIES, USER_PATH, } from 'App/constants/card'; -import { Select, Space, Switch, Dropdown, Button } from 'antd'; +import { Space, Switch, Dropdown, Button } from 'antd'; import { DownOutlined } from '@ant-design/icons'; import { useStore } from 'App/mstore'; import ClickMapRagePicker from 'Components/Dashboard/components/ClickMapRagePicker/ClickMapRagePicker'; @@ -24,6 +24,8 @@ import { Library, ChartColumnBig, ChartBarBig, + Split, + CircleDashed, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -38,7 +40,9 @@ function WidgetOptions() { }; // const hasSeriesTypes = [TIMESERIES, FUNNEL, TABLE].includes(metric.metricType); - const hasViewTypes = [TIMESERIES, FUNNEL].includes(metric.metricType); + const hasViewTypes = [TIMESERIES, FUNNEL, USER_PATH].includes( + metric.metricType, + ); return (
{metric.metricType === USER_PATH && ( @@ -154,19 +158,34 @@ const WidgetViewTypeOptions = observer(({ metric }: { metric: any }) => { metric: 'Metric', table: 'Table', }; - const usedChartTypes = - metric.metricType === FUNNEL ? funnelChartTypes : chartTypes; + const pathTypes = { + chart: 'Flow Chart', + sunburst: 'Sunburst', + }; + + const usedChartTypes = { + [FUNNEL]: funnelChartTypes, + [TIMESERIES]: chartTypes, + [USER_PATH]: pathTypes, + }; const chartIcons = { - lineChart: , - barChart: , - areaChart: , - pieChart: , - progressChart: , - metric: , - table: , - // funnel specific - columnChart: , - chart: , + [TIMESERIES]: { + lineChart: , + barChart: , + areaChart: , + pieChart: , + progressChart: , + metric: , + table:
, + }, + [FUNNEL]: { + columnChart: , + chart: , + }, + [USER_PATH]: { + chart: , + sunburst: , + }, }; const allowedTypes = { [TIMESERIES]: [ @@ -179,18 +198,21 @@ const WidgetViewTypeOptions = observer(({ metric }: { metric: any }) => { 'table', ], [FUNNEL]: ['chart', 'columnChart', 'metric', 'table'], + [USER_PATH]: ['chart', 'sunburst'], }; + const metricType = metric.metricType; + const viewType = metric.viewType; return ( ({ + items: allowedTypes[metricType].map((key) => ({ key, label: (
- {chartIcons[key]} -
{usedChartTypes[key]}
+ {chartIcons[metricType][key]} +
{usedChartTypes[metricType][key]}
), })), @@ -207,8 +229,8 @@ const WidgetViewTypeOptions = observer(({ metric }: { metric: any }) => { className="btn-visualization-type" > - {chartIcons[metric.viewType]} -
{usedChartTypes[metric.viewType]}
+ {chartIcons[metricType][viewType]} +
{usedChartTypes[metricType][viewType]}
From 1062f3c2e2fa7187f4a3d9ab773358a230c71db5 Mon Sep 17 00:00:00 2001 From: nick-delirium Date: Mon, 5 May 2025 16:06:21 +0200 Subject: [PATCH 4/4] ui: better drop handling, create list of ids for hover and select events --- .../Charts/SunburstChart/DroppedSessions.tsx | 42 ++++++++----------- .../Charts/SunburstChart/Sunburst.tsx | 27 ++++++++++-- .../Charts/SunburstChart/sunburstUtils.ts | 36 +++++++++++++--- 3 files changed, 72 insertions(+), 33 deletions(-) diff --git a/frontend/app/components/Charts/SunburstChart/DroppedSessions.tsx b/frontend/app/components/Charts/SunburstChart/DroppedSessions.tsx index 08474e55e..b1f58f99b 100644 --- a/frontend/app/components/Charts/SunburstChart/DroppedSessions.tsx +++ b/frontend/app/components/Charts/SunburstChart/DroppedSessions.tsx @@ -2,41 +2,31 @@ import React from 'react'; import type { Data } from '../SankeyChart'; function DroppedSessionsList({ - data, colorMap, + onHover, + dropsByUrl, + onLeave, }: { - data: Data; colorMap: Map; + onHover: (dataIndex: any[]) => void; + dropsByUrl: Record | null; + onLeave: () => void; }) { - 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; - console.log(link, sourceUrl, dropsByUrl); - if (sourceUrl) { - dropsByUrl[sourceUrl] = (dropsByUrl[sourceUrl] || 0) + link.sessionsCount; - } - }); - + console.log(colorMap, dropsByUrl) + if (!dropsByUrl) return null; const totalDropSessions = Object.values(dropsByUrl).reduce( - (sum, count) => sum + count, + (sum, { drop }) => sum + drop, 0, ); const sortedDrops = Object.entries(dropsByUrl) - .map(([url, count]) => ({ + .map(([url, { drop, ids }]) => ({ url, - count, - percentage: Math.round((count / totalDropSessions) * 100), + drop, + ids, + percentage: Math.round((drop / totalDropSessions) * 100), })) - .sort((a, b) => b.count - a.count); + .sort((a, b) => b.drop - a.drop); return (

Sessions Drop by Page

@@ -46,6 +36,8 @@ function DroppedSessionsList({
onHover(item.ids)} + onMouseLeave={() => onLeave()} >
{item.url} - {item.count} + {item.drop} ({item.percentage}%)
)) diff --git a/frontend/app/components/Charts/SunburstChart/Sunburst.tsx b/frontend/app/components/Charts/SunburstChart/Sunburst.tsx index e7df0f4c9..0fc8255fe 100644 --- a/frontend/app/components/Charts/SunburstChart/Sunburst.tsx +++ b/frontend/app/components/Charts/SunburstChart/Sunburst.tsx @@ -16,13 +16,15 @@ const EChartsSunburst = (props: Props) => { const { data, height = 240 } = props; const chartRef = React.useRef(null); const [colors, setColors] = React.useState>(new Map()); + const [chartInst, setChartInst] = React.useState(null); + const [dropsByUrl, setDropsByUrl] = React.useState(null); React.useEffect(() => { if (!chartRef.current || data.nodes.length === 0 || data.links.length === 0) return; const chart = echarts.init(chartRef.current); - const { tree, colors } = convertSankeyToSunburst(data); + const { tree, colors, dropsByUrl } = convertSankeyToSunburst(data); const singleRoot = data.nodes.reduce((acc, node) => { if (node.depth === 0) { @@ -30,11 +32,12 @@ const EChartsSunburst = (props: Props) => { } return acc; }, 0) === 1; + const finalData = singleRoot ? tree.children : [tree] const options = { ...defaultOptions, series: { type: 'sunburst', - data: singleRoot ? tree.children : [tree], + data: finalData, radius: [30, '90%'], itemStyle: { borderRadius: 6, @@ -50,13 +53,18 @@ const EChartsSunburst = (props: Props) => { }, }, }; + console.log(finalData) chart.setOption(options); const ro = new ResizeObserver(() => chart.resize()); ro.observe(chartRef.current); setColors(colors); + setChartInst(chart); + setDropsByUrl(dropsByUrl); return () => { chart.dispose(); ro.disconnect(); + setChartInst(null); + setDropsByUrl(null); }; }, [data, height]); @@ -65,6 +73,19 @@ const EChartsSunburst = (props: Props) => { height, flex: 1, }; + + const onHover = (dataIndex: any[]) => { + chartInst?.dispatchAction({ + type: 'highlight', + dataIndex, + }) + } + const onLeave = () => { + chartInst?.dispatchAction({ + type: 'downplay', + }) + } + return (
{ style={containerStyle} className="min-w-[600px] relative" /> - +
); }; diff --git a/frontend/app/components/Charts/SunburstChart/sunburstUtils.ts b/frontend/app/components/Charts/SunburstChart/sunburstUtils.ts index ca6ca99e7..df50fbad7 100644 --- a/frontend/app/components/Charts/SunburstChart/sunburstUtils.ts +++ b/frontend/app/components/Charts/SunburstChart/sunburstUtils.ts @@ -14,6 +14,7 @@ const colorMap = new Map(); export function convertSankeyToSunburst(data: Data): { tree: SunburstChild; colors: Map; + dropsByUrl: Record; } { const dataLinks = data.links.filter((link) => { const sourceNode = data.nodes.find((node) => node.id === link.source); @@ -34,8 +35,26 @@ export function convertSankeyToSunburst(data: Data): { })); const nodesById: Record = {}; + const dropsByUrl: Record = {}; + dataLinks.forEach((link) => { + const targetNode = nodesCopy.find((node) => node.id === link.target); + const sourceNode = nodesCopy.find((node) => node.id === link.source); + if (!targetNode || !sourceNode) return; + + const isDrop = targetNode.eventType === 'DROP'; + if (!isDrop) return; + + const sourceUrl = sourceNode.name; + if (sourceUrl) { + if (dropsByUrl[sourceUrl]) { + dropsByUrl[sourceUrl].drop = dropsByUrl[sourceUrl].drop + link.sessionsCount; + } else { + dropsByUrl[sourceUrl] = { drop: link.sessionsCount, ids: [] }; + } + } + }); nodesCopy.forEach((node) => { - nodesById[node.id as number] = node; + nodesById[node.id as number] = { ...node, dataIndex: node.id }; }); dataLinks.forEach((link) => { @@ -67,7 +86,6 @@ export function convertSankeyToSunburst(data: Data): { const rootNode = nodesById[0]; const nameCount: Record = {}; - let dataIndex = 0; function buildSunburstNode(node: SunburstChild): SunburstChild | null { if (!node) return null; // eventType === DROP @@ -76,22 +94,29 @@ export function convertSankeyToSunburst(data: Data): { // colorMap.set('DROP', 'black') return null; } + let color = colorMap.get(node.name); if (!color) { color = randomColor(colorMap.size); colorMap.set(node.name, color); } let nodeName; + if (node.name.includes('feature/sess')) { + console.log(node) + } if (nameCount[node.name]) { - nodeName = `${node.name}_$$$_${nameCount[node.name]++}`; + nodeName = `${node.name}_OPENREPLAY_NODE_${nameCount[node.name]++}`; } else { nodeName = node.name; nameCount[node.name] = 1; } + if (dropsByUrl[node.name]) { + dropsByUrl[node.name].ids.push(node.dataIndex); + } const result: SunburstChild = { name: nodeName, value: node.value || 0, - dataIndex: dataIndex++, + dataIndex: node.dataIndex, itemStyle: { color, }, @@ -109,6 +134,7 @@ export function convertSankeyToSunburst(data: Data): { return { tree: buildSunburstNode(rootNode) as SunburstChild, colors: colorMap, + dropsByUrl, }; } @@ -125,7 +151,7 @@ export function sunburstTooltip(colorMap: Map) { if ('name' in params.data) { const color = colorMap.get(params.data.name); const clearName = params.data.name - ? params.data.name.split('_$$$_')[0] + ? params.data.name.split('_OPENREPLAY_NODE_')[0] : 'Total'; return `