fix: show exit as red card in next column instead of SVG text hack
This commit is contained in:
@@ -215,14 +215,16 @@ function JourneyColumn({
|
|||||||
column,
|
column,
|
||||||
color,
|
color,
|
||||||
selectedPath,
|
selectedPath,
|
||||||
|
exitCount,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
column: Column
|
column: Column
|
||||||
color: string
|
color: string
|
||||||
selectedPath: string | undefined
|
selectedPath: string | undefined
|
||||||
|
exitCount: number
|
||||||
onSelect: (path: string) => void
|
onSelect: (path: string) => void
|
||||||
}) {
|
}) {
|
||||||
if (column.pages.length === 0) {
|
if (column.pages.length === 0 && exitCount === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="w-60 shrink-0">
|
<div className="w-60 shrink-0">
|
||||||
<ColumnHeader column={column} color={color} />
|
<ColumnHeader column={column} color={color} />
|
||||||
@@ -255,6 +257,20 @@ function JourneyColumn({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{exitCount > 0 && (
|
||||||
|
<div
|
||||||
|
data-col={column.index}
|
||||||
|
data-path="(exit)"
|
||||||
|
className="flex items-center justify-between w-full px-3 py-2.5 rounded-lg border border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-red-600 dark:text-red-400">
|
||||||
|
(exit)
|
||||||
|
</span>
|
||||||
|
<span className="ml-3 shrink-0 text-sm tabular-nums font-semibold text-red-600 dark:text-red-400">
|
||||||
|
{exitCount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -262,13 +278,6 @@ function JourneyColumn({
|
|||||||
|
|
||||||
// ─── Connection Lines ───────────────────────────────────────────────
|
// ─── Connection Lines ───────────────────────────────────────────────
|
||||||
|
|
||||||
interface ExitLabel {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
count: number
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConnectionLines({
|
function ConnectionLines({
|
||||||
containerRef,
|
containerRef,
|
||||||
selections,
|
selections,
|
||||||
@@ -281,14 +290,12 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,10 +306,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)}"]`
|
||||||
@@ -319,77 +326,43 @@ function ConnectionLines({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const color = colorForColumn(colIdx)
|
const color = colorForColumn(colIdx)
|
||||||
|
const maxCount = relevantTransitions.length > 0
|
||||||
|
? Math.max(...relevantTransitions.map((rt) => rt.session_count))
|
||||||
|
: 1
|
||||||
|
|
||||||
// Find total sessions for this page
|
for (const t of relevantTransitions) {
|
||||||
const col = columns[colIdx]
|
const destEl = container.querySelector(
|
||||||
const page = col?.pages.find((p) => p.path === selectedPath)
|
`[data-col="${colIdx + 1}"][data-path="${CSS.escape(t.to_path)}"]`
|
||||||
const pageCount = page?.sessionCount ?? 0
|
) as HTMLElement | null
|
||||||
const outboundCount = relevantTransitions.reduce((sum, t) => sum + t.session_count, 0)
|
if (!destEl) continue
|
||||||
const exitCount = pageCount - outboundCount
|
|
||||||
|
|
||||||
if (nextCol) {
|
const destRect = destEl.getBoundingClientRect()
|
||||||
const maxCount = Math.max(
|
const destY =
|
||||||
...relevantTransitions.map((rt) => rt.session_count),
|
destRect.top + destRect.height / 2 - containerRect.top + container.scrollTop
|
||||||
exitCount > 0 ? exitCount : 0
|
const destX = destRect.left - containerRect.left + container.scrollLeft
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
const destRect = destEl.getBoundingClientRect()
|
newLines.push({ sourceY, destY, sourceX, destX, weight, color })
|
||||||
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
|
// Draw line to exit card if it exists
|
||||||
if (exitCount > 0) {
|
const exitEl = container.querySelector(
|
||||||
// Position the exit label below the last destination or below the source
|
`[data-col="${colIdx + 1}"][data-path="(exit)"]`
|
||||||
const lastDestY = newLines.length > 0
|
) as HTMLElement | null
|
||||||
? Math.max(...newLines.filter((l) => l.sourceX === sourceX).map((l) => l.destY))
|
if (exitEl) {
|
||||||
: sourceY
|
const exitRect = exitEl.getBoundingClientRect()
|
||||||
|
const exitY =
|
||||||
const exitY = lastDestY + 30
|
exitRect.top + exitRect.height / 2 - containerRect.top + container.scrollTop
|
||||||
const exitX = nextCol
|
const exitX = exitRect.left - containerRect.left + container.scrollLeft
|
||||||
? ((): number => {
|
newLines.push({ sourceY, destY: exitY, sourceX, destX: exitX, weight: 1, color: '#ef4444' })
|
||||||
// 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 && exits.length === 0) return null
|
if (lines.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -411,23 +384,27 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 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 ─────────────────────────────────────────────────
|
// ─── Main Component ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ColumnJourney({
|
export default function ColumnJourney({
|
||||||
@@ -523,15 +500,24 @@ export default function ColumnJourney({
|
|||||||
className="overflow-x-auto -mx-6 px-6 pb-2 relative"
|
className="overflow-x-auto -mx-6 px-6 pb-2 relative"
|
||||||
>
|
>
|
||||||
<div className="flex gap-8 min-w-fit py-2">
|
<div className="flex gap-8 min-w-fit py-2">
|
||||||
{columns.map((col) => (
|
{columns.map((col) => {
|
||||||
<JourneyColumn
|
// Show exit card in this column if the previous column has a selection
|
||||||
key={col.index}
|
const prevSelection = selections.get(col.index - 1)
|
||||||
column={col}
|
const exitCount = prevSelection
|
||||||
color={colorForColumn(col.index)}
|
? getExitCount(col.index - 1, prevSelection, columns, transitions)
|
||||||
selectedPath={selections.get(col.index)}
|
: 0
|
||||||
onSelect={(path) => handleSelect(col.index, path)}
|
|
||||||
/>
|
return (
|
||||||
))}
|
<JourneyColumn
|
||||||
|
key={col.index}
|
||||||
|
column={col}
|
||||||
|
color={colorForColumn(col.index)}
|
||||||
|
selectedPath={selections.get(col.index)}
|
||||||
|
exitCount={exitCount}
|
||||||
|
onSelect={(path) => handleSelect(col.index, path)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<ConnectionLines
|
<ConnectionLines
|
||||||
containerRef={containerRef}
|
containerRef={containerRef}
|
||||||
|
|||||||
Reference in New Issue
Block a user