ui: more details for sunburst

This commit is contained in:
nick-delirium 2025-05-05 12:49:39 +02:00
parent ab9b374b2b
commit c82820dbbb
No known key found for this signature in database
GPG key ID: 93ABD695DF5FDBA0
5 changed files with 213 additions and 106 deletions

View file

@ -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<string, string> }) {
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)
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 (
<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="bg-white rounded-lg border shadow p-4 h-fit min-w-[210px] w-1/3">
<h3 className="text-lg font-medium mb-3">Sessions Drop by Page</h3>
<div className="space-y-1.5">
{sortedDrops.length > 0 ? (
sortedDrops.map((item, index) => (
@ -39,7 +47,17 @@ function DroppedSessionsList({ data, colorMap }: { data: Data, colorMap: Map<str
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, }} />
<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>
@ -51,6 +69,6 @@ function DroppedSessionsList({ data, colorMap }: { data: Data, colorMap: Map<str
</div>
</div>
);
};
}
export default DroppedSessionsList;

View file

@ -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<HTMLDListElement>(null);
const [colors, setColors] = React.useState<Map<string, string>>(new Map())
const [colors, setColors] = React.useState<Map<string, string>>(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 <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>;
}
return (
<div
style={{
maxHeight: 620,
overflow: 'auto',
maxWidth: 1240,
minHeight: 240,
display: 'flex',
gap: '0.5rem',
margin: '0 auto',
}}
>
<div
ref={chartRef}
style={containerStyle}
className="min-w-[600px] relative"
/>
<DroppedSessionsList colorMap={colors} data={data} />
</div>
);
};
export default EChartsSunburst;

View file

@ -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<string, string> } {
const nodesCopy: any = data.nodes.map(node => ({
export function convertSankeyToSunburst(data: Data): {
tree: SunburstChild;
colors: Map<string, string>;
} {
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<number, typeof nodesCopy[number]> = {};
const nodesById: Record<number, (typeof nodesCopy)[number]> = {};
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<string, number> = {};
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<string, string>) {
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 `
<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}
${clearName}
</div>
<div class="text-black text-sm">${params.value} <span class="text-disabled-text">Sessions</span></div>
</div>
`;
}
}
};
}

View file

@ -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 (
<div>
<SankeyChart
if (viewType === 'sunburst') {
const isUngrouped = props.isPreview
? !(_metric.hideExcess ?? true)
: false;
const height = props.isPreview ? 550 : 240;
return (
<SunBurstChart
height={height}
data={data}
inGrid={!props.isPreview}
@ -540,17 +540,25 @@ function WidgetChart(props: Props) {
}}
isUngrouped={isUngrouped}
/>
<SunBurstChart
height={height}
data={data}
inGrid={!props.isPreview}
onChartClick={(filters: any) => {
dashboardStore.drillDownFilter.merge({ filters, page: 1 });
}}
isUngrouped={isUngrouped}
/>
</div>
)
);
}
if (viewType === 'chart') {
const isUngrouped = props.isPreview
? !(_metric.hideExcess ?? true)
: false;
const height = props.isPreview ? 550 : 240;
return (
<SankeyChart
height={height}
data={data}
inGrid={!props.isPreview}
onChartClick={(filters: any) => {
dashboardStore.drillDownFilter.merge({ filters, page: 1 });
}}
isUngrouped={isUngrouped}
/>
)
}
}
if (metricType === RETENTION) {

View file

@ -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 (
<div className="flex items-center gap-2">
{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: <ChartLine size={16} strokeWidth={1} />,
barChart: <ChartColumn size={16} strokeWidth={1} />,
areaChart: <ChartArea size={16} strokeWidth={1} />,
pieChart: <ChartPie size={16} strokeWidth={1} />,
progressChart: <ChartBar size={16} strokeWidth={1} />,
metric: <Hash size={16} strokeWidth={1} />,
table: <Table size={16} strokeWidth={1} />,
// funnel specific
columnChart: <ChartColumnBig size={16} strokeWidth={1} />,
chart: <ChartBarBig size={16} strokeWidth={1} />,
[TIMESERIES]: {
lineChart: <ChartLine size={16} strokeWidth={1} />,
barChart: <ChartColumn size={16} strokeWidth={1} />,
areaChart: <ChartArea size={16} strokeWidth={1} />,
pieChart: <ChartPie size={16} strokeWidth={1} />,
progressChart: <ChartBar size={16} strokeWidth={1} />,
metric: <Hash size={16} strokeWidth={1} />,
table: <Table size={16} strokeWidth={1} />,
},
[FUNNEL]: {
columnChart: <ChartColumnBig size={16} strokeWidth={1} />,
chart: <ChartBarBig size={16} strokeWidth={1} />,
},
[USER_PATH]: {
chart: <Split size={16} strokeWidth={1} />,
sunburst: <CircleDashed size={16} strokeWidth={1} />,
},
};
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 (
<Dropdown
trigger={['click']}
menu={{
selectable: true,
items: allowedTypes[metric.metricType].map((key) => ({
items: allowedTypes[metricType].map((key) => ({
key,
label: (
<div className="flex gap-2 items-center">
{chartIcons[key]}
<div>{usedChartTypes[key]}</div>
{chartIcons[metricType][key]}
<div>{usedChartTypes[metricType][key]}</div>
</div>
),
})),
@ -207,8 +229,8 @@ const WidgetViewTypeOptions = observer(({ metric }: { metric: any }) => {
className="btn-visualization-type"
>
<Space>
{chartIcons[metric.viewType]}
<div>{usedChartTypes[metric.viewType]}</div>
{chartIcons[metricType][viewType]}
<div>{usedChartTypes[metricType][viewType]}</div>
<DownOutlined className="text-sm " />
</Space>
</Button>