diff --git a/components/journeys/ColumnJourney.tsx b/components/journeys/ColumnJourney.tsx index bae0d9c..1646450 100644 --- a/components/journeys/ColumnJourney.tsx +++ b/components/journeys/ColumnJourney.tsx @@ -1,6 +1,6 @@ 'use client' -import { Fragment, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { TreeStructure } from '@phosphor-icons/react' import type { PathTransition } from '@/lib/api/journeys' @@ -53,6 +53,34 @@ function smartLabel(path: string): string { return `…/${segments[segments.length - 1]}` } +// ─── Animated count hook ──────────────────────────────────────────── + +function useAnimatedCount(target: number, duration = 400): number { + const [display, setDisplay] = useState(0) + const prevTarget = useRef(target) + + useEffect(() => { + const from = prevTarget.current + prevTarget.current = target + if (from === target) { + setDisplay(target) + return + } + const start = performance.now() + let raf: number + const tick = (now: number) => { + const t = Math.min((now - start) / duration, 1) + const eased = 1 - Math.pow(1 - t, 3) // ease-out cubic + setDisplay(Math.round(from + (target - from) * eased)) + if (t < 1) raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, [target, duration]) + + return display +} + // ─── Data transformation ──────────────────────────────────────────── function buildColumns( @@ -112,6 +140,21 @@ function buildColumns( // ─── Sub-components ───────────────────────────────────────────────── +function AnimatedDropOff({ percent }: { percent: number }) { + const displayed = useAnimatedCount(percent) + if (displayed === 0 && percent === 0) return null + return ( + + {percent > 0 ? '+' : displayed < 0 ? '' : ''} + {displayed}% + + ) +} + function ColumnHeader({ column, }: { @@ -127,14 +170,7 @@ function ColumnHeader({ {column.totalSessions.toLocaleString()} visitors {column.dropOffPercent !== 0 && ( - - {column.dropOffPercent > 0 ? '+' : ''} - {column.dropOffPercent}% - + )} @@ -144,18 +180,22 @@ function ColumnHeader({ function PageRow({ page, colIndex, + rowIndex, columnTotal, maxCount, isSelected, isOther, + isMounted, onClick, }: { page: ColumnPage colIndex: number + rowIndex: number columnTotal: number maxCount: number isSelected: boolean isOther: boolean + isMounted: boolean onClick: () => void }) { const pct = columnTotal > 0 ? Math.round((page.sessionCount / columnTotal) * 100) : 0 @@ -171,22 +211,23 @@ function PageRow({ data-path={page.path} className={` group flex items-center justify-between w-full relative - h-9 px-3 rounded-lg text-left transition-colors + h-9 px-3 rounded-lg text-left transition-all duration-200 ${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' + : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50 hover:-translate-y-px hover:shadow-sm' } `} > - {/* Background bar */} + {/* Background bar — animates width on mount */} {!isOther && barWidth > 0 && (
@@ -233,9 +274,24 @@ function JourneyColumn({ exitCount: number onSelect: (path: string) => void }) { + // Animation #2 & #3: trigger bar grow after mount + const [isMounted, setIsMounted] = useState(false) + useEffect(() => { + const raf = requestAnimationFrame(() => setIsMounted(true)) + return () => { + cancelAnimationFrame(raf) + setIsMounted(false) + } + }, [column.pages]) + if (column.pages.length === 0 && exitCount === 0) { return ( -
+
@@ -249,31 +305,40 @@ function JourneyColumn({ const maxCount = Math.max(...column.pages.map((p) => p.sessionCount), 0) return ( -
+
- {column.pages.map((page) => { + {column.pages.map((page, rowIndex) => { const isOther = page.path === '(other)' return ( { if (!isOther) onSelect(page.path) }} /> ) })} + {/* Animation #5: exit card slides in */} {exitCount > 0 && (
([]) + const [lines, setLines] = useState<(LineDef & { color: string; length: number })[]>([]) const [dimensions, setDimensions] = useState({ width: 0, height: 0 }) useLayoutEffect(() => { @@ -324,7 +389,7 @@ function ConnectionLines({ height: container.scrollHeight, }) - const newLines: (LineDef & { color: string })[] = [] + const newLines: (LineDef & { color: string; length: number })[] = [] for (const [colIdx, selectedPath] of selections) { const nextCol = columns[colIdx + 1] @@ -362,7 +427,12 @@ function ConnectionLines({ const weight = Math.max(1, Math.min(4, (t.session_count / maxCount) * 4)) - newLines.push({ sourceY, destY, sourceX, destX, weight, color }) + // Approximate bezier curve length for animation + const dx = destX - sourceX + const dy = destY - sourceY + const length = Math.sqrt(dx * dx + dy * dy) * 1.2 + + newLines.push({ sourceY, destY, sourceX, destX, weight, color, length }) } // Draw line to exit card if it exists @@ -374,7 +444,10 @@ function ConnectionLines({ 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' }) + const dx = exitX - sourceX + const dy = exitY - sourceY + const length = Math.sqrt(dx * dx + dy * dy) * 1.2 + newLines.push({ sourceY, destY: exitY, sourceX, destX: exitX, weight: 1, color: '#ef4444', length }) } } @@ -400,9 +473,19 @@ function ConnectionLines({ strokeWidth={line.weight} strokeOpacity={0.35} fill="none" + strokeDasharray={line.length} + strokeDashoffset={line.length} + style={{ + animation: `draw-line 400ms ease-out ${i * 50}ms forwards`, + }} /> ) })} + ) } @@ -488,6 +571,16 @@ export default function ColumnJourney({ return (
+
{i > 0 && ( -
+
)}