Skip to content

Commit fbcf7ad

Browse files
committed
feat: add search, controls, fix UX issues in dependency graph
- Add SearchBar: type to find files, fuzzy match, zoom-to-focus on select Keyboard shortcut: / to open, Escape to clear - Add GraphControls: zoom in/out, fit-to-screen, center buttons - Fix ImpactPanel positioning: now overlays as right sidebar on graph - Filter orphan nodes (0 connections) to reduce visual noise - Tune ForceAtlas2: lower gravity, higher scaling for spread-out layout linLogMode + outboundAttractionDistribution for better clustering - Increase iterations to 400 for more settled layout - hideEdgesOnMove for better pan/zoom performance
1 parent ab33409 commit fbcf7ad

5 files changed

Lines changed: 259 additions & 48 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Graph controls: zoom in, zoom out, fit-to-screen, center
2+
// Positioned bottom-left of the graph canvas
3+
4+
import { useCallback } from 'react'
5+
import { useSigma, useCamera } from '@react-sigma/core'
6+
import { ZoomIn, ZoomOut, Maximize2, LocateFixed } from 'lucide-react'
7+
8+
const BTN = 'p-1.5 bg-zinc-900/80 backdrop-blur-sm border border-zinc-700 rounded-md text-zinc-400 hover:text-zinc-200 hover:border-zinc-600 transition-colors'
9+
10+
export function GraphControls() {
11+
const sigma = useSigma()
12+
const { zoomIn, zoomOut, reset } = useCamera({ duration: 300, factor: 1.5 })
13+
14+
const fitToScreen = useCallback(() => {
15+
reset()
16+
}, [reset])
17+
18+
const centerGraph = useCallback(() => {
19+
// zoom to fit all nodes with some padding
20+
sigma.getCamera().animatedReset({ duration: 300 })
21+
}, [sigma])
22+
23+
return (
24+
<div className="absolute bottom-4 left-4 z-10 flex flex-col gap-1">
25+
<button onClick={() => zoomIn()} className={BTN} title="Zoom in">
26+
<ZoomIn className="w-4 h-4" />
27+
</button>
28+
<button onClick={() => zoomOut()} className={BTN} title="Zoom out">
29+
<ZoomOut className="w-4 h-4" />
30+
</button>
31+
<button onClick={fitToScreen} className={BTN} title="Fit to screen">
32+
<Maximize2 className="w-4 h-4" />
33+
</button>
34+
<button onClick={centerGraph} className={BTN} title="Center graph">
35+
<LocateFixed className="w-4 h-4" />
36+
</button>
37+
</div>
38+
)
39+
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Search bar for finding and focusing nodes in the graph
2+
// Floats top-left of the graph canvas
3+
4+
import { useState, useRef, useEffect, useCallback } from 'react'
5+
import { Search, X } from 'lucide-react'
6+
import { useSigma } from '@react-sigma/core'
7+
8+
export function SearchBar() {
9+
const sigma = useSigma()
10+
const [query, setQuery] = useState('')
11+
const [results, setResults] = useState<{ id: string; label: string }[]>([])
12+
const [isOpen, setIsOpen] = useState(false)
13+
const inputRef = useRef<HTMLInputElement>(null)
14+
15+
// fuzzy match against all node labels and full paths
16+
useEffect(() => {
17+
if (!query.trim()) {
18+
setResults([])
19+
return
20+
}
21+
22+
const graph = sigma.getGraph()
23+
const q = query.toLowerCase()
24+
const matches: { id: string; label: string }[] = []
25+
26+
graph.forEachNode((node, attrs) => {
27+
const label = (attrs.label as string) || ''
28+
const fullPath = node.toLowerCase()
29+
if (fullPath.includes(q) || label.toLowerCase().includes(q)) {
30+
matches.push({ id: node, label })
31+
}
32+
})
33+
34+
// sort: exact filename matches first, then by path
35+
matches.sort((a, b) => {
36+
const aExact = a.label.toLowerCase().startsWith(q) ? 0 : 1
37+
const bExact = b.label.toLowerCase().startsWith(q) ? 0 : 1
38+
return aExact - bExact || a.id.localeCompare(b.id)
39+
})
40+
41+
setResults(matches.slice(0, 8))
42+
}, [query, sigma])
43+
44+
const focusNode = useCallback((nodeId: string) => {
45+
const graph = sigma.getGraph()
46+
if (!graph.hasNode(nodeId)) return
47+
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+
})
72+
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+
}
81+
82+
setQuery('')
83+
setResults([])
84+
setIsOpen(false)
85+
}, [sigma])
86+
87+
const clearSearch = useCallback(() => {
88+
setQuery('')
89+
setResults([])
90+
setIsOpen(false)
91+
// reset reducers
92+
sigma.setSetting('nodeReducer', null)
93+
sigma.setSetting('edgeReducer', null)
94+
sigma.getCamera().animatedReset({ duration: 300 })
95+
}, [sigma])
96+
97+
// keyboard shortcut: / or cmd+f to focus search
98+
useEffect(() => {
99+
const handler = (e: KeyboardEvent) => {
100+
if (e.key === '/' && !isOpen) {
101+
e.preventDefault()
102+
setIsOpen(true)
103+
setTimeout(() => inputRef.current?.focus(), 50)
104+
}
105+
if (e.key === 'Escape' && isOpen) {
106+
clearSearch()
107+
}
108+
}
109+
window.addEventListener('keydown', handler)
110+
return () => window.removeEventListener('keydown', handler)
111+
}, [isOpen, clearSearch])
112+
113+
return (
114+
<div className="absolute top-3 left-3 z-10">
115+
{!isOpen ? (
116+
<button
117+
onClick={() => {
118+
setIsOpen(true)
119+
setTimeout(() => inputRef.current?.focus(), 50)
120+
}}
121+
className="flex items-center gap-2 px-3 py-1.5 bg-zinc-900/80 backdrop-blur-sm border border-zinc-700 rounded-lg text-xs text-zinc-400 hover:text-zinc-200 hover:border-zinc-600 transition-colors"
122+
>
123+
<Search className="w-3.5 h-3.5" />
124+
<span>Find file...</span>
125+
<kbd className="ml-2 px-1.5 py-0.5 bg-zinc-800 rounded text-[10px] text-zinc-500">/</kbd>
126+
</button>
127+
) : (
128+
<div className="w-64">
129+
<div className="flex items-center gap-2 px-3 py-1.5 bg-zinc-900/95 backdrop-blur-sm border border-zinc-600 rounded-lg">
130+
<Search className="w-3.5 h-3.5 text-zinc-400 flex-shrink-0" />
131+
<input
132+
ref={inputRef}
133+
type="text"
134+
value={query}
135+
onChange={(e) => setQuery(e.target.value)}
136+
placeholder="Search files..."
137+
className="flex-1 bg-transparent text-sm text-zinc-200 outline-none placeholder:text-zinc-500"
138+
/>
139+
<button onClick={clearSearch} className="text-zinc-500 hover:text-zinc-300">
140+
<X className="w-3.5 h-3.5" />
141+
</button>
142+
</div>
143+
144+
{results.length > 0 && (
145+
<div className="mt-1 bg-zinc-900/95 backdrop-blur-sm border border-zinc-700 rounded-lg overflow-hidden">
146+
{results.map((r) => (
147+
<button
148+
key={r.id}
149+
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"
151+
>
152+
<div className="text-xs text-zinc-200 font-medium">{r.label}</div>
153+
<div className="text-[10px] text-zinc-500 truncate">{r.id}</div>
154+
</button>
155+
))}
156+
</div>
157+
)}
158+
159+
{query && results.length === 0 && (
160+
<div className="mt-1 bg-zinc-900/95 border border-zinc-700 rounded-lg px-3 py-2 text-xs text-zinc-500">
161+
No files found
162+
</div>
163+
)}
164+
</div>
165+
)}
166+
</div>
167+
)
168+
}

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

