11import './Paths.scss'
22
33import { useActions , useValues } from 'kea'
4- import { useEffect , useRef , useState } from 'react'
4+ import { useEffect , useLayoutEffect , useMemo , useRef , useState } from 'react'
55
66import { useResizeObserver } from 'lib/hooks/useResizeObserver'
77import { InsightEmptyState , InsightErrorState } from 'scenes/insights/EmptyStates'
@@ -13,15 +13,16 @@ import { shouldQueryBeAsync } from '~/queries/utils'
1313
1414import { PathNodeCard } from './PathNodeCard'
1515import { pathsDataLogic } from './pathsDataLogic'
16+ import { pathsInteractionLogic } from './pathsInteractionLogic'
1617import type { PathNodeData } from './pathUtils'
18+ import type { PathsHoverHandlers } from './renderPaths'
1719// eslint-disable-next-line import/no-cycle
1820import { renderPaths } from './renderPaths'
1921
2022const DEFAULT_PATHS_ID = 'default_paths'
2123export const HIDE_PATH_CARD_HEIGHT = 30
2224export const FALLBACK_CANVAS_WIDTH = 1000
2325const FALLBACK_CANVAS_HEIGHT = 0
24- // Debounce delay for canvas resize to prevent rapid SVG recreation from ResizeObserver callbacks
2526const CANVAS_RESIZE_DEBOUNCE_MS = 50
2627
2728export 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 >
0 commit comments