Skip to content

Commit 9b6f21f

Browse files
committed
fix: search and click now pin nodes, hover doesn't clear pinned state
Three bugs fixed: 1. Search blanking graph: search now sets pinnedNode which persists. Reducers use activeNode = hoveredNode || pinnedNode so the pinned highlight stays visible even when mouse moves away. 2. Click not persisting: clicking a node now pins it AND opens impact panel. The highlight stays until user clicks empty stage or closes the panel. 3. Search + hover fighting: hover takes visual priority (for exploring) but pinned state persists underneath. When hover ends, pinned node highlight comes back. Also: search zoom uses setTimeout(50ms) so reducer applies before camera animates -- prevents blank frame during transition.
1 parent 0da3dba commit 9b6f21f

2 files changed

Lines changed: 86 additions & 63 deletions

File tree

frontend/src/components/DependencyGraph/GraphView/SearchBar.tsx

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Search bar for finding and focusing nodes in the graph
2-
// Uses shared highlight state from parent -- doesn't touch reducers directly
2+
// Sets pinnedNode in parent -- doesn't manage reducers
33

44
import { useState, useRef, useEffect, useCallback } from 'react'
55
import { Search, X } from 'lucide-react'
@@ -16,7 +16,6 @@ export function SearchBar({ onFocusNode }: SearchBarProps) {
1616
const [isOpen, setIsOpen] = useState(false)
1717
const inputRef = useRef<HTMLInputElement>(null)
1818

19-
// fuzzy match against all node labels and full paths
2019
useEffect(() => {
2120
if (!query.trim()) {
2221
setResults([])
@@ -29,8 +28,7 @@ export function SearchBar({ onFocusNode }: SearchBarProps) {
2928

3029
graph.forEachNode((node, attrs) => {
3130
const label = (attrs.label as string) || ''
32-
const fullPath = node.toLowerCase()
33-
if (fullPath.includes(q) || label.toLowerCase().includes(q)) {
31+
if (node.toLowerCase().includes(q) || label.toLowerCase().includes(q)) {
3432
matches.push({ id: node, label })
3533
}
3634
})
@@ -48,15 +46,18 @@ export function SearchBar({ onFocusNode }: SearchBarProps) {
4846
const graph = sigma.getGraph()
4947
if (!graph.hasNode(nodeId)) return
5048

51-
// tell parent to highlight this node (drives shared reducers)
49+
// pin this node (parent handles highlight via reducers)
5250
onFocusNode(nodeId)
5351

54-
// zoom to the node using graph coordinates (not display coords)
52+
// zoom camera to node using graph coordinates
5553
const attrs = graph.getNodeAttributes(nodeId)
56-
sigma.getCamera().animate(
57-
{ x: attrs.x as number, y: attrs.y as number, ratio: 0.15 },
58-
{ duration: 400 }
59-
)
54+
// small delay so the reducer applies before camera moves
55+
setTimeout(() => {
56+
sigma.getCamera().animate(
57+
{ x: attrs.x as number, y: attrs.y as number, ratio: 0.2 },
58+
{ duration: 500 }
59+
)
60+
}, 50)
6061

6162
setQuery('')
6263
setResults([])
@@ -71,10 +72,8 @@ export function SearchBar({ onFocusNode }: SearchBarProps) {
7172
sigma.getCamera().animatedReset({ duration: 300 })
7273
}, [sigma, onFocusNode])
7374

74-
// keyboard: / to open, Escape to clear
7575
useEffect(() => {
7676
const handler = (e: KeyboardEvent) => {
77-
// don't capture if user is typing in another input
7877
const target = e.target as HTMLElement
7978
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return
8079

frontend/src/components/DependencyGraph/GraphView/index.tsx

Lines changed: 75 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
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'
56
import {
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

166186
export 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

Comments
 (0)