Lines changed: 22 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
// Sigma.js WebGL graph view
2-
// Renders the dependency graph using WebGL for performance
3-
// Layout + clustering computed in useGraphData, rendering handled by Sigma
2+
// Renders dependency graph with search, controls, hover/click interactions
43

54
import { useState, useEffect } from 'react'
65
import {
@@ -13,6 +12,8 @@ import '@react-sigma/core/lib/style.css'
1312

1413
import { useGraphData } from './useGraphData'
1514
import { NodeTooltip } from './NodeTooltip'
15+
import { SearchBar } from './SearchBar'
16+
import { GraphControls } from './GraphControls'
1617
import type { DependencyApiResponse } from '../types'
1718
import type Graph from 'graphology'
1819

@@ -21,10 +22,10 @@ interface GraphViewProps {
2122
onSelectFile?: (filePath: string) => void
2223
}
2324

24-
// dark theme -- bg matches zinc-950, edges nearly invisible until hover
25+
// dark bg, subtle edges, only important labels visible
2526
const SIGMA_SETTINGS = {
2627
defaultNodeColor: '#6366f1',
27-
defaultEdgeColor: 'rgba(75, 85, 99, 0.15)',
28+
defaultEdgeColor: 'rgba(75, 85, 99, 0.12)',
2829
defaultEdgeType: 'arrow' as const,
2930
edgeReducer: null as any,
3031
nodeReducer: null as any,
@@ -33,34 +34,35 @@ const SIGMA_SETTINGS = {
3334
labelSize: 11,
3435
labelWeight: '500',
3536
labelColor: { color: '#d1d5db' },
36-
// only show labels for large (important) nodes at default zoom
37-
labelRenderedSizeThreshold: 14,
38-
labelDensity: 0.15,
37+
labelRenderedSizeThreshold: 12,
38+
labelDensity: 0.12,
3939
zIndex: true,
4040
minCameraRatio: 0.03,
4141
maxCameraRatio: 3,
42-
stagePadding: 40,
43-
// subtle node borders
42+
stagePadding: 30,
4443
defaultNodeBorderSize: 1,
45-
defaultNodeBorderColor: 'rgba(255, 255, 255, 0.08)',
44+
defaultNodeBorderColor: 'rgba(255, 255, 255, 0.06)',
45+
// performance: skip edges when zoomed out
46+
hideEdgesOnMove: true,
4647
}
4748

48-
// Loads graph into Sigma and fits camera
4949
function LoadAndDisplay({ graph }: { graph: Graph }) {
5050
const loadGraph = useLoadGraph()
5151
const sigma = useSigma()
5252

5353
useEffect(() => {
5454
loadGraph(graph)
55+
// let sigma calculate bounds, then fit the camera with padding
5556
requestAnimationFrame(() => {
56-
sigma.getCamera().animatedReset({ duration: 300 })
57+
requestAnimationFrame(() => {
58+
sigma.getCamera().animatedReset({ duration: 400 })
59+
})
5760
})
5861
}, [graph, loadGraph, sigma])
5962

6063
return null
6164
}
6265

63-
// Handles hover/click interactions and drives node/edge reducers
6466
function Interactions({ onSelectFile }: { onSelectFile?: (filePath: string) => void }) {
6567
const sigma = useSigma()
6668
const registerEvents = useRegisterEvents()
@@ -89,7 +91,7 @@ function Interactions({ onSelectFile }: { onSelectFile?: (filePath: string) => v
8991
const pos = sigma.getNodeDisplayData(node)
9092
if (pos) {
9193
sigma.getCamera().animate(
92-
{ x: pos.x, y: pos.y, ratio: 0.15 },
94+
{ x: pos.x, y: pos.y, ratio: 0.12 },
9395
{ duration: 400 }
9496
)
9597
}
@@ -110,28 +112,18 @@ function Interactions({ onSelectFile }: { onSelectFile?: (filePath: string) => v
110112
return {
111113
...data,
112114
zIndex: 1,
113-
// show label on hover for all neighbors
114115
label: data.label,
115-
// slight glow effect via larger border
116116
borderSize: node === hoveredNode ? 3 : 1,
117-
borderColor: node === hoveredNode ? '#ffffff' : 'rgba(255,255,255,0.2)',
117+
borderColor: node === hoveredNode ? '#ffffff' : 'rgba(255,255,255,0.15)',
118118
}
119119
}
120-
// fade non-neighbors hard
121-
return {
122-
...data,
123-
color: 'rgba(31, 41, 55, 0.3)',
124-
label: '',
125-
zIndex: 0,
126-
borderSize: 0,
127-
}
120+
return { ...data, color: 'rgba(31, 41, 55, 0.25)', label: '', zIndex: 0, borderSize: 0 }
128121
})
129-
130122
sigma.setSetting('edgeReducer', (edge, data) => {
131123
const src = graph.source(edge)
132124
const tgt = graph.target(edge)
133125
if (neighbors.has(src) && neighbors.has(tgt)) {
134-
return { ...data, color: 'rgba(99, 102, 241, 0.6)', size: 1.5 }
126+
return { ...data, color: 'rgba(99, 102, 241, 0.5)', size: 1.5 }
135127
}
136128
return { ...data, hidden: true }
137129
})
@@ -179,9 +171,11 @@ export function GraphView({ data, onSelectFile }: GraphViewProps) {
179171
>
180172
<LoadAndDisplay graph={graph} />
181173
<Interactions onSelectFile={onSelectFile} />
174+
<SearchBar />
175+
<GraphControls />
182176
</SigmaContainer>
183177

184-
{/* legend - glass card style */}
178+
{/* legend */}
185179
<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]">
186180
<div className="text-zinc-400 font-medium mb-1.5">Legend</div>
187181
<div className="space-y-0.5 text-zinc-500">

frontend/src/components/DependencyGraph/GraphView/useGraphData.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,22 @@ export function useGraphData(apiData: DependencyApiResponse | undefined) {
3838

3939
const graph = new Graph({ type: 'directed' })
4040

41-
// Build in-degree map to know dependents count per node
41+
// Build in-degree and out-degree maps
4242
const inDegree: Record<string, number> = {}
43+
const outDegree: Record<string, number> = {}
4344
for (const edge of apiData.edges) {
4445
inDegree[edge.target] = (inDegree[edge.target] || 0) + 1
46+
outDegree[edge.source] = (outDegree[edge.source] || 0) + 1
4547
}
4648

47-
// Add nodes
49+
// Add nodes -- skip orphans (0 connections) to reduce noise
4850
for (const node of apiData.nodes) {
4951
const dependents = inDegree[node.id] || 0
5052
const imports = node.import_count ?? node.imports ?? 0
53+
const totalConnections = dependents + (outDegree[node.id] || 0)
54+
55+
// filter out isolated nodes (no edges at all) -- they clutter the graph
56+
if (totalConnections === 0) continue
5157

5258
graph.addNode(node.id, {
5359
label: node.label || node.id.split('/').pop() || node.id,
@@ -105,18 +111,19 @@ export function useGraphData(apiData: DependencyApiResponse | undefined) {
105111
})
106112
}
107113

108-
// Run ForceAtlas2 for layout positions
109-
// Use synchronous version with fixed iterations for deterministic layout
114+
// ForceAtlas2 for layout -- tuned for readability over compactness
110115
forceAtlas2.assign(graph, {
111-
iterations: 300,
116+
iterations: 400,
112117
settings: {
113-
gravity: 1,
114-
scalingRatio: 10,
115-
barnesHutOptimize: graph.order > 100,
118+
gravity: 0.5,
119+
scalingRatio: 20,
120+
barnesHutOptimize: graph.order > 50,
116121
barnesHutTheta: 0.5,
117-
slowDown: 5,
122+
slowDown: 3,
118123
strongGravityMode: false,
119124
adjustSizes: true,
125+
linLogMode: true,
126+
outboundAttractionDistribution: true,
120127
},
121128
})
122129

0 commit comments

Comments
 (0)