Skip to content

Commit bb0efd4

Browse files
pauldambraclaude
andcommitted
feat(paths): hover interaction logic, overlap stacking, and visual active state
Move all hover state management into pathsInteractionLogic (kea): - hoverNode/hoverLink/clearHover actions with debounced clear - Forward-only chain traversal with one-step-back incoming links - activeIndices and resolvedNodeCards selectors - cardHovered flag to suppress SVG events during card interaction Add overlap detection for stacked cards in the same layer, visual active state (glow, border, dimming of inactive cards), and useLayoutEffect for synchronous SVG stroke updates. Clean up renderPaths to use a PathsHoverHandlers interface instead of raw setState/refs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 017dab2 commit bb0efd4

File tree

9 files changed

+765
-101
lines changed

9 files changed

+765
-101
lines changed

frontend/src/scenes/paths/PathNodeCard.tsx

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useActions, useValues } from 'kea'
2+
import { useRef } from 'react'
23

34
import { LemonDropdown } from '@posthog/lemon-ui'
45

@@ -15,9 +16,18 @@ export type PathNodeCardProps = {
1516
insightProps: InsightLogicProps
1617
node: PathNodeData
1718
canvasHeight: number
19+
onMouseEnter?: () => void
20+
onMouseLeave?: () => void
1821
}
1922

