umami/src/app/(main)/reports/journey/JourneyView.tsx

256 lines
8.9 KiB
TypeScript
Raw Normal View History

2024-06-01 18:45:06 +00:00
import { useContext, useMemo, useState } from 'react';
2024-06-15 01:33:54 +00:00
import { TooltipPopup } from 'react-basics';
2024-06-01 18:45:06 +00:00
import { firstBy } from 'thenby';
import classNames from 'classnames';
2024-06-15 01:33:54 +00:00
import { useEscapeKey, useMessages } from 'components/hooks';
import { objectToArray } from 'lib/data';
import { ReportContext } from '../[reportId]/Report';
// eslint-disable-next-line css-modules/no-unused-class
2024-06-05 02:53:49 +00:00
import styles from './JourneyView.module.css';
2024-05-17 08:42:36 +00:00
const NODE_HEIGHT = 60;
const NODE_GAP = 10;
2024-06-08 05:44:00 +00:00
const LINE_WIDTH = 3;
2024-05-17 08:42:36 +00:00
export default function JourneyView() {
2024-06-05 02:53:49 +00:00
const [selectedNode, setSelectedNode] = useState(null);
const [activeNode, setActiveNode] = useState(null);
2024-05-17 08:42:36 +00:00
const { report } = useContext(ReportContext);
2024-06-04 06:40:38 +00:00
const { data, parameters } = report || {};
2024-06-15 01:33:54 +00:00
const { formatMessage, labels } = useMessages();
2024-06-05 02:53:49 +00:00
useEscapeKey(() => setSelectedNode(null));
2024-06-01 18:45:06 +00:00
const columns = useMemo(() => {
if (!data) {
return [];
}
2024-06-14 05:15:31 +00:00
const selectedPaths = selectedNode?.paths ?? [];
const activePaths = activeNode?.paths ?? [];
2024-06-15 03:44:47 +00:00
const columns = [];
for (let columnIndex = 0; columnIndex < +parameters.steps; columnIndex++) {
const nodes = {};
data.forEach(({ items, count }: any, nodeIndex: any) => {
const name = items[columnIndex];
if (name) {
const selected = !!selectedPaths.find(path => path.items[columnIndex] === name);
const active = selected && !!activePaths.find(path => path.items[columnIndex] === name);
if (!nodes[name]) {
const paths = data.filter((d, i) => {
return i !== columnIndex && d.items[columnIndex] === name;
});
const from =
columnIndex > 0 &&
selected &&
paths.reduce((obj, path) => {
const { items, count } = path;
const name = items[columnIndex - 1];
if (!obj[name]) {
obj[name] = { name, count };
} else {
obj[name].count += count;
}
return obj;
}, {});
nodes[name] = {
name,
count,
totalCount: count,
nodeIndex,
columnIndex,
selected,
active,
paths,
from: objectToArray(from),
};
} else {
nodes[name].totalCount += count;
}
}
});
columns.push({
nodes: objectToArray(nodes).sort(firstBy('total', -1)),
});
}
2024-06-14 05:15:31 +00:00
2024-06-15 03:44:47 +00:00
columns.forEach((column, columnIndex) => {
const nodes = column.nodes.map((node, nodeIndex) => {
const { from, totalCount } = node;
const previousNodes = columns[columnIndex - 1]?.nodes;
let selectedCount = from?.length ? 0 : totalCount;
let activeCount = selectedCount;
const lines = from?.reduce((arr: any[][], { name, count }: any) => {
const fromIndex = previousNodes.findIndex((node: { name: any; selected: any }) => {
return node.name === name && node.selected;
});
if (fromIndex > -1) {
arr.push([fromIndex, nodeIndex]);
selectedCount += count;
2024-06-01 18:45:06 +00:00
}
2024-06-15 03:44:47 +00:00
if (
previousNodes.findIndex(node => {
return node.name === name && node.active;
}) > -1
) {
activeCount += count;
}
return arr;
}, []);
return { ...node, selectedCount, activeCount, lines };
2024-06-01 18:45:06 +00:00
});
2024-06-15 03:44:47 +00:00
const visitorCount = nodes.reduce(
(sum: number, { selected, selectedCount, active, activeCount, totalCount }) => {
if (!selectedNode) {
sum += totalCount;
} else if (!activeNode && selected) {
sum += selectedCount;
} else if (active) {
sum += activeCount;
}
return sum;
},
0,
);
const previousTotal = columns[columnIndex - 1]?.visitorCount ?? 0;
const dropOff =
previousTotal > 0 ? ((visitorCount - previousTotal) / previousTotal) * 100 : 0;
Object.assign(column, { nodes, visitorCount, dropOff });
});
return columns;
2024-06-14 05:15:31 +00:00
}, [data, selectedNode, activeNode]);
2024-06-01 18:45:06 +00:00
2024-06-14 05:15:31 +00:00
const handleClick = (name: string, index: number, paths: any[]) => {
if (name !== selectedNode?.name || index !== selectedNode?.index) {
setSelectedNode({ name, index, paths });
2024-06-01 18:45:06 +00:00
} else {
2024-06-05 02:53:49 +00:00
setSelectedNode(null);
2024-06-01 18:45:06 +00:00
}
2024-06-15 03:44:47 +00:00
setActiveNode(null);
2024-06-01 18:45:06 +00:00
};
2024-05-17 08:42:36 +00:00
if (!data) {
return null;
}
2024-06-01 18:45:06 +00:00
return (
<div className={styles.container}>
<div className={styles.view}>
{columns.map((column, columnIndex) => {
2024-06-15 03:44:47 +00:00
const dropOffPercent = `${~~column.dropOff}%`;
2024-06-01 18:45:06 +00:00
return (
<div
key={columnIndex}
2024-06-15 03:44:47 +00:00
className={classNames(styles.column, {
[styles.selected]: selectedNode,
[styles.active]: activeNode,
})}
2024-06-01 18:45:06 +00:00
>
<div className={styles.header}>
<div className={styles.num}>{columnIndex + 1}</div>
2024-06-15 01:33:54 +00:00
<div className={styles.stats}>
<div className={styles.visitors}>
2024-06-15 03:44:47 +00:00
{column.visitorCount} {formatMessage(labels.visitors)}
2024-06-15 01:33:54 +00:00
</div>
2024-06-15 03:44:47 +00:00
{columnIndex > 0 && <div className={styles.dropoff}>{dropOffPercent}</div>}
2024-06-15 01:33:54 +00:00
</div>
2024-06-01 18:45:06 +00:00
</div>
2024-06-05 02:53:49 +00:00
<div className={styles.nodes}>
2024-06-15 03:44:47 +00:00
{column.nodes.map(
({
name,
totalCount,
selected,
active,
paths,
activeCount,
selectedCount,
lines,
}) => {
return (
<div
key={name}
className={classNames(styles.item, {
[styles.selected]: selected,
[styles.active]: active,
2024-06-14 05:15:31 +00:00
})}
2024-06-15 03:44:47 +00:00
onClick={() => handleClick(name, columnIndex, paths)}
onMouseEnter={() => selected && setActiveNode({ name, columnIndex, paths })}
onMouseLeave={() => selected && setActiveNode(null)}
>
<div className={styles.name}>{name}</div>
<TooltipPopup label={dropOffPercent} disabled={!selected}>
<div className={styles.count}>
{selected ? (active ? activeCount : selectedCount) : totalCount}
</div>
</TooltipPopup>
{columnIndex < columns.length &&
lines.map(([fromIndex, nodeIndex], i) => {
const height =
(Math.abs(nodeIndex - fromIndex) + 1) * (NODE_HEIGHT + NODE_GAP) -
NODE_GAP;
const midHeight =
(Math.abs(nodeIndex - fromIndex) - 1) * (NODE_HEIGHT + NODE_GAP) +
NODE_GAP +
LINE_WIDTH;
const nodeName = columns[columnIndex - 1]?.nodes[fromIndex].name;
return (
<div
key={`${fromIndex}${nodeIndex}${i}`}
className={classNames(styles.line, {
[styles.active]:
active &&
activeNode?.paths.find(
path =>
path.items[columnIndex] === name &&
path.items[columnIndex - 1] === nodeName,
),
[styles.up]: fromIndex < nodeIndex,
[styles.down]: fromIndex > nodeIndex,
[styles.flat]: fromIndex === nodeIndex,
})}
style={{ height }}
>
<div className={classNames(styles.segment, styles.start)} />
<div
className={classNames(styles.segment, styles.mid)}
style={{
height: midHeight,
}}
/>
<div className={classNames(styles.segment, styles.end)} />
</div>
);
})}
</div>
);
},
)}
2024-06-05 02:53:49 +00:00
</div>
2024-06-01 18:45:06 +00:00
</div>
);
})}
</div>
</div>
);
2024-05-17 08:42:36 +00:00
}