BunnyCDN, Search tab, journeys redesign, and dashboard polish #52
@@ -262,6 +262,13 @@ function JourneyColumn({
|
|||||||
|
|
||||||
// ─── Connection Lines ───────────────────────────────────────────────
|
// ─── Connection Lines ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ExitLabel {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
count: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
function ConnectionLines({
|
function ConnectionLines({
|
||||||
containerRef,
|
containerRef,
|
||||||
selections,
|
selections,
|
||||||
@@ -274,12 +281,14 @@ function ConnectionLines({
|
|||||||
transitions: PathTransition[]
|
transitions: PathTransition[]
|
||||||
}) {
|
}) {
|
||||||
const [lines, setLines] = useState<(LineDef & { color: string })[]>([])
|
const [lines, setLines] = useState<(LineDef & { color: string })[]>([])
|
||||||
|
const [exits, setExits] = useState<ExitLabel[]>([])
|
||||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
|
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const container = containerRef.current
|
const container = containerRef.current
|
||||||
if (!container || selections.size === 0) {
|
if (!container || selections.size === 0) {
|
||||||
setLines([])
|
setLines([])
|
||||||
|
setExits([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,10 +299,10 @@ function ConnectionLines({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const newLines: (LineDef & { color: string })[] = []
|
const newLines: (LineDef & { color: string })[] = []
|
||||||
|
const newExits: ExitLabel[] = []
|
||||||
|
|
||||||
for (const [colIdx, selectedPath] of selections) {
|
for (const [colIdx, selectedPath] of selections) {
|
||||||
const nextCol = columns[colIdx + 1]
|
const nextCol = columns[colIdx + 1]
|
||||||
if (!nextCol) continue
|
|
||||||
|
|
||||||
const sourceEl = container.querySelector(
|
const sourceEl = container.querySelector(
|
||||||
`[data-col="${colIdx}"][data-path="${CSS.escape(selectedPath)}"]`
|
`[data-col="${colIdx}"][data-path="${CSS.escape(selectedPath)}"]`
|
||||||
@@ -311,28 +320,76 @@ function ConnectionLines({
|
|||||||
|
|
||||||
const color = colorForColumn(colIdx)
|
const color = colorForColumn(colIdx)
|
||||||
|
|
||||||
for (const t of relevantTransitions) {
|
// Find total sessions for this page
|
||||||
const destEl = container.querySelector(
|
const col = columns[colIdx]
|
||||||
`[data-col="${colIdx + 1}"][data-path="${CSS.escape(t.to_path)}"]`
|
const page = col?.pages.find((p) => p.path === selectedPath)
|
||||||
) as HTMLElement | null
|
const pageCount = page?.sessionCount ?? 0
|
||||||
if (!destEl) continue
|
const outboundCount = relevantTransitions.reduce((sum, t) => sum + t.session_count, 0)
|
||||||
|
const exitCount = pageCount - outboundCount
|
||||||
|
|
||||||
const destRect = destEl.getBoundingClientRect()
|
if (nextCol) {
|
||||||
const destY =
|
const maxCount = Math.max(
|
||||||
destRect.top + destRect.height / 2 - containerRect.top + container.scrollTop
|
...relevantTransitions.map((rt) => rt.session_count),
|
||||||
const destX = destRect.left - containerRect.left + container.scrollLeft
|
exitCount > 0 ? exitCount : 0
|
||||||
|
)
|
||||||
|
|
||||||
const maxCount = Math.max(...relevantTransitions.map((rt) => rt.session_count))
|
for (const t of relevantTransitions) {
|
||||||
const weight = Math.max(1, Math.min(4, (t.session_count / maxCount) * 4))
|
const destEl = container.querySelector(
|
||||||
|
`[data-col="${colIdx + 1}"][data-path="${CSS.escape(t.to_path)}"]`
|
||||||
|
) as HTMLElement | null
|
||||||
|
if (!destEl) continue
|
||||||
|
|
||||||
newLines.push({ sourceY, destY, sourceX, destX, weight, color })
|
const destRect = destEl.getBoundingClientRect()
|
||||||
|
const destY =
|
||||||
|
destRect.top + destRect.height / 2 - containerRect.top + container.scrollTop
|
||||||
|
const destX = destRect.left - containerRect.left + container.scrollLeft
|
||||||
|
|
||||||
|
const weight = maxCount > 0
|
||||||
|
? Math.max(1, Math.min(4, (t.session_count / maxCount) * 4))
|
||||||
|
: 1
|
||||||
|
|
||||||
|
newLines.push({ sourceY, destY, sourceX, destX, weight, color })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show exit if any visitors dropped off
|
||||||
|
if (exitCount > 0) {
|
||||||
|
// Position the exit label below the last destination or below the source
|
||||||
|
const lastDestY = newLines.length > 0
|
||||||
|
? Math.max(...newLines.filter((l) => l.sourceX === sourceX).map((l) => l.destY))
|
||||||
|
: sourceY
|
||||||
|
|
||||||
|
const exitY = lastDestY + 30
|
||||||
|
const exitX = nextCol
|
||||||
|
? ((): number => {
|
||||||
|
// Find the left edge of the next column
|
||||||
|
const nextColEl = container.querySelector(`[data-col="${colIdx + 1}"]`) as HTMLElement | null
|
||||||
|
if (nextColEl) {
|
||||||
|
const nextRect = nextColEl.getBoundingClientRect()
|
||||||
|
return nextRect.left - containerRect.left + container.scrollLeft
|
||||||
|
}
|
||||||
|
return sourceX + 100
|
||||||
|
})()
|
||||||
|
: sourceX + 100
|
||||||
|
|
||||||
|
newLines.push({
|
||||||
|
sourceY,
|
||||||
|
destY: exitY,
|
||||||
|
sourceX,
|
||||||
|
destX: exitX,
|
||||||
|
weight: 1,
|
||||||
|
color: '#52525b', // EXIT_GREY
|
||||||
|
})
|
||||||
|
|
||||||
|
newExits.push({ x: exitX, y: exitY, count: exitCount, color: '#52525b' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setLines(newLines)
|
setLines(newLines)
|
||||||
|
setExits(newExits)
|
||||||
}, [selections, columns, transitions, containerRef])
|
}, [selections, columns, transitions, containerRef])
|
||||||
|
|
||||||
if (lines.length === 0) return null
|
if (lines.length === 0 && exits.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -354,6 +411,19 @@ function ConnectionLines({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{exits.map((exit, i) => (
|
||||||
|
<g key={`exit-${i}`}>
|
||||||
|
<text
|
||||||
|
x={exit.x + 4}
|
||||||
|
y={exit.y + 4}
|
||||||
|
fontSize={11}
|
||||||
|
fontFamily="system-ui, -apple-system, sans-serif"
|
||||||
|
fill={exit.color}
|
||||||
|
>
|
||||||
|
(exit) {exit.count}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -472,7 +542,7 @@ export default function ColumnJourney({
|
|||||||
</div>
|
</div>
|
||||||
{/* Scroll fade indicator */}
|
{/* Scroll fade indicator */}
|
||||||
{canScrollRight && (
|
{canScrollRight && (
|
||||||
<div className="absolute top-0 right-0 bottom-0 w-16 pointer-events-none bg-gradient-to-l from-white dark:from-neutral-900 to-transparent" />
|
<div className="absolute top-0 right-0 bottom-0 w-10 pointer-events-none bg-gradient-to-l from-white/90 dark:from-neutral-900/90 to-transparent" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user