20-
export function PathNodeCard({ insightProps, node, canvasHeight }: PathNodeCardProps): JSX.Element | null {
23+
export function PathNodeCard({
24+
insightProps,
25+
node,
26+
canvasHeight,
27+
onMouseEnter,
28+
onMouseLeave,
29+
}: PathNodeCardProps): JSX.Element | null {
30+
const cardRef = useRef<HTMLDivElement>(null)
2131
const { pathsFilter: _pathsFilter, funnelPathsFilter: _funnelPathsFilter } = useValues(pathsDataLogic(insightProps))
2232
const { updateInsightFilter, openPersonsModal, viewPathToFunnel } = useActions(pathsDataLogic(insightProps))
2333

@@ -56,23 +66,37 @@ export function PathNodeCard({ insightProps, node, canvasHeight }: PathNodeCardP
5666
placement="bottom"
5767
padded={false}
5868
matchWidth
69+
onVisibilityChange={(visible) => {
70+
if (!visible && !cardRef.current?.matches(':hover')) {
71+
onMouseLeave?.()
72+
}
73+
}}
5974
>
6075
<div
61-
className="absolute rounded bg-surface-primary p-1"
76+
ref={cardRef}
77+
className={`PathNodeCard absolute rounded bg-surface-primary p-1${node.active ? ' PathNodeCard--active' : ''}`}
6278
// eslint-disable-next-line react/forbid-dom-props
6379
style={{
6480
width: PATH_NODE_CARD_WIDTH,
6581
left: !isPathEnd
6682
? node.x0 + PATH_NODE_CARD_LEFT_OFFSET
6783
: node.x0 + PATH_NODE_CARD_LEFT_OFFSET - PATH_NODE_CARD_WIDTH,
68-
top: calculatePathNodeCardTop(node, canvasHeight),
84+
top: node.resolvedTop ?? calculatePathNodeCardTop(node, canvasHeight),
6985
border: `1px solid ${
7086
isSelectedPathStartOrEnd(pathsFilter, funnelPathsFilter, node)
7187
? 'purple'
72-
: 'var(--color-border-primary)'
88+
: node.active
89+
? 'var(--paths-link-hover)'
90+
: 'var(--color-border-primary)'
7391
}`,
92+
zIndex: node.active ? 10 : 'auto',
93+
boxShadow: node.active
94+
? '0 2px 10px rgba(0, 0, 0, 0.18), 0 0 0 1px var(--paths-link-hover)'
95+
: 'none',
96+
opacity: node.active ? 1 : undefined,
7497
}}
7598
data-attr="path-node-card-button"
99+
onMouseEnter={onMouseEnter}
76100
>
77101
<PathNodeCardButton
78102
name={node.name}

frontend/src/scenes/paths/Paths.scss

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,14 @@
99
width: 100%;
1010
height: 100% !important;
1111
}
12+
13+
.PathNodeCard:hover {
14+
z-index: 20 !important;
15+
box-shadow: 0 2px 8px rgb(0 0 0 / 15%);
16+
}
17+
18+
// When any card is active, dim the non-active cards
19+
&:has(.PathNodeCard--active) .PathNodeCard:not(.PathNodeCard--active) {
20+
opacity: 0.4;
21+
}
1222
}

frontend/src/scenes/paths/Paths.tsx

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import './Paths.scss'
22

33
import { useActions, useValues } from 'kea'
4-
import { useEffect, useRef, useState } from 'react'
4+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
55

66
import { useResizeObserver } from 'lib/hooks/useResizeObserver'
77
import { InsightEmptyState, InsightErrorState } from 'scenes/insights/EmptyStates'
@@ -13,15 +13,16 @@ import { shouldQueryBeAsync } from '~/queries/utils'
1313

1414
import { PathNodeCard } from './PathNodeCard'
1515
import { pathsDataLogic } from './pathsDataLogic'
16+
import { pathsInteractionLogic } from './pathsInteractionLogic'
1617
import type { PathNodeData } from './pathUtils'
18+
import type { PathsHoverHandlers } from './renderPaths'
1719
// eslint-disable-next-line import/no-cycle
1820
import { renderPaths } from './renderPaths'
1921

2022
const DEFAULT_PATHS_ID = 'default_paths'
2123
export const HIDE_PATH_CARD_HEIGHT = 30
2224
export const FALLBACK_CANVAS_WIDTH = 1000
2325
const FALLBACK_CANVAS_HEIGHT = 0
24-
// Debounce delay for canvas resize to prevent rapid SVG recreation from ResizeObserver callbacks
2526
const CANVAS_RESIZE_DEBOUNCE_MS = 50
2627

2728
export function Paths(): JSX.Element {
@@ -30,7 +31,6 @@ export function Paths(): JSX.Element {
3031
const { width: rawCanvasWidth, height: rawCanvasHeight } = useResizeObserver({ ref: canvasRef })
3132
const [canvasWidth, setCanvasWidth] = useState(FALLBACK_CANVAS_WIDTH)
3233
const [canvasHeight, setCanvasHeight] = useState(FALLBACK_CANVAS_HEIGHT)
33-
const [nodeCards, setNodeCards] = useState<PathNodeData[]>([])
3434

3535
// Debounce canvas dimension updates to prevent rapid SVG recreation from ResizeObserver.
3636
// We do NOT remove data-stable here — the render effect below removes it only when the SVG
@@ -49,18 +49,40 @@ export function Paths(): JSX.Element {
4949
useValues(pathsDataLogic(insightProps))
5050
const { loadData } = useActions(insightDataLogic(insightProps))
5151

52+
const interactionLogic = pathsInteractionLogic(insightProps)
53+
const { resolvedNodeCards, activeIndices } = useValues(interactionLogic)
54+
const { setNodes, hoverNode, hoverLink, clearHover, requestClearHover, setCardHovered } =
55+
useActions(interactionLogic)
56+
57+
const hoverHandlers = useMemo<PathsHoverHandlers>(
58+
() => ({
59+
onNodesReady: (nodes: PathNodeData[]) => setNodes(nodes, canvasHeight),
60+
onNodeHover: hoverNode,
61+
onLinkHover: hoverLink,
62+
onHoverClear: requestClearHover,
63+
isCardHovered: () => interactionLogic.values.cardHovered,
64+
}),
65+
[canvasHeight]
66+
)
67+
68+
useLayoutEffect(() => {
69+
canvasRef.current?.querySelectorAll<SVGPathElement>('path[id^="path-"]').forEach((el) => {
70+
const pathIndex = Number(el.id.replace('path-', ''))
71+
el.setAttribute(
72+
'stroke',
73+
activeIndices.linkIndices.has(pathIndex) ? 'var(--paths-link-hover)' : 'var(--paths-link)'
74+
)
75+
})
76+
}, [activeIndices])
77+
5278
const id = `'${insight?.short_id || DEFAULT_PATHS_ID}'`
5379

5480
useEffect(() => {
55-
setNodeCards([])
81+
clearHover()
5682

57-
// Remove the existing SVG canvas(es). The .Paths__canvas selector is crucial, as we have to be sure
58-
// we're only removing the Paths viz and not, for example, button icons.
59-
// Only remove canvases within this component's container
6083
const elements = canvasContainerRef.current?.querySelectorAll(`.Paths__canvas`)
6184
elements?.forEach((node) => node?.parentNode?.removeChild(node))
6285

63-
// Mark the canvas as not yet stable while we recreate the SVG
6486
if (canvasRef.current) {
6587
canvasRef.current.removeAttribute('data-stable')
6688
}
@@ -72,21 +94,29 @@ export function Paths(): JSX.Element {
7294
paths,
7395
pathsFilter || {},
7496
funnelPathsFilter || ({} as FunnelPathsFilter),
75-
setNodeCards
97+
hoverHandlers
7698
)
7799

78-
// Mark the canvas as stable after SVG creation
79100
if (canvasRef.current) {
80101
canvasRef.current.setAttribute('data-stable', 'true')
81102
}
82103

83-
// Proper cleanup
84104
return () => {
85105
const elements = canvasContainerRef.current?.querySelectorAll(`.Paths__canvas`)
86106
elements?.forEach((node) => node?.parentNode?.removeChild(node))
87107
}
88108
}, [paths, insightDataLoading, canvasWidth, canvasHeight, theme, pathsFilter, funnelPathsFilter])
89109

110+
const handleCardMouseEnter = (node: PathNodeData): void => {
111+
setCardHovered(true)
112+
hoverNode(node.index)
113+
}
114+
115+
const handleCardMouseLeave = (): void => {
116+
setCardHovered(false)
117+
requestClearHover()
118+
}
119+
90120
if (insightDataError) {
91121
return (
92122
<InsightErrorState
@@ -118,9 +148,15 @@ export function Paths(): JSX.Element {
118148
>
119149
{!insightDataLoading && paths && paths.nodes.length === 0 && !insightDataError && <InsightEmptyState />}
120150
{!insightDataError &&
121-
nodeCards &&
122-
nodeCards.map((node, idx) => (
123-
<PathNodeCard key={idx} node={node} insightProps={insightProps} canvasHeight={canvasHeight} />
151+
resolvedNodeCards.map((node, idx) => (
152+
<PathNodeCard
153+
key={idx}
154+
node={node}
155+
insightProps={insightProps}
156+
canvasHeight={canvasHeight}
157+
onMouseEnter={() => handleCardMouseEnter(node)}
158+
onMouseLeave={handleCardMouseLeave}
159+
/>
124160
))}
125161
</div>
126162
</div>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export const PATH_NODE_CARD_WIDTH = 240
2+
export const PATH_NODE_CARD_HEIGHT = 38
3+
export const PATH_NODE_CARD_OVERLAP_GAP = 4
24
export const PATH_NODE_CARD_TOP_OFFSET = 5
35
export const PATH_NODE_CARD_LEFT_OFFSET = 7

0 commit comments

Comments
 (0)