This commit is contained in:
Delirium 2025-05-06 10:32:15 +02:00 committed by GitHub
commit afa9f2be3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 421 additions and 36 deletions

View file

@ -23,7 +23,7 @@ interface SankeyLink {
eventType?: string;
}
interface Data {
export interface Data {
nodes: SankeyNode[];
links: SankeyLink[];
}

View file

@ -0,0 +1,66 @@
import React from 'react';
import type { Data } from '../SankeyChart';
function DroppedSessionsList({
colorMap,
onHover,
dropsByUrl,
onLeave,
}: {
colorMap: Map<string, string>;
onHover: (dataIndex: any[]) => void;
dropsByUrl: Record<string, { drop: number, ids: any[] }> | 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 (
<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) => (
<div
key={index}
className="py-1.5 px-2 hover:bg-gray-50 rounded transition-colors flex justify-between gap-2 relative"
onMouseEnter={() => onHover(item.ids)}
onMouseLeave={() => onLeave()}
>
<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.drop}</span>
<span className="text-gray-400">({item.percentage}%)</span>
</div>
))
) : (
<div className="text-gray-500 py-2">No drop sessions found</div>
)}
</div>
</div>
);
}
export default DroppedSessionsList;

View file

@ -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<HTMLDListElement>(null);
const [colors, setColors] = React.useState<Map<string, string>>(new Map());
const [chartInst, setChartInst] = React.useState<echarts.ECharts | null>(null);
const [dropsByUrl, setDropsByUrl] = React.useState<any>(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 (
<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 dropsByUrl={dropsByUrl} onHover={onHover} onLeave={onLeave} colorMap={colors} data={data} />
</div>
);
};
export default EChartsSunburst;

View file

@ -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<string, string>;
dropsByUrl: Record<string, { drop: number, ids: any[] }>;
} {
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<number, (typeof nodesCopy)[number]> = {};
const dropsByUrl: Record<string, { drop: number, ids: any[] }> = {};
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<string, number> = {};
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<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('_OPENREPLAY_NODE_')[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>
${clearName}
</div>
<div class="text-black text-sm">${params.value} <span class="text-disabled-text">Sessions</span></div>
</div>
`;
}
};
}

View file

@ -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 (
<SankeyChart
height={height}
data={data}
inGrid={!props.isPreview}
onChartClick={(filters: any) => {
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 (
<SunBurstChart
height={height}
data={data}
inGrid={!props.isPreview}
onChartClick={(filters: any) => {
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 (
<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>