mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +01:00
302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
import { Column, Focusable, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
|
|
import classNames from 'classnames';
|
|
import { useMemo, useState } from 'react';
|
|
import { firstBy } from 'thenby';
|
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
|
import { useEscapeKey, useMessages, useResultQuery } from '@/components/hooks';
|
|
import { File } from '@/components/icons';
|
|
import { Lightning } from '@/components/svg';
|
|
import { objectToArray } from '@/lib/data';
|
|
import { formatLongNumber } from '@/lib/format';
|
|
import styles from './Journey.module.css';
|
|
|
|
const NODE_HEIGHT = 60;
|
|
const NODE_GAP = 10;
|
|
const LINE_WIDTH = 3;
|
|
|
|
export interface JourneyProps {
|
|
websiteId: string;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
steps: number;
|
|
startStep?: string;
|
|
endStep?: string;
|
|
view: string;
|
|
}
|
|
|
|
const EVENT_TYPES = {
|
|
views: 1,
|
|
events: 2,
|
|
};
|
|
|
|
export function Journey({ websiteId, steps, startStep, endStep, view }: JourneyProps) {
|
|
const [selectedNode, setSelectedNode] = useState(null);
|
|
const [activeNode, setActiveNode] = useState(null);
|
|
const { formatMessage, labels } = useMessages();
|
|
const { data, error, isLoading } = useResultQuery<any>('journey', {
|
|
websiteId,
|
|
steps,
|
|
startStep,
|
|
endStep,
|
|
view,
|
|
eventType: EVENT_TYPES[view],
|
|
});
|
|
|
|
useEscapeKey(() => setSelectedNode(null));
|
|
|
|
const columns = useMemo(() => {
|
|
if (!data) {
|
|
return [];
|
|
}
|
|
|
|
const selectedPaths = selectedNode?.paths ?? [];
|
|
const activePaths = activeNode?.paths ?? [];
|
|
const columns = [];
|
|
|
|
for (let columnIndex = 0; columnIndex < +steps; columnIndex++) {
|
|
const nodes = {};
|
|
|
|
data.forEach(({ items, count }: any, nodeIndex: any) => {
|
|
const name = items[columnIndex];
|
|
|
|
if (name) {
|
|
const selected = !!selectedPaths.find(({ items }) => items[columnIndex] === name);
|
|
const active = selected && !!activePaths.find(({ items }) => items[columnIndex] === name);
|
|
|
|
if (!nodes[name]) {
|
|
const paths = data.filter(({ items }) => items[columnIndex] === name);
|
|
|
|
nodes[name] = {
|
|
name,
|
|
count,
|
|
totalCount: count,
|
|
nodeIndex,
|
|
columnIndex,
|
|
selected,
|
|
active,
|
|
paths,
|
|
pathMap: paths.map(({ items, count }) => ({
|
|
[`${columnIndex}:${items.join(':')}`]: count,
|
|
})),
|
|
};
|
|
} else {
|
|
nodes[name].totalCount += count;
|
|
}
|
|
}
|
|
});
|
|
|
|
columns.push({
|
|
nodes: objectToArray(nodes).sort(firstBy('total', -1)),
|
|
});
|
|
}
|
|
|
|
columns.forEach((column, columnIndex) => {
|
|
const nodes = column.nodes.map(
|
|
(
|
|
currentNode: { totalCount: number; name: string; selected: boolean },
|
|
currentNodeIndex: any,
|
|
) => {
|
|
const previousNodes = columns[columnIndex - 1]?.nodes;
|
|
let selectedCount = previousNodes ? 0 : currentNode.totalCount;
|
|
let activeCount = selectedCount;
|
|
|
|
const lines =
|
|
previousNodes?.reduce((arr: any[][], previousNode: any, previousNodeIndex: number) => {
|
|
const fromCount = selectedNode?.paths.reduce((sum, path) => {
|
|
if (
|
|
previousNode.name === path.items[columnIndex - 1] &&
|
|
currentNode.name === path.items[columnIndex]
|
|
) {
|
|
sum += path.count;
|
|
}
|
|
return sum;
|
|
}, 0);
|
|
|
|
if (currentNode.selected && previousNode.selected && fromCount) {
|
|
arr.push([previousNodeIndex, currentNodeIndex]);
|
|
selectedCount += fromCount;
|
|
|
|
if (previousNode.active) {
|
|
activeCount += fromCount;
|
|
}
|
|
}
|
|
|
|
return arr;
|
|
}, []) || [];
|
|
|
|
return { ...currentNode, selectedCount, activeCount, lines };
|
|
},
|
|
);
|
|
|
|
const visitorCount = nodes.reduce(
|
|
(sum: number, { selected, selectedCount, active, activeCount, totalCount }) => {
|
|
if (!selectedNode) {
|
|
sum += totalCount;
|
|
} else if (!activeNode && selectedNode && selected) {
|
|
sum += selectedCount;
|
|
} else if (activeNode && 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;
|
|
}, [data, selectedNode, activeNode]);
|
|
|
|
const handleClick = (name: string, columnIndex: number, paths: any[]) => {
|
|
if (name !== selectedNode?.name || columnIndex !== selectedNode?.columnIndex) {
|
|
setSelectedNode({ name, columnIndex, paths });
|
|
} else {
|
|
setSelectedNode(null);
|
|
}
|
|
setActiveNode(null);
|
|
};
|
|
|
|
return (
|
|
<LoadingPanel data={data} isLoading={isLoading} error={error} height="100%">
|
|
<div className={styles.container}>
|
|
<div className={styles.view}>
|
|
{columns.map(({ visitorCount, nodes }, columnIndex) => {
|
|
return (
|
|
<div
|
|
key={columnIndex}
|
|
className={classNames(styles.column, {
|
|
[styles.selected]: selectedNode,
|
|
[styles.active]: activeNode,
|
|
})}
|
|
>
|
|
<div className={styles.header}>
|
|
<div className={styles.num}>{columnIndex + 1}</div>
|
|
<div className={styles.stats}>
|
|
<div className={styles.visitors} title={visitorCount}>
|
|
{formatLongNumber(visitorCount)} {formatMessage(labels.visitors)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className={styles.nodes}>
|
|
{nodes.map(
|
|
({
|
|
name,
|
|
totalCount,
|
|
selected,
|
|
active,
|
|
paths,
|
|
activeCount,
|
|
selectedCount,
|
|
lines,
|
|
}) => {
|
|
const nodeCount = selected
|
|
? active
|
|
? activeCount
|
|
: selectedCount
|
|
: totalCount;
|
|
|
|
const remaining =
|
|
columnIndex > 0
|
|
? Math.round((nodeCount / columns[columnIndex - 1]?.visitorCount) * 100)
|
|
: 0;
|
|
|
|
const dropped = 100 - remaining;
|
|
|
|
return (
|
|
<div
|
|
key={name}
|
|
className={styles.wrapper}
|
|
onMouseEnter={() =>
|
|
selected && setActiveNode({ name, columnIndex, paths })
|
|
}
|
|
onMouseLeave={() => selected && setActiveNode(null)}
|
|
>
|
|
<div
|
|
className={classNames(styles.node, {
|
|
[styles.selected]: selected,
|
|
[styles.active]: active,
|
|
})}
|
|
onClick={() => handleClick(name, columnIndex, paths)}
|
|
>
|
|
<Row alignItems="center" className={styles.name} title={name} gap>
|
|
<Icon>{name.startsWith('/') ? <File /> : <Lightning />}</Icon>
|
|
<Text truncate>{name}</Text>
|
|
</Row>
|
|
<div className={styles.count} title={nodeCount}>
|
|
<TooltipTrigger
|
|
delay={0}
|
|
isDisabled={columnIndex === 0 || (selectedNode && !selected)}
|
|
>
|
|
<Focusable>
|
|
<div>{formatLongNumber(nodeCount)}</div>
|
|
</Focusable>
|
|
<Tooltip placement="top" offset={20} showArrow>
|
|
<Text transform="lowercase" color="ruby">
|
|
{`${dropped}% ${formatMessage(labels.dropoff)}`}
|
|
</Text>
|
|
<Column>
|
|
<Text transform="lowercase">
|
|
{`${remaining}% ${formatMessage(labels.conversion)}`}
|
|
</Text>
|
|
</Column>
|
|
</Tooltip>
|
|
</TooltipTrigger>
|
|
</div>
|
|
{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: { items: any[] }) =>
|
|
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>
|
|
</div>
|
|
);
|
|
},
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</LoadingPanel>
|
|
);
|
|
}
|