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/DroppedSessions.tsx b/frontend/app/components/Charts/SunburstChart/DroppedSessions.tsx new file mode 100644 index 000000000..b1f58f99b --- /dev/null +++ b/frontend/app/components/Charts/SunburstChart/DroppedSessions.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import type { Data } from '../SankeyChart'; + +function DroppedSessionsList({ + colorMap, + onHover, + dropsByUrl, + onLeave, +}: { + colorMap: Map; + onHover: (dataIndex: any[]) => void; + dropsByUrl: Record | null; + onLeave: () => void; +}) { + console.log(colorMap, dropsByUrl) + if (!dropsByUrl) return null; + const totalDropSessions = Object.values(dropsByUrl).reduce( + (sum, { drop }) => sum + drop, + 0, + ); + + const sortedDrops = Object.entries(dropsByUrl) + .map(([url, { drop, ids }]) => ({ + url, + drop, + ids, + percentage: Math.round((drop / totalDropSessions) * 100), + })) + .sort((a, b) => b.drop - a.drop); + return ( +
+

Sessions Drop by Page

+
+ {sortedDrops.length > 0 ? ( + sortedDrops.map((item, index) => ( +
onHover(item.ids)} + onMouseLeave={() => onLeave()} + > +
+ {item.url} + {item.drop} + ({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 new file mode 100644 index 000000000..0fc8255fe --- /dev/null +++ b/frontend/app/components/Charts/SunburstChart/Sunburst.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { SunburstChart } from 'echarts/charts'; +import { echarts, defaultOptions } from '../init'; +import type { Data } from '../SankeyChart'; +import DroppedSessionsList from './DroppedSessions'; +import { convertSankeyToSunburst, sunburstTooltip } from './sunburstUtils'; + +echarts.use([SunburstChart]); + +interface Props { + data: Data; + height?: number; +} + +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, dropsByUrl } = convertSankeyToSunburst(data); + const singleRoot = + data.nodes.reduce((acc, node) => { + if (node.depth === 0) { + acc++; + } + return acc; + }, 0) === 1; + const finalData = singleRoot ? tree.children : [tree] + const options = { + ...defaultOptions, + series: { + type: 'sunburst', + data: finalData, + radius: [30, '90%'], + itemStyle: { + borderRadius: 6, + borderWidth: 2, + }, + center: ['50%', '50%'], + clockwise: true, + label: { + show: false, + }, + tooltip: { + formatter: sunburstTooltip(colors), + }, + }, + }; + 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]); + + const containerStyle = { + width: '100%', + height, + flex: 1, + }; + + const onHover = (dataIndex: any[]) => { + chartInst?.dispatchAction({ + type: 'highlight', + dataIndex, + }) + } + const onLeave = () => { + chartInst?.dispatchAction({ + type: 'downplay', + }) + } + + 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..df50fbad7 --- /dev/null +++ b/frontend/app/components/Charts/SunburstChart/sunburstUtils.ts @@ -0,0 +1,167 @@ +import { colors } from '../utils'; +import type { Data } from '../SankeyChart'; + +export interface SunburstChild { + name: string; + value: number; + children?: SunburstChild[]; + itemStyle?: any; + dataIndex: number; +} + +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); + 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, + })); + + 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, dataIndex: node.id }; + }); + + 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}`; + const fakeNode = { + ...targetNode, + id: specificId, + value: link.sessionsCount, + }; + sourceNode.children.push(fakeNode); + sourceNode.childrenIds.add(specificId); + } + } + }); + + const rootNode = nodesById[0]; + const nameCount: Record = {}; + function buildSunburstNode(node: SunburstChild): SunburstChild | null { + if (!node) return null; + // eventType === DROP + if (!node.name) { + // node.name = `DROP` + // 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}_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: node.dataIndex, + 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, + dropsByUrl, + }; +} + +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); + const clearName = params.data.name + ? params.data.name.split('_OPENREPLAY_NODE_')[0] + : 'Total'; + return ` +
+
+ ■︎ + ${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 4b0313ef2..811a3c0ba 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'; @@ -525,21 +525,40 @@ 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 === 'sunburst') { + 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]}