ui: more details for sunburst
This commit is contained in:
parent
ab9b374b2b
commit
c82820dbbb
5 changed files with 213 additions and 106 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;">■︎</span>
|
||||
${params.data.name}
|
||||
${clearName}
|
||||
</div>
|
||||
<div class="text-black text-sm">${params.value} <span class="text-disabled-text">Sessions</span></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue