Skip to content

Commit 0da3dba

Browse files
committed
fix: search breaking graph -- shared highlight state between search and hover
Root cause: SearchBar and Interactions both set nodeReducer/edgeReducer independently. When search set reducers, hover's useEffect fired with hoveredNode=null and reset them to null, blanking the graph. Fix: lift highlightedNode state to GraphView parent. Both SearchBar and Interactions write to the same state. Single useEffect drives the reducers from that shared state. Also fixed: camera zoom using graph coords (node attributes x/y) instead of display coords (getNodeDisplayData) which were viewport- relative and caused zoom to wrong position. Also: clickStage clears highlight, keyboard handler skips when user is typing in other inputs.
1 parent fbcf7ad commit 0da3dba

2 files changed

Lines changed: 67 additions & 70 deletions

File tree

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

Lines changed: 24 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
// Search bar for finding and focusing nodes in the graph
2-
// Floats top-left of the graph canvas
2+
// Uses shared highlight state from parent -- doesn't touch reducers directly
33

44
import { useState, useRef, useEffect, useCallback } from 'react'
55
import { Search, X } from 'lucide-react'
66
import { useSigma } from '@react-sigma/core'
77

8-
export function SearchBar() {
8+
interface SearchBarProps {
9+
onFocusNode: (nodeId: string | null) => void
10+
}
11+
12+
export function SearchBar({ onFocusNode }: SearchBarProps) {
913
const sigma = useSigma()
1014
const [query, setQuery] = useState('')
1115
const [results, setResults] = useState<{ id: string; label: string }[]>([])
@@ -31,7 +35,6 @@ export function SearchBar() {
3135
}
3236
})
3337

34-
// sort: exact filename matches first, then by path
3538
matches.sort((a, b) => {
3639
const aExact = a.label.toLowerCase().startsWith(q) ? 0 : 1
3740
const bExact = b.label.toLowerCase().startsWith(q) ? 0 : 1
@@ -45,58 +48,36 @@ export function SearchBar() {
4548
const graph = sigma.getGraph()
4649
if (!graph.hasNode(nodeId)) return
4750

48-
// highlight this node and neighbors
49-
const neighbors = new Set(graph.neighbors(nodeId))
50-
neighbors.add(nodeId)
51-
52-
sigma.setSetting('nodeReducer', (node, data) => {
53-
if (neighbors.has(node)) {
54-
return {
55-
...data,
56-
zIndex: 1,
57-
label: data.label,
58-
borderSize: node === nodeId ? 3 : 1,
59-
borderColor: node === nodeId ? '#ffffff' : 'rgba(255,255,255,0.2)',
60-
}
61-
}
62-
return { ...data, color: 'rgba(31, 41, 55, 0.3)', label: '', zIndex: 0, borderSize: 0 }
63-
})
64-
sigma.setSetting('edgeReducer', (edge, data) => {
65-
const src = graph.source(edge)
66-
const tgt = graph.target(edge)
67-
if (neighbors.has(src) && neighbors.has(tgt)) {
68-
return { ...data, color: 'rgba(99, 102, 241, 0.6)', size: 1.5 }
69-
}
70-
return { ...data, hidden: true }
71-
})
51+
// tell parent to highlight this node (drives shared reducers)
52+
onFocusNode(nodeId)
7253

73-
// zoom to the node
74-
const pos = sigma.getNodeDisplayData(nodeId)
75-
if (pos) {
76-
sigma.getCamera().animate(
77-
{ x: pos.x, y: pos.y, ratio: 0.15 },
78-
{ duration: 400 }
79-
)
80-
}
54+
// zoom to the node using graph coordinates (not display coords)
55+
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+
)
8160

8261
setQuery('')
8362
setResults([])
8463
setIsOpen(false)
85-
}, [sigma])
64+
}, [sigma, onFocusNode])
8665

8766
const clearSearch = useCallback(() => {
8867
setQuery('')
8968
setResults([])
9069
setIsOpen(false)
91-
// reset reducers
92-
sigma.setSetting('nodeReducer', null)
93-
sigma.setSetting('edgeReducer', null)
70+
onFocusNode(null)
9471
sigma.getCamera().animatedReset({ duration: 300 })
95-
}, [sigma])
72+
}, [sigma, onFocusNode])
9673

