Previously clicking a step 1 block would set it as an entry point filter instead of showing connection lines. Now all steps behave consistently — clicking any step toggles selection and draws connector lines to the next column. Entry point filtering remains available via the dropdown.
527 lines
16 KiB
TypeScript
527 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { Fragment, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
import { TreeStructure } from '@phosphor-icons/react'
|
|
import type { PathTransition } from '@/lib/api/journeys'
|
|
|
|
// ─── Types ──────────────────────────────────────────────────────────
|
|
|
|
interface ColumnJourneyProps {
|
|
transitions: PathTransition[]
|
|
totalSessions: number
|
|
depth: number
|
|
}
|
|
|
|
interface ColumnPage {
|
|
path: string
|
|
sessionCount: number
|
|
}
|
|
|
|
interface Column {
|
|
index: number
|
|
totalSessions: number
|
|
dropOffPercent: number
|
|
pages: ColumnPage[]
|
|
}
|
|
|
|
interface LineDef {
|
|
sourceY: number
|
|
destY: number
|
|
sourceX: number
|
|
destX: number
|
|
weight: number
|
|
}
|
|
|
|
// ─── Constants ──────────────────────────────────────────────────────
|
|
|
|
const COLUMN_COLORS = [
|
|
'#FD5E0F', '#3B82F6', '#10B981', '#F59E0B', '#8B5CF6',
|
|
'#EC4899', '#06B6D4', '#EF4444', '#84CC16', '#F97316', '#6366F1',
|
|
]
|
|
const MAX_NODES_PER_COLUMN = 10
|
|
|
|
function colorForColumn(col: number): string {
|
|
return COLUMN_COLORS[col % COLUMN_COLORS.length]
|
|
}
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────
|
|
|
|
function smartLabel(path: string): string {
|
|
if (path === '/' || path === '(other)') return path
|
|
const segments = path.replace(/\/$/, '').split('/')
|
|
if (segments.length <= 2) return path
|
|
return `…/${segments[segments.length - 1]}`
|
|
}
|
|
|
|
// ─── Data transformation ────────────────────────────────────────────
|
|
|
|
function buildColumns(
|
|
transitions: PathTransition[],
|
|
depth: number,
|
|
): Column[] {
|
|
const numCols = depth + 1
|
|
const columns: Column[] = []
|
|
|
|
for (let col = 0; col < numCols; col++) {
|
|
const pageMap = new Map<string, number>()
|
|
|
|
if (col === 0) {
|
|
for (const t of transitions) {
|
|
if (t.step_index === 0) {
|
|
pageMap.set(t.from_path, (pageMap.get(t.from_path) ?? 0) + t.session_count)
|
|
}
|
|
}
|
|
} else {
|
|
for (const t of transitions) {
|
|
if (t.step_index === col - 1) {
|
|
pageMap.set(t.to_path, (pageMap.get(t.to_path) ?? 0) + t.session_count)
|
|
}
|
|
}
|
|
}
|
|
|
|
let pages = Array.from(pageMap.entries())
|
|
.map(([path, sessionCount]) => ({ path, sessionCount }))
|
|
.sort((a, b) => b.sessionCount - a.sessionCount)
|
|
|
|
if (pages.length > MAX_NODES_PER_COLUMN) {
|
|
const kept = pages.slice(0, MAX_NODES_PER_COLUMN)
|
|
const otherCount = pages
|
|
.slice(MAX_NODES_PER_COLUMN)
|
|
.reduce((sum, p) => sum + p.sessionCount, 0)
|
|
kept.push({ path: '(other)', sessionCount: otherCount })
|
|
pages = kept
|
|
}
|
|
|
|
const totalSessions = pages.reduce((sum, p) => sum + p.sessionCount, 0)
|
|
const prevTotal = col > 0 ? columns[col - 1].totalSessions : totalSessions
|
|
const dropOffPercent =
|
|
col === 0 || prevTotal === 0
|
|
? 0
|
|
: Math.round(((totalSessions - prevTotal) / prevTotal) * 100)
|
|
|
|
columns.push({ index: col, totalSessions, dropOffPercent, pages })
|
|
}
|
|
|
|
// Trim empty trailing columns
|
|
while (columns.length > 1 && columns[columns.length - 1].pages.length === 0) {
|
|
columns.pop()
|
|
}
|
|
|
|
return columns
|
|
}
|
|
|
|
// ─── Sub-components ─────────────────────────────────────────────────
|
|
|
|
function ColumnHeader({
|
|
column,
|
|
}: {
|
|
column: Column
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col items-center gap-0.5 mb-4">
|
|
<span className="text-xs font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider">
|
|
Step {column.index + 1}
|
|
</span>
|
|
<div className="flex items-baseline gap-1.5">
|
|
<span className="text-sm font-semibold text-neutral-900 dark:text-white tabular-nums">
|
|
{column.totalSessions.toLocaleString()} visitors
|
|
</span>
|
|
{column.dropOffPercent !== 0 && (
|
|
<span
|
|
className={`text-xs font-medium ${
|
|
column.dropOffPercent < 0 ? 'text-red-500' : 'text-emerald-500'
|
|
}`}
|
|
>
|
|
{column.dropOffPercent > 0 ? '+' : ''}
|
|
{column.dropOffPercent}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PageRow({
|
|
page,
|
|
colIndex,
|
|
columnTotal,
|
|
maxCount,
|
|
isSelected,
|
|
isOther,
|
|
onClick,
|
|
}: {
|
|
page: ColumnPage
|
|
colIndex: number
|
|
columnTotal: number
|
|
maxCount: number
|
|
isSelected: boolean
|
|
isOther: boolean
|
|
onClick: () => void
|
|
}) {
|
|
const pct = columnTotal > 0 ? Math.round((page.sessionCount / columnTotal) * 100) : 0
|
|
const barWidth = maxCount > 0 ? (page.sessionCount / maxCount) * 100 : 0
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
disabled={isOther}
|
|
onClick={onClick}
|
|
title={page.path}
|
|
data-col={colIndex}
|
|
data-path={page.path}
|
|
className={`
|
|
group flex items-center justify-between w-full relative
|
|
h-9 px-3 rounded-lg text-left transition-colors
|
|
${isOther ? 'cursor-default' : 'cursor-pointer'}
|
|
${isSelected
|
|
? 'bg-brand-orange/10 dark:bg-brand-orange/10'
|
|
: isOther
|
|
? ''
|
|
: 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
|
|
}
|
|
`}
|
|
>
|
|
{/* Background bar */}
|
|
{!isOther && barWidth > 0 && (
|
|
<div
|
|
className="absolute top-0.5 bottom-0.5 left-0.5 rounded-md transition-all"
|
|
style={{
|
|
width: `calc(${barWidth}% - 4px)`,
|
|
backgroundColor: isSelected ? 'rgba(253, 94, 15, 0.15)' : 'rgba(253, 94, 15, 0.08)',
|
|
}}
|
|
/>
|
|
)}
|
|
<span
|
|
className={`relative flex-1 truncate text-sm ${
|
|
isSelected
|
|
? 'text-neutral-900 dark:text-white font-medium'
|
|
: isOther
|
|
? 'italic text-neutral-400 dark:text-neutral-500'
|
|
: 'text-neutral-900 dark:text-white'
|
|
}`}
|
|
>
|
|
{isOther ? page.path : smartLabel(page.path)}
|
|
</span>
|
|
<div className="relative flex items-center gap-2 ml-2 shrink-0">
|
|
{!isOther && (
|
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
|
{pct}%
|
|
</span>
|
|
)}
|
|
<span
|
|
className={`text-sm tabular-nums font-semibold ${
|
|
isOther
|
|
? 'text-neutral-400 dark:text-neutral-500'
|
|
: 'text-neutral-600 dark:text-neutral-400'
|
|
}`}
|
|
>
|
|
{page.sessionCount.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function JourneyColumn({
|
|
column,
|
|
selectedPath,
|
|
exitCount,
|
|
onSelect,
|
|
}: {
|
|
column: Column
|
|
selectedPath: string | undefined
|
|
exitCount: number
|
|
onSelect: (path: string) => void
|
|
}) {
|
|
if (column.pages.length === 0 && exitCount === 0) {
|
|
return (
|
|
<div className="w-56 shrink-0">
|
|
<ColumnHeader column={column} />
|
|
<div className="flex items-center justify-center h-16 px-2">
|
|
<span className="text-xs text-neutral-400 dark:text-neutral-500">
|
|
No onward traffic
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const maxCount = Math.max(...column.pages.map((p) => p.sessionCount), 0)
|
|
|
|
return (
|
|
<div className="w-56 shrink-0 px-3">
|
|
<ColumnHeader column={column} />
|
|
<div className="space-y-0.5 max-h-[500px] overflow-y-auto">
|
|
{column.pages.map((page) => {
|
|
const isOther = page.path === '(other)'
|
|
return (
|
|
<PageRow
|
|
key={page.path}
|
|
page={page}
|
|
colIndex={column.index}
|
|
columnTotal={column.totalSessions}
|
|
maxCount={maxCount}
|
|
isSelected={selectedPath === page.path}
|
|
isOther={isOther}
|
|
onClick={() => {
|
|
if (!isOther) onSelect(page.path)
|
|
}}
|
|
/>
|
|
)
|
|
})}
|
|
{exitCount > 0 && (
|
|
<div
|
|
data-col={column.index}
|
|
data-path="(exit)"
|
|
className="flex items-center justify-between w-full relative h-9 px-3 rounded-lg bg-red-500/15 dark:bg-red-500/15"
|
|
>
|
|
<div
|
|
className="absolute top-0.5 bottom-0.5 left-0.5 rounded-md"
|
|
style={{
|
|
width: `calc(100% - 4px)`,
|
|
backgroundColor: 'rgba(239, 68, 68, 0.15)',
|
|
}}
|
|
/>
|
|
<span className="relative text-sm text-red-500 dark:text-red-400 font-medium">
|
|
(exit)
|
|
</span>
|
|
<span className="relative text-sm tabular-nums font-semibold text-red-500 dark:text-red-400">
|
|
{exitCount.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Connection Lines ───────────────────────────────────────────────
|
|
|
|
function ConnectionLines({
|
|
containerRef,
|
|
selections,
|
|
columns,
|
|
transitions,
|
|
}: {
|
|
containerRef: React.RefObject<HTMLDivElement | null>
|
|
selections: Map<number, string>
|
|
columns: Column[]
|
|
transitions: PathTransition[]
|
|
}) {
|
|
const [lines, setLines] = useState<(LineDef & { color: string })[]>([])
|
|
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
|
|
|
|
useLayoutEffect(() => {
|
|
const container = containerRef.current
|
|
if (!container || selections.size === 0) {
|
|
setLines([])
|
|
return
|
|
}
|
|
|
|
const containerRect = container.getBoundingClientRect()
|
|
setDimensions({
|
|
width: container.scrollWidth,
|
|
height: container.scrollHeight,
|
|
})
|
|
|
|
const newLines: (LineDef & { color: string })[] = []
|
|
|
|
for (const [colIdx, selectedPath] of selections) {
|
|
const nextCol = columns[colIdx + 1]
|
|
if (!nextCol) continue
|
|
|
|
const sourceEl = container.querySelector(
|
|
`[data-col="${colIdx}"][data-path="${CSS.escape(selectedPath)}"]`
|
|
) as HTMLElement | null
|
|
if (!sourceEl) continue
|
|
|
|
const sourceRect = sourceEl.getBoundingClientRect()
|
|
const sourceY =
|
|
sourceRect.top + sourceRect.height / 2 - containerRect.top + container.scrollTop
|
|
const sourceX = sourceRect.right - containerRect.left + container.scrollLeft + 4
|
|
|
|
const relevantTransitions = transitions.filter(
|
|
(t) => t.step_index === colIdx && t.from_path === selectedPath
|
|
)
|
|
|
|
const color = colorForColumn(colIdx)
|
|
const maxCount = relevantTransitions.length > 0
|
|
? Math.max(...relevantTransitions.map((rt) => rt.session_count))
|
|
: 1
|
|
|
|
for (const t of relevantTransitions) {
|
|
const destEl = container.querySelector(
|
|
`[data-col="${colIdx + 1}"][data-path="${CSS.escape(t.to_path)}"]`
|
|
) as HTMLElement | null
|
|
if (!destEl) continue
|
|
|
|
const destRect = destEl.getBoundingClientRect()
|
|
const destY =
|
|
destRect.top + destRect.height / 2 - containerRect.top + container.scrollTop
|
|
const destX = destRect.left - containerRect.left + container.scrollLeft - 4
|
|
|
|
const weight = Math.max(1, Math.min(4, (t.session_count / maxCount) * 4))
|
|
|
|
newLines.push({ sourceY, destY, sourceX, destX, weight, color })
|
|
}
|
|
|
|
// Draw line to exit card if it exists
|
|
const exitEl = container.querySelector(
|
|
`[data-col="${colIdx + 1}"][data-path="(exit)"]`
|
|
) as HTMLElement | null
|
|
if (exitEl) {
|
|
const exitRect = exitEl.getBoundingClientRect()
|
|
const exitY =
|
|
exitRect.top + exitRect.height / 2 - containerRect.top + container.scrollTop
|
|
const exitX = exitRect.left - containerRect.left + container.scrollLeft
|
|
newLines.push({ sourceY, destY: exitY, sourceX, destX: exitX, weight: 1, color: '#ef4444' })
|
|
}
|
|
}
|
|
|
|
setLines(newLines)
|
|
}, [selections, columns, transitions, containerRef])
|
|
|
|
if (lines.length === 0) return null
|
|
|
|
return (
|
|
<svg
|
|
className="absolute top-0 left-0 pointer-events-none"
|
|
width={dimensions.width}
|
|
height={dimensions.height}
|
|
style={{ overflow: 'visible' }}
|
|
>
|
|
{lines.map((line, i) => {
|
|
const midX = (line.sourceX + line.destX) / 2
|
|
return (
|
|
<path
|
|
key={i}
|
|
d={`M ${line.sourceX},${line.sourceY} C ${midX},${line.sourceY} ${midX},${line.destY} ${line.destX},${line.destY}`}
|
|
stroke={line.color}
|
|
strokeWidth={line.weight}
|
|
strokeOpacity={0.35}
|
|
fill="none"
|
|
/>
|
|
)
|
|
})}
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
// ─── Exit count helper ──────────────────────────────────────────────
|
|
|
|
function getExitCount(
|
|
colIdx: number,
|
|
selectedPath: string,
|
|
columns: Column[],
|
|
transitions: PathTransition[],
|
|
): number {
|
|
const col = columns[colIdx]
|
|
const page = col?.pages.find((p) => p.path === selectedPath)
|
|
if (!page) return 0
|
|
const outbound = transitions
|
|
.filter((t) => t.step_index === colIdx && t.from_path === selectedPath)
|
|
.reduce((sum, t) => sum + t.session_count, 0)
|
|
return Math.max(0, page.sessionCount - outbound)
|
|
}
|
|
|
|
// ─── Main Component ─────────────────────────────────────────────────
|
|
|
|
export default function ColumnJourney({
|
|
transitions,
|
|
totalSessions,
|
|
depth,
|
|
}: ColumnJourneyProps) {
|
|
const [selections, setSelections] = useState<Map<number, string>>(new Map())
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Clear selections when data changes
|
|
const transitionsKey = useMemo(
|
|
() => transitions.length + '-' + depth,
|
|
[transitions.length, depth]
|
|
)
|
|
const prevKeyRef = useRef(transitionsKey)
|
|
if (prevKeyRef.current !== transitionsKey) {
|
|
prevKeyRef.current = transitionsKey
|
|
if (selections.size > 0) setSelections(new Map())
|
|
}
|
|
|
|
const columns = useMemo(
|
|
() => buildColumns(transitions, depth),
|
|
[transitions, depth]
|
|
)
|
|
|
|
|
|
const handleSelect = useCallback(
|
|
(colIndex: number, path: string) => {
|
|
setSelections((prev) => {
|
|
const next = new Map(prev)
|
|
if (next.get(colIndex) === path) {
|
|
next.delete(colIndex)
|
|
} else {
|
|
next.set(colIndex, path)
|
|
}
|
|
for (const key of Array.from(next.keys())) {
|
|
if (key > colIndex) next.delete(key)
|
|
}
|
|
return next
|
|
})
|
|
},
|
|
[]
|
|
)
|
|
|
|
// ─── Empty state ────────────────────────────────────────────────
|
|
if (!transitions.length) {
|
|
return (
|
|
<div className="h-[400px] flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
|
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
|
<TreeStructure className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
|
</div>
|
|
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
|
No journey data yet
|
|
</h4>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
|
Navigation flows will appear here as visitors browse through your site.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="relative">
|
|
<div
|
|
ref={containerRef}
|
|
className="overflow-x-auto -mx-6 px-6 pb-2 relative"
|
|
>
|
|
<div className="flex min-w-fit py-2">
|
|
{columns.map((col, i) => {
|
|
const prevSelection = selections.get(col.index - 1)
|
|
const exitCount = prevSelection
|
|
? getExitCount(col.index - 1, prevSelection, columns, transitions)
|
|
: 0
|
|
|
|
return (
|
|
<Fragment key={col.index}>
|
|
{i > 0 && (
|
|
<div className="w-px shrink-0 bg-neutral-100 dark:bg-neutral-800" />
|
|
)}
|
|
<JourneyColumn
|
|
column={col}
|
|
selectedPath={selections.get(col.index)}
|
|
exitCount={exitCount}
|
|
onSelect={(path) => handleSelect(col.index, path)}
|
|
/>
|
|
</Fragment>
|
|
)
|
|
})}
|
|
</div>
|
|
<ConnectionLines
|
|
containerRef={containerRef}
|
|
selections={selections}
|
|
columns={columns}
|
|
transitions={transitions}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|