feat: add micro-animations to journey chart
- Connection lines draw-in with staggered stroke-dashoffset - Bar widths grow from zero on mount with row stagger - Columns fade + slide in from left with 50ms delay each - Hover lift (-1px translate + shadow) on page rows - Exit card fades in from top - Drop-off percentages count up with eased animation
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'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 { TreeStructure } from '@phosphor-icons/react'
|
||||||
import type { PathTransition } from '@/lib/api/journeys'
|
import type { PathTransition } from '@/lib/api/journeys'
|
||||||
|
|
||||||
@@ -53,6 +53,34 @@ function smartLabel(path: string): string {
|
|||||||
return `…/${segments[segments.length - 1]}`
|
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 ────────────────────────────────────────────
|
// ─── Data transformation ────────────────────────────────────────────
|
||||||
|
|
||||||
function buildColumns(
|
function buildColumns(
|
||||||
@@ -112,6 +140,21 @@ function buildColumns(
|
|||||||
|
|
||||||
// ─── Sub-components ─────────────────────────────────────────────────
|
// ─── Sub-components ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AnimatedDropOff({ percent }: { percent: number }) {
|
||||||
|
const displayed = useAnimatedCount(percent)
|
||||||
|
if (displayed === 0 && percent === 0) return null
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium ${
|
||||||
|
percent < 0 ? 'text-red-500' : 'text-emerald-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{percent > 0 ? '+' : displayed < 0 ? '' : ''}
|
||||||
|
{displayed}%
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ColumnHeader({
|
function ColumnHeader({
|
||||||
column,
|
column,
|
||||||
}: {
|
}: {
|
||||||
@@ -127,14 +170,7 @@ function ColumnHeader({
|
|||||||
{column.totalSessions.toLocaleString()} visitors
|
{column.totalSessions.toLocaleString()} visitors
|
||||||
</span>
|
</span>
|
||||||
{column.dropOffPercent !== 0 && (
|
{column.dropOffPercent !== 0 && (
|
||||||
<span
|
<AnimatedDropOff percent={column.dropOffPercent} />
|
||||||
className={`text-xs font-medium ${
|
|
||||||
column.dropOffPercent < 0 ? 'text-red-500' : 'text-emerald-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{column.dropOffPercent > 0 ? '+' : ''}
|
|
||||||
{column.dropOffPercent}%
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,18 +180,22 @@ function ColumnHeader({
|
|||||||
function PageRow({
|
function PageRow({
|
||||||
page,
|
page,
|
||||||
colIndex,
|
colIndex,
|
||||||
|
rowIndex,
|
||||||
columnTotal,
|
columnTotal,
|
||||||
maxCount,
|
maxCount,
|
||||||
isSelected,
|
isSelected,
|
||||||
isOther,
|
isOther,
|
||||||
|
isMounted,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
page: ColumnPage
|
page: ColumnPage
|
||||||
colIndex: number
|
colIndex: number
|
||||||
|
rowIndex: number
|
||||||
columnTotal: number
|
columnTotal: number
|
||||||
maxCount: number
|
maxCount: number
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
isOther: boolean
|
isOther: boolean
|
||||||
|
isMounted: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
}) {
|
}) {
|
||||||
const pct = columnTotal > 0 ? Math.round((page.sessionCount / columnTotal) * 100) : 0
|
const pct = columnTotal > 0 ? Math.round((page.sessionCount / columnTotal) * 100) : 0
|
||||||
@@ -171,22 +211,23 @@ function PageRow({
|
|||||||
data-path={page.path}
|
data-path={page.path}
|
||||||
className={`
|
className={`
|
||||||
group flex items-center justify-between w-full relative
|
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'}
|
${isOther ? 'cursor-default' : 'cursor-pointer'}
|
||||||
${isSelected
|
${isSelected
|
||||||
? 'bg-brand-orange/10 dark:bg-brand-orange/10'
|
? 'bg-brand-orange/10 dark:bg-brand-orange/10'
|
||||||
: isOther
|
: 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 && (
|
{!isOther && barWidth > 0 && (
|
||||||
<div
|
<div
|
||||||
className="absolute top-0.5 bottom-0.5 left-0.5 rounded-md transition-all"
|
className="absolute top-0.5 bottom-0.5 left-0.5 rounded-md transition-all duration-500 ease-out"
|
||||||
style={{
|
style={{
|
||||||
width: `calc(${barWidth}% - 4px)`,
|
width: isMounted ? `calc(${barWidth}% - 4px)` : '0%',
|
||||||
|
transitionDelay: `${rowIndex * 30}ms`,
|
||||||
backgroundColor: isSelected ? 'rgba(253, 94, 15, 0.15)' : 'rgba(253, 94, 15, 0.08)',
|
backgroundColor: isSelected ? 'rgba(253, 94, 15, 0.15)' : 'rgba(253, 94, 15, 0.08)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -233,9 +274,24 @@ function JourneyColumn({
|
|||||||
exitCount: number
|
exitCount: number
|
||||||
onSelect: (path: string) => void
|
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) {
|
if (column.pages.length === 0 && exitCount === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="w-56 shrink-0">
|
<div
|
||||||
|
className="w-56 shrink-0"
|
||||||
|
style={{
|
||||||
|
animation: `col-enter 300ms ease-out ${column.index * 50}ms backwards`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ColumnHeader column={column} />
|
<ColumnHeader column={column} />
|
||||||
<div className="flex items-center justify-center h-16 px-2">
|
<div className="flex items-center justify-center h-16 px-2">
|
||||||
<span className="text-xs text-neutral-400 dark:text-neutral-500">
|
<span className="text-xs text-neutral-400 dark:text-neutral-500">
|
||||||
@@ -249,31 +305,40 @@ function JourneyColumn({
|
|||||||
const maxCount = Math.max(...column.pages.map((p) => p.sessionCount), 0)
|
const maxCount = Math.max(...column.pages.map((p) => p.sessionCount), 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-56 shrink-0 px-3">
|
<div
|
||||||
|
className="w-56 shrink-0 px-3"
|
||||||
|
style={{
|
||||||
|
animation: `col-enter 300ms ease-out ${column.index * 50}ms backwards`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ColumnHeader column={column} />
|
<ColumnHeader column={column} />
|
||||||
<div className="space-y-0.5 max-h-[500px] overflow-y-auto">
|
<div className="space-y-0.5 max-h-[500px] overflow-y-auto">
|
||||||
{column.pages.map((page) => {
|
{column.pages.map((page, rowIndex) => {
|
||||||
const isOther = page.path === '(other)'
|
const isOther = page.path === '(other)'
|
||||||
return (
|
return (
|
||||||
<PageRow
|
<PageRow
|
||||||
key={page.path}
|
key={page.path}
|
||||||
page={page}
|
page={page}
|
||||||
colIndex={column.index}
|
colIndex={column.index}
|
||||||
|
rowIndex={rowIndex}
|
||||||
columnTotal={column.totalSessions}
|
columnTotal={column.totalSessions}
|
||||||
maxCount={maxCount}
|
maxCount={maxCount}
|
||||||
isSelected={selectedPath === page.path}
|
isSelected={selectedPath === page.path}
|
||||||
isOther={isOther}
|
isOther={isOther}
|
||||||
|
isMounted={isMounted}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isOther) onSelect(page.path)
|
if (!isOther) onSelect(page.path)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{/* Animation #5: exit card slides in */}
|
||||||
{exitCount > 0 && (
|
{exitCount > 0 && (
|
||||||
<div
|
<div
|
||||||
data-col={column.index}
|
data-col={column.index}
|
||||||
data-path="(exit)"
|
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"
|
className="flex items-center justify-between w-full relative h-9 px-3 rounded-lg bg-red-500/15 dark:bg-red-500/15"
|
||||||
|
style={{ animation: 'exit-reveal 300ms ease-out backwards' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute top-0.5 bottom-0.5 left-0.5 rounded-md"
|
className="absolute top-0.5 bottom-0.5 left-0.5 rounded-md"
|
||||||
@@ -308,7 +373,7 @@ function ConnectionLines({
|
|||||||
columns: Column[]
|
columns: Column[]
|
||||||
transitions: PathTransition[]
|
transitions: PathTransition[]
|
||||||
}) {
|
}) {
|
||||||
const [lines, setLines] = useState<(LineDef & { color: string })[]>([])
|
const [lines, setLines] = useState<(LineDef & { color: string; length: number })[]>([])
|
||||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
|
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -324,7 +389,7 @@ function ConnectionLines({
|
|||||||
height: container.scrollHeight,
|
height: container.scrollHeight,
|
||||||
})
|
})
|
||||||
|
|
||||||
const newLines: (LineDef & { color: string })[] = []
|
const newLines: (LineDef & { color: string; length: number })[] = []
|
||||||
|
|
||||||
for (const [colIdx, selectedPath] of selections) {
|
for (const [colIdx, selectedPath] of selections) {
|
||||||
const nextCol = columns[colIdx + 1]
|
const nextCol = columns[colIdx + 1]
|
||||||
@@ -362,7 +427,12 @@ function ConnectionLines({
|
|||||||
|
|
||||||
const weight = Math.max(1, Math.min(4, (t.session_count / maxCount) * 4))
|
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
|
// Draw line to exit card if it exists
|
||||||
@@ -374,7 +444,10 @@ function ConnectionLines({
|
|||||||
const exitY =
|
const exitY =
|
||||||
exitRect.top + exitRect.height / 2 - containerRect.top + container.scrollTop
|
exitRect.top + exitRect.height / 2 - containerRect.top + container.scrollTop
|
||||||
const exitX = exitRect.left - containerRect.left + container.scrollLeft
|
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}
|
strokeWidth={line.weight}
|
||||||
strokeOpacity={0.35}
|
strokeOpacity={0.35}
|
||||||
fill="none"
|
fill="none"
|
||||||
|
strokeDasharray={line.length}
|
||||||
|
strokeDashoffset={line.length}
|
||||||
|
style={{
|
||||||
|
animation: `draw-line 400ms ease-out ${i * 50}ms forwards`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
<style>
|
||||||
|
{`@keyframes draw-line {
|
||||||
|
to { stroke-dashoffset: 0; }
|
||||||
|
}`}
|
||||||
|
</style>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -488,6 +571,16 @@ export default function ColumnJourney({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
<style>
|
||||||
|
{`@keyframes col-enter {
|
||||||
|
from { opacity: 0; transform: translateX(-8px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
@keyframes exit-reveal {
|
||||||
|
from { opacity: 0; transform: translateY(-4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}`}
|
||||||
|
</style>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="overflow-x-auto -mx-6 px-6 pb-2 relative"
|
className="overflow-x-auto -mx-6 px-6 pb-2 relative"
|
||||||
@@ -502,7 +595,7 @@ export default function ColumnJourney({
|
|||||||
return (
|
return (
|
||||||
<Fragment key={col.index}>
|
<Fragment key={col.index}>
|
||||||
{i > 0 && (
|
{i > 0 && (
|
||||||
<div className="w-px shrink-0 bg-neutral-100 dark:bg-neutral-800" />
|
<div className="w-px shrink-0 mx-3 bg-neutral-100 dark:bg-neutral-800" />
|
||||||
)}
|
)}
|
||||||
<JourneyColumn
|
<JourneyColumn
|
||||||
column={col}
|
column={col}
|
||||||
|
|||||||
Reference in New Issue
Block a user