Merge 1062f3c2e2 into b477f9637d
This commit is contained in:
commit
afa9f2be3e
6 changed files with 421 additions and 36 deletions
|
|
@ -23,7 +23,7 @@ interface SankeyLink {
|
|||
eventType?: string;
|
||||
}
|
||||
|
||||
interface Data {
|
||||
export interface Data {
|
||||
nodes: SankeyNode[];
|
||||
links: SankeyLink[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
111
frontend/app/components/Charts/SunburstChart/Sunburst.tsx
Normal file
111
frontend/app/components/Charts/SunburstChart/Sunburst.tsx
Normal 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;
|
||||
167
frontend/app/components/Charts/SunburstChart/sunburstUtils.ts
Normal file
167
frontend/app/components/Charts/SunburstChart/sunburstUtils.ts
Normal 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;">■︎</span>
|
||||
${clearName}
|
||||
</div>
|
||||
<div class="text-black text-sm">${params.value} <span class="text-disabled-text">Sessions</span></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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