11// Sigma.js WebGL graph view
2- // Single source of truth for highlight state shared between search and hover
2+ // Highlight state: "pinned" (from click/search) vs "hovered" (from mouseover)
3+ // Pinned persists until user clicks stage or closes panel. Hover is transient.
34
4- import { useState , useEffect , useCallback } from 'react'
5+ import { useState , useEffect } from 'react'
56import {
67 SigmaContainer ,
78 useSigma ,
@@ -60,19 +61,27 @@ function LoadAndDisplay({ graph }: { graph: Graph }) {
6061 return null
6162}
6263
63- // Single component that owns all highlight state
64- // Both hover and search funnel into "highlightedNode"
65- function InteractionsAndHighlight ( {
64+ // Builds the neighbor set for a given node, used by the reducer
65+ function getNeighborSet ( sigma : ReturnType < typeof useSigma > , nodeId : string ) : Set < string > | null {
66+ const graph = sigma . getGraph ( )
67+ if ( ! graph . hasNode ( nodeId ) ) return null
68+ const neighbors = new Set ( graph . neighbors ( nodeId ) )
69+ neighbors . add ( nodeId )
70+ return neighbors
71+ }
72+
73+ function Interactions ( {
6674 onSelectFile,
67- highlightedNode ,
68- setHighlightedNode ,
75+ pinnedNode ,
76+ setPinnedNode ,
6977} : {
7078 onSelectFile ?: ( filePath : string ) => void
71- highlightedNode : string | null
72- setHighlightedNode : ( node : string | null ) => void
79+ pinnedNode : string | null
80+ setPinnedNode : ( node : string | null ) => void
7381} ) {
7482 const sigma = useSigma ( )
7583 const registerEvents = useRegisterEvents ( )
84+ const [ hoveredNode , setHoveredNode ] = useState < string | null > ( null )
7685 const [ tooltip , setTooltip ] = useState < {
7786 nodeId : string
7887 position : { x : number ; y : number }
@@ -81,68 +90,79 @@ function InteractionsAndHighlight({
8190 useEffect ( ( ) => {
8291 registerEvents ( {
8392 enterNode : ( { node, event } ) => {
84- setHighlightedNode ( node )
93+ setHoveredNode ( node )
8594 setTooltip ( { nodeId : node , position : { x : event . x , y : event . y } } )
8695 const el = sigma . getContainer ( )
8796 if ( el ) el . style . cursor = 'pointer'
8897 } ,
8998 leaveNode : ( ) => {
90- setHighlightedNode ( null )
99+ setHoveredNode ( null )
91100 setTooltip ( null )
92101 const el = sigma . getContainer ( )
93102 if ( el ) el . style . cursor = 'default'
94103 } ,
95- clickNode : ( { node } ) => onSelectFile ?.( node ) ,
104+ clickNode : ( { node } ) => {
105+ // pin this node and open impact panel
106+ setPinnedNode ( node )
107+ onSelectFile ?.( node )
108+ } ,
96109 doubleClickNode : ( { node } ) => {
97- // use graph coords (node attributes), not display coords
98110 const graph = sigma . getGraph ( )
99111 if ( ! graph . hasNode ( node ) ) return
100112 const attrs = graph . getNodeAttributes ( node )
101113 sigma . getCamera ( ) . animate (
102- { x : attrs . x , y : attrs . y , ratio : 0.12 } ,
114+ { x : attrs . x as number , y : attrs . y as number , ratio : 0.12 } ,
103115 { duration : 400 }
104116 )
105117 } ,
106- // click on empty stage clears highlight
107118 clickStage : ( ) => {
108- setHighlightedNode ( null )
119+ // clear pinned state when clicking empty space
120+ setPinnedNode ( null )
121+ onSelectFile ?.( undefined as any )
109122 } ,
110123 } )
111- } , [ registerEvents , sigma , onSelectFile , setHighlightedNode ] )
124+ } , [ registerEvents , sigma , onSelectFile , setPinnedNode ] )
125+
126+ // the active node is: hovered takes priority for visual, but pinned persists
127+ const activeNode = hoveredNode || pinnedNode
112128
113- // single reducer driven by highlightedNode -- works for both hover and search
114129 useEffect ( ( ) => {
115- const graph = sigma . getGraph ( )
130+ if ( ! activeNode ) {
131+ sigma . setSetting ( 'nodeReducer' , null )
132+ sigma . setSetting ( 'edgeReducer' , null )
133+ return
134+ }
116135
117- if ( highlightedNode && graph . hasNode ( highlightedNode ) ) {
118- const neighbors = new Set ( graph . neighbors ( highlightedNode ) )
119- neighbors . add ( highlightedNode )
120-
121- sigma . setSetting ( 'nodeReducer' , ( node , data ) => {
122- if ( neighbors . has ( node ) ) {
123- return {
124- ...data ,
125- zIndex : 1 ,
126- label : data . label ,
127- borderSize : node === highlightedNode ? 3 : 1 ,
128- borderColor : node === highlightedNode ? '#ffffff' : 'rgba(255,255,255,0.15)' ,
129- }
130- }
131- return { ...data , color : 'rgba(31, 41, 55, 0.25)' , label : '' , zIndex : 0 , borderSize : 0 }
132- } )
133- sigma . setSetting ( 'edgeReducer' , ( edge , data ) => {
134- const src = graph . source ( edge )
135- const tgt = graph . target ( edge )
136- if ( neighbors . has ( src ) && neighbors . has ( tgt ) ) {
137- return { ...data , color : 'rgba(99, 102, 241, 0.5)' , size : 1.5 }
138- }
139- return { ...data , hidden : true }
140- } )
141- } else {
136+ const neighbors = getNeighborSet ( sigma , activeNode )
137+ if ( ! neighbors ) {
142138 sigma . setSetting ( 'nodeReducer' , null )
143139 sigma . setSetting ( 'edgeReducer' , null )
140+ return
144141 }
145- } , [ highlightedNode , sigma ] )
142+
143+ sigma . setSetting ( 'nodeReducer' , ( node , data ) => {
144+ if ( neighbors . has ( node ) ) {
145+ return {
146+ ...data ,
147+ zIndex : 1 ,
148+ label : data . label ,
149+ borderSize : node === activeNode ? 3 : 1 ,
150+ borderColor : node === activeNode ? '#ffffff' : 'rgba(255,255,255,0.15)' ,
151+ }
152+ }
153+ return { ...data , color : 'rgba(31, 41, 55, 0.25)' , label : '' , zIndex : 0 , borderSize : 0 }
154+ } )
155+
156+ sigma . setSetting ( 'edgeReducer' , ( edge , data ) => {
157+ const graph = sigma . getGraph ( )
158+ const src = graph . source ( edge )
159+ const tgt = graph . target ( edge )
160+ if ( neighbors . has ( src ) && neighbors . has ( tgt ) ) {
161+ return { ...data , color : 'rgba(99, 102, 241, 0.5)' , size : 1.5 }
162+ }
163+ return { ...data , hidden : true }
164+ } )
165+ } , [ activeNode , sigma ] )
146166
147167 const tooltipData = ( ( ) => {
148168 if ( ! tooltip ) return null
@@ -165,8 +185,7 @@ function InteractionsAndHighlight({
165185
166186export function GraphView ( { data, onSelectFile } : GraphViewProps ) {
167187 const graph = useGraphData ( data )
168- // shared highlight state -- search and hover both write to this
169- const [ highlightedNode , setHighlightedNode ] = useState < string | null > ( null )
188+ const [ pinnedNode , setPinnedNode ] = useState < string | null > ( null )
170189
171190 if ( ! graph || graph . order === 0 ) {
172191 return (
@@ -183,12 +202,17 @@ export function GraphView({ data, onSelectFile }: GraphViewProps) {
183202 settings = { SIGMA_SETTINGS }
184203 >
185204 < LoadAndDisplay graph = { graph } />
186- < InteractionsAndHighlight
205+ < Interactions
187206 onSelectFile = { onSelectFile }
188- highlightedNode = { highlightedNode }
189- setHighlightedNode = { setHighlightedNode }
207+ pinnedNode = { pinnedNode }
208+ setPinnedNode = { setPinnedNode }
209+ />
210+ < SearchBar
211+ onFocusNode = { ( nodeId ) => {
212+ setPinnedNode ( nodeId )
213+ if ( nodeId ) onSelectFile ?.( nodeId )
214+ } }
190215 />
191- < SearchBar onFocusNode = { setHighlightedNode } />
192216 < GraphControls />
193217 </ SigmaContainer >
194218
0 commit comments