97-
// keyboard shortcut: / or cmd+f to focus search
74+
// keyboard: / to open, Escape to clear
9875
useEffect(() => {
9976
const handler = (e: KeyboardEvent) => {
77+
// don't capture if user is typing in another input
78+
const target = e.target as HTMLElement
79+
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return
80+
10081
if (e.key === '/' && !isOpen) {
10182
e.preventDefault()
10283
setIsOpen(true)
@@ -142,12 +123,12 @@ export function SearchBar() {
142123
</div>
143124

144125
{results.length > 0 && (
145-
<div className="mt-1 bg-zinc-900/95 backdrop-blur-sm border border-zinc-700 rounded-lg overflow-hidden">
126+
<div className="mt-1 bg-zinc-900/95 backdrop-blur-sm border border-zinc-700 rounded-lg overflow-hidden max-h-[280px] overflow-y-auto">
146127
{results.map((r) => (
147128
<button
148129
key={r.id}
149130
onClick={() => focusNode(r.id)}
150-
className="w-full text-left px-3 py-2 hover:bg-zinc-800 transition-colors border-b border-zinc-800 last:border-0"
131+
className="w-full text-left px-3 py-2 hover:bg-zinc-800 transition-colors border-b border-zinc-800/50 last:border-0"
151132
>
152133
<div className="text-xs text-zinc-200 font-medium">{r.label}</div>
153134
<div className="text-[10px] text-zinc-500 truncate">{r.id}</div>

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

Lines changed: 43 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Sigma.js WebGL graph view
2-
// Renders dependency graph with search, controls, hover/click interactions
2+
// Single source of truth for highlight state shared between search and hover
33

4-
import { useState, useEffect } from 'react'
4+
import { useState, useEffect, useCallback } from 'react'
55
import {
66
SigmaContainer,
77
useSigma,
@@ -22,7 +22,6 @@ interface GraphViewProps {
2222
onSelectFile?: (filePath: string) => void
2323
}
2424

25-
// dark bg, subtle edges, only important labels visible
2625
const SIGMA_SETTINGS = {
2726
defaultNodeColor: '#6366f1',
2827
defaultEdgeColor: 'rgba(75, 85, 99, 0.12)',
@@ -42,7 +41,6 @@ const SIGMA_SETTINGS = {
4241
stagePadding: 30,
4342
defaultNodeBorderSize: 1,
4443
defaultNodeBorderColor: 'rgba(255, 255, 255, 0.06)',
45-
// performance: skip edges when zoomed out
4644
hideEdgesOnMove: true,
4745
}
4846

@@ -52,7 +50,6 @@ function LoadAndDisplay({ graph }: { graph: Graph }) {
5250

5351
useEffect(() => {
5452
loadGraph(graph)
55-
// let sigma calculate bounds, then fit the camera with padding
5653
requestAnimationFrame(() => {
5754
requestAnimationFrame(() => {
5855
sigma.getCamera().animatedReset({ duration: 400 })
@@ -63,10 +60,19 @@ function LoadAndDisplay({ graph }: { graph: Graph }) {
6360
return null
6461
}
6562

66-
function Interactions({ onSelectFile }: { onSelectFile?: (filePath: string) => void }) {
63+
// Single component that owns all highlight state
64+
// Both hover and search funnel into "highlightedNode"
65+
function InteractionsAndHighlight({
66+
onSelectFile,
67+
highlightedNode,
68+
setHighlightedNode,
69+
}: {
70+
onSelectFile?: (filePath: string) => void
71+
highlightedNode: string | null
72+
setHighlightedNode: (node: string | null) => void
73+
}) {
6774
const sigma = useSigma()
6875
const registerEvents = useRegisterEvents()
69-
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
7076
const [tooltip, setTooltip] = useState<{
7177
nodeId: string
7278
position: { x: number; y: number }
@@ -75,46 +81,51 @@ function Interactions({ onSelectFile }: { onSelectFile?: (filePath: string) => v
7581
useEffect(() => {
7682
registerEvents({
7783
enterNode: ({ node, event }) => {
78-
setHoveredNode(node)
84+
setHighlightedNode(node)
7985
setTooltip({ nodeId: node, position: { x: event.x, y: event.y } })
8086
const el = sigma.getContainer()
8187
if (el) el.style.cursor = 'pointer'
8288
},
8389
leaveNode: () => {
84-
setHoveredNode(null)
90+
setHighlightedNode(null)
8591
setTooltip(null)
8692
const el = sigma.getContainer()
8793
if (el) el.style.cursor = 'default'
8894
},
8995
clickNode: ({ node }) => onSelectFile?.(node),
9096
doubleClickNode: ({ node }) => {
91-
const pos = sigma.getNodeDisplayData(node)
92-
if (pos) {
93-
sigma.getCamera().animate(
94-
{ x: pos.x, y: pos.y, ratio: 0.12 },
95-
{ duration: 400 }
96-
)
97-
}
97+
// use graph coords (node attributes), not display coords
98+
const graph = sigma.getGraph()
99+
if (!graph.hasNode(node)) return
100+
const attrs = graph.getNodeAttributes(node)
101+
sigma.getCamera().animate(
102+
{ x: attrs.x, y: attrs.y, ratio: 0.12 },
103+
{ duration: 400 }
104+
)
105+
},
106+
// click on empty stage clears highlight
107+
clickStage: () => {
108+
setHighlightedNode(null)
98109
},
99110
})
100-
}, [registerEvents, sigma, onSelectFile])
111+
}, [registerEvents, sigma, onSelectFile, setHighlightedNode])
101112

102-
// highlight hovered neighborhood, fade everything else
113+
// single reducer driven by highlightedNode -- works for both hover and search
103114
useEffect(() => {
104115
const graph = sigma.getGraph()
105116

106-
if (hoveredNode && graph.hasNode(hoveredNode)) {
107-
const neighbors = new Set(graph.neighbors(hoveredNode))
108-
neighbors.add(hoveredNode)
117+
if (highlightedNode && graph.hasNode(highlightedNode)) {
118+
const neighbors = new Set(graph.neighbors(highlightedNode))
119+
neighbors.add(highlightedNode)
109120

110121
sigma.setSetting('nodeReducer', (node, data) => {
111122
if (neighbors.has(node)) {
112123
return {
113124
...data,
114125
zIndex: 1,
115126
label: data.label,
116-
borderSize: node === hoveredNode ? 3 : 1,
117-
borderColor: node === hoveredNode ? '#ffffff' : 'rgba(255,255,255,0.15)',
127+
borderSize: node === highlightedNode ? 3 : 1,
128+
borderColor: node === highlightedNode ? '#ffffff' : 'rgba(255,255,255,0.15)',
118129
}
119130
}
120131
return { ...data, color: 'rgba(31, 41, 55, 0.25)', label: '', zIndex: 0, borderSize: 0 }
@@ -131,7 +142,7 @@ function Interactions({ onSelectFile }: { onSelectFile?: (filePath: string) => v
131142
sigma.setSetting('nodeReducer', null)
132143
sigma.setSetting('edgeReducer', null)
133144
}
134-
}, [hoveredNode, sigma])
145+
}, [highlightedNode, sigma])
135146

136147
const tooltipData = (() => {
137148
if (!tooltip) return null
@@ -154,6 +165,8 @@ function Interactions({ onSelectFile }: { onSelectFile?: (filePath: string) => v
154165

155166
export function GraphView({ data, onSelectFile }: GraphViewProps) {
156167
const graph = useGraphData(data)
168+
// shared highlight state -- search and hover both write to this
169+
const [highlightedNode, setHighlightedNode] = useState<string | null>(null)
157170

158171
if (!graph || graph.order === 0) {
159172
return (
@@ -170,12 +183,15 @@ export function GraphView({ data, onSelectFile }: GraphViewProps) {
170183
settings={SIGMA_SETTINGS}
171184
>
172185
<LoadAndDisplay graph={graph} />
173-
<Interactions onSelectFile={onSelectFile} />
174-
<SearchBar />
186+
<InteractionsAndHighlight
187+
onSelectFile={onSelectFile}
188+
highlightedNode={highlightedNode}
189+
setHighlightedNode={setHighlightedNode}
190+
/>
191+
<SearchBar onFocusNode={setHighlightedNode} />
175192
<GraphControls />
176193
</SigmaContainer>
177194

178-
{/* legend */}
179195
<div className="absolute bottom-4 right-4 bg-zinc-900/80 backdrop-blur-sm border border-zinc-800 rounded-lg px-3 py-2.5 text-[11px]">
180196
<div className="text-zinc-400 font-medium mb-1.5">Legend</div>
181197
<div className="space-y-0.5 text-zinc-500">

0 commit comments

Comments
 (0)