From d5e14fb5a86de6f5c6985793adc4efc207ce2855 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Thu, 6 Jun 2024 22:30:58 -0700 Subject: [PATCH] Journey report view with nodes and lines. --- .../reports/journey/JourneyView.module.css | 145 ++++++++++++++++ .../(main)/reports/journey/JourneyView.tsx | 162 ++++++++++++++---- src/lib/data.ts | 4 + 3 files changed, 280 insertions(+), 31 deletions(-) diff --git a/src/app/(main)/reports/journey/JourneyView.module.css b/src/app/(main)/reports/journey/JourneyView.module.css index 29ebb800..a9d94251 100644 --- a/src/app/(main)/reports/journey/JourneyView.module.css +++ b/src/app/(main)/reports/journey/JourneyView.module.css @@ -2,6 +2,9 @@ width: 100%; height: 100%; position: relative; + + --journey-line-color: var(--base600); + --journey-active-color: var(--primary400); } .view { @@ -43,18 +46,22 @@ } .nodes { + position: relative; display: flex; flex-direction: column; gap: 10px; + height: 100%; } .item { + position: relative; cursor: pointer; padding: 10px 20px; background: var(--base75); border-radius: 5px; display: flex; align-items: center; + justify-content: space-between; min-width: 300px; min-height: 60px; } @@ -70,6 +77,10 @@ font-weight: 400; } +.item.active { + background: var(--primary400); +} + .behind { color: var(--base400); } @@ -81,3 +92,137 @@ .current { 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); +} diff --git a/src/app/(main)/reports/journey/JourneyView.tsx b/src/app/(main)/reports/journey/JourneyView.tsx index 00172f37..1b9333c3 100644 --- a/src/app/(main)/reports/journey/JourneyView.tsx +++ b/src/app/(main)/reports/journey/JourneyView.tsx @@ -1,12 +1,19 @@ import { useContext, useMemo, useState } from 'react'; -import { ReportContext } from '../[reportId]/Report'; import { firstBy } from 'thenby'; import classNames from 'classnames'; 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'; +const NODE_HEIGHT = 60; +const NODE_GAP = 10; +const BAR_OFFSET = 3; + export default function JourneyView() { const [selectedNode, setSelectedNode] = useState(null); + const [activeNode, setActiveNode] = useState(null); const { report } = useContext(ReportContext); const { data, parameters } = report || {}; @@ -18,34 +25,63 @@ export default function JourneyView() { } return Array(Number(parameters.steps)) .fill(undefined) - .map((col = {}, index) => { + .map((column = {}, index) => { data.forEach(({ items, count }) => { - const item = items[index]; + const name = items[index]; + const selectedNodes = selectedNode?.paths ?? []; - if (item) { - if (!col[item]) { - col[item] = { - item, + if (name) { + if (!column[name]) { + const selected = !!selectedNodes.find(a => a.items[index] === name); + 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, - index, - selected: !!selectedNode?.paths.find(arr => { - return arr.find(a => a.items[index] === item); - }), - paths: [ - data.filter((d, i) => { - return d.items[index] === item && i !== index; - }), - ], + columnIndex: index, + selected, + paths, + from: objectToArray(from), + to: objectToArray(to), }; } else { - col[item].total += +count; + column[name].total += +count; } } }); - return Object.keys(col) - .map(key => col[key]) - .sort(firstBy('total', -1)); + return { + nodes: objectToArray(column).sort(firstBy('total', -1)), + }; }); }, [data, selectedNode]); @@ -61,17 +97,19 @@ export default function JourneyView() { return null; } + //console.log({ columns, selectedNode, activeNode }); + return (
- {columns.map((nodes, index) => { - const current = index === selectedNode?.index; - const behind = index <= selectedNode?.index - 1; - const ahead = index > selectedNode?.index; + {columns.map((column, columnIndex) => { + const current = columnIndex === selectedNode?.index; + const behind = columnIndex <= selectedNode?.index - 1; + const ahead = columnIndex > selectedNode?.index; return (
-
{index + 1}
+
{columnIndex + 1}
- {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 (
handleClick(item, index, paths)} + onClick={() => handleClick(name, columnIndex, paths)} + onMouseEnter={() => selected && setActiveNode({ name, columnIndex, paths })} + onMouseLeave={() => selected && setActiveNode(null)} > - {item} ({total}) +
{name}
+
{total}
+ {Object.keys(lines).map(key => { + return
; + })} + {columnIndex < columns.length && + bars.map(([a, b, d], i) => { + const height = (b - a - 1) * (NODE_HEIGHT + NODE_GAP) + NODE_GAP; + return ( +
+ ); + })}
); })} diff --git a/src/lib/data.ts b/src/lib/data.ts index 24c76dd0..cf2722b5 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -88,3 +88,7 @@ function getKeyName(key: string, parentKey: string) { return `${parentKey}.${key}`; } + +export function objectToArray(obj: object) { + return Object.keys(obj).map(key => obj[key]); +}