mirror of
https://github.com/kremalicious/umami.git
synced 2025-02-14 21:10:34 +01:00
Journey report view with nodes and lines.
This commit is contained in:
parent
9abb201d86
commit
d5e14fb5a8
@ -2,6 +2,9 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
--journey-line-color: var(--base600);
|
||||||
|
--journey-active-color: var(--primary400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.view {
|
.view {
|
||||||
@ -43,18 +46,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nodes {
|
.nodes {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
background: var(--base75);
|
background: var(--base75);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
}
|
}
|
||||||
@ -70,6 +77,10 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item.active {
|
||||||
|
background: var(--primary400);
|
||||||
|
}
|
||||||
|
|
||||||
.behind {
|
.behind {
|
||||||
color: var(--base400);
|
color: var(--base400);
|
||||||
}
|
}
|
||||||
@ -81,3 +92,137 @@
|
|||||||
.current {
|
.current {
|
||||||
color: var(--base500);
|
color: var(--base500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: var(--base200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected .count {
|
||||||
|
color: var(--base50);
|
||||||
|
background: var(--base800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
position: absolute;
|
||||||
|
left: -100px;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.flat:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 97px;
|
||||||
|
left: 3px;
|
||||||
|
border-top: 3px solid var(--journey-line-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.fromUp:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -30px;
|
||||||
|
left: 50px;
|
||||||
|
margin-top: 3px;
|
||||||
|
margin-left: -3px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom-left-radius: 100%;
|
||||||
|
border-left: 3px solid var(--journey-line-color);
|
||||||
|
border-bottom: 3px solid var(--journey-line-color);
|
||||||
|
width: 50px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.fromDown:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50px;
|
||||||
|
margin-left: -3px;
|
||||||
|
border: 0;
|
||||||
|
border-top-left-radius: 100%;
|
||||||
|
border-left: 3px solid var(--journey-line-color);
|
||||||
|
border-top: 3px solid var(--journey-line-color);
|
||||||
|
width: 50px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.fromUp:after,
|
||||||
|
.line.fromDown:after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
margin: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border-radius: 100%;
|
||||||
|
border: 3px solid var(--journey-line-color);
|
||||||
|
background: var(--base50);
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.toUp:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -30px;
|
||||||
|
margin-top: 3px;
|
||||||
|
right: -350px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom-right-radius: 100%;
|
||||||
|
border-right: 3px solid var(--journey-line-color);
|
||||||
|
border-bottom: 3px solid var(--journey-line-color);
|
||||||
|
width: 50px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.toDown:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -350px;
|
||||||
|
border: 0;
|
||||||
|
border-top-right-radius: 100%;
|
||||||
|
border-right: 3px solid var(--journey-line-color);
|
||||||
|
border-top: 3px solid var(--journey-line-color);
|
||||||
|
width: 50px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.toUp:after,
|
||||||
|
.line.toDown:after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -306px;
|
||||||
|
margin: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border-radius: 100%;
|
||||||
|
border: 3px solid var(--journey-line-color);
|
||||||
|
background: var(--base50);
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
position: absolute;
|
||||||
|
left: -100px;
|
||||||
|
width: 50px;
|
||||||
|
height: 20px;
|
||||||
|
border-right: 3px solid var(--journey-line-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.active .bar,
|
||||||
|
.item.active .line:before,
|
||||||
|
.item.active .line:after {
|
||||||
|
border-color: var(--journey-active-color);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.active .count {
|
||||||
|
background: var(--primary600);
|
||||||
|
}
|
||||||
|
@ -1,12 +1,19 @@
|
|||||||
import { useContext, useMemo, useState } from 'react';
|
import { useContext, useMemo, useState } from 'react';
|
||||||
import { ReportContext } from '../[reportId]/Report';
|
|
||||||
import { firstBy } from 'thenby';
|
import { firstBy } from 'thenby';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useEscapeKey } from 'components/hooks';
|
import { useEscapeKey } from 'components/hooks';
|
||||||
|
import { objectToArray } from 'lib/data';
|
||||||
|
import { ReportContext } from '../[reportId]/Report';
|
||||||
|
// eslint-disable-next-line css-modules/no-unused-class
|
||||||
import styles from './JourneyView.module.css';
|
import styles from './JourneyView.module.css';
|
||||||
|
|
||||||
|
const NODE_HEIGHT = 60;
|
||||||
|
const NODE_GAP = 10;
|
||||||
|
const BAR_OFFSET = 3;
|
||||||
|
|
||||||
export default function JourneyView() {
|
export default function JourneyView() {
|
||||||
const [selectedNode, setSelectedNode] = useState(null);
|
const [selectedNode, setSelectedNode] = useState(null);
|
||||||
|
const [activeNode, setActiveNode] = useState(null);
|
||||||
const { report } = useContext(ReportContext);
|
const { report } = useContext(ReportContext);
|
||||||
const { data, parameters } = report || {};
|
const { data, parameters } = report || {};
|
||||||
|
|
||||||
@ -18,34 +25,63 @@ export default function JourneyView() {
|
|||||||
}
|
}
|
||||||
return Array(Number(parameters.steps))
|
return Array(Number(parameters.steps))
|
||||||
.fill(undefined)
|
.fill(undefined)
|
||||||
.map((col = {}, index) => {
|
.map((column = {}, index) => {
|
||||||
data.forEach(({ items, count }) => {
|
data.forEach(({ items, count }) => {
|
||||||
const item = items[index];
|
const name = items[index];
|
||||||
|
const selectedNodes = selectedNode?.paths ?? [];
|
||||||
|
|
||||||
if (item) {
|
if (name) {
|
||||||
if (!col[item]) {
|
if (!column[name]) {
|
||||||
col[item] = {
|
const selected = !!selectedNodes.find(a => a.items[index] === name);
|
||||||
item,
|
const paths = data.filter((d, i) => {
|
||||||
|
return i !== index && d.items[index] === name;
|
||||||
|
});
|
||||||
|
const from =
|
||||||
|
index > 0 &&
|
||||||
|
selected &&
|
||||||
|
paths.reduce((obj, path) => {
|
||||||
|
const { items, count } = path;
|
||||||
|
const name = items[index - 1];
|
||||||
|
if (!obj[name]) {
|
||||||
|
obj[name] = { name, count: +count };
|
||||||
|
} else {
|
||||||
|
obj[name].count += +count;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}, {});
|
||||||
|
const to =
|
||||||
|
selected &&
|
||||||
|
paths.reduce((obj, path) => {
|
||||||
|
const { items, count } = path;
|
||||||
|
const name = items[index + 1];
|
||||||
|
if (name) {
|
||||||
|
if (!obj[name]) {
|
||||||
|
obj[name] = { name, count: +count };
|
||||||
|
} else {
|
||||||
|
obj[name].count += +count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
column[name] = {
|
||||||
|
name,
|
||||||
total: +count,
|
total: +count,
|
||||||
index,
|
columnIndex: index,
|
||||||
selected: !!selectedNode?.paths.find(arr => {
|
selected,
|
||||||
return arr.find(a => a.items[index] === item);
|
paths,
|
||||||
}),
|
from: objectToArray(from),
|
||||||
paths: [
|
to: objectToArray(to),
|
||||||
data.filter((d, i) => {
|
|
||||||
return d.items[index] === item && i !== index;
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
col[item].total += +count;
|
column[name].total += +count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Object.keys(col)
|
return {
|
||||||
.map(key => col[key])
|
nodes: objectToArray(column).sort(firstBy('total', -1)),
|
||||||
.sort(firstBy('total', -1));
|
};
|
||||||
});
|
});
|
||||||
}, [data, selectedNode]);
|
}, [data, selectedNode]);
|
||||||
|
|
||||||
@ -61,17 +97,19 @@ export default function JourneyView() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//console.log({ columns, selectedNode, activeNode });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.view}>
|
<div className={styles.view}>
|
||||||
{columns.map((nodes, index) => {
|
{columns.map((column, columnIndex) => {
|
||||||
const current = index === selectedNode?.index;
|
const current = columnIndex === selectedNode?.index;
|
||||||
const behind = index <= selectedNode?.index - 1;
|
const behind = columnIndex <= selectedNode?.index - 1;
|
||||||
const ahead = index > selectedNode?.index;
|
const ahead = columnIndex > selectedNode?.index;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={columnIndex}
|
||||||
className={classNames(styles.column, {
|
className={classNames(styles.column, {
|
||||||
[styles.current]: current,
|
[styles.current]: current,
|
||||||
[styles.behind]: behind,
|
[styles.behind]: behind,
|
||||||
@ -79,20 +117,82 @@ export default function JourneyView() {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div className={styles.num}>{index + 1}</div>
|
<div className={styles.num}>{columnIndex + 1}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.nodes}>
|
<div className={styles.nodes}>
|
||||||
{nodes.map(({ item, total, selected, paths }, i) => {
|
{column.nodes.map(({ name, total, selected, paths, from, to }, nodeIndex) => {
|
||||||
|
const active =
|
||||||
|
selected && activeNode?.paths.find(path => path.items[columnIndex] === name);
|
||||||
|
const bars = [];
|
||||||
|
const lines = from?.reduce(
|
||||||
|
(obj: { flat: boolean; fromUp: boolean; fromDown: boolean }, { name }: any) => {
|
||||||
|
const fromIndex = columns[columnIndex - 1]?.nodes.findIndex(node => {
|
||||||
|
return node.name === name && node.selected;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fromIndex > -1) {
|
||||||
|
if (nodeIndex === fromIndex) {
|
||||||
|
obj.flat = true;
|
||||||
|
} else if (nodeIndex > fromIndex) {
|
||||||
|
obj.fromUp = true;
|
||||||
|
bars.push([fromIndex, nodeIndex, 1]);
|
||||||
|
} else if (nodeIndex < fromIndex) {
|
||||||
|
obj.fromDown = true;
|
||||||
|
bars.push([nodeIndex, fromIndex, 0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
to?.reduce((obj: { toUp: boolean; toDown: boolean }, { name }: any) => {
|
||||||
|
const toIndex = columns[columnIndex + 1]?.nodes.findIndex(node => {
|
||||||
|
return node.name === name && node.selected;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (toIndex > -1) {
|
||||||
|
if (nodeIndex > toIndex) {
|
||||||
|
obj.toUp = true;
|
||||||
|
} else if (nodeIndex < toIndex) {
|
||||||
|
obj.toDown = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}, lines);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={`node_${index}_${i}`}
|
key={name}
|
||||||
key={item}
|
|
||||||
className={classNames(styles.item, {
|
className={classNames(styles.item, {
|
||||||
[styles.selected]: selected,
|
[styles.selected]: selected,
|
||||||
|
[styles.active]: active,
|
||||||
})}
|
})}
|
||||||
onClick={() => handleClick(item, index, paths)}
|
onClick={() => handleClick(name, columnIndex, paths)}
|
||||||
|
onMouseEnter={() => selected && setActiveNode({ name, columnIndex, paths })}
|
||||||
|
onMouseLeave={() => selected && setActiveNode(null)}
|
||||||
>
|
>
|
||||||
{item} ({total})
|
<div className={styles.name}>{name}</div>
|
||||||
|
<div className={styles.count}>{total}</div>
|
||||||
|
{Object.keys(lines).map(key => {
|
||||||
|
return <div key={key} className={classNames(styles.line, styles[key])} />;
|
||||||
|
})}
|
||||||
|
{columnIndex < columns.length &&
|
||||||
|
bars.map(([a, b, d], i) => {
|
||||||
|
const height = (b - a - 1) * (NODE_HEIGHT + NODE_GAP) + NODE_GAP;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={styles.bar}
|
||||||
|
style={{
|
||||||
|
height: height + BAR_OFFSET,
|
||||||
|
top: d ? -height : NODE_HEIGHT,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
@ -88,3 +88,7 @@ function getKeyName(key: string, parentKey: string) {
|
|||||||
|
|
||||||
return `${parentKey}.${key}`;
|
return `${parentKey}.${key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function objectToArray(obj: object) {
|
||||||
|
return Object.keys(obj).map(key => obj[key]);
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user