Skip to content

Commit 3f6ac31

Browse files
committed
feat: implement Sigma.js GraphView and Dependency Matrix view
GraphView (OPE-45): - WebGL rendering via Sigma.js with dark theme - ForceAtlas2 layout with Louvain community detection for cluster coloring - Hover highlights node neighborhood, fades rest - Click opens impact analysis, double-click zooms to node - Node tooltip showing file details on hover - Legend overlay explaining visual encoding MatrixView (OPE-46): - Dependency Structure Matrix showing file-to-file imports - Color intensity maps to coupling strength - Red cells highlight circular dependencies - Directory grouping with visual separators - Sticky row/column headers for scroll navigation - Row click triggers impact analysis - Stats bar showing file count, deps, and cycle count - Truncation for large matrices (150+ files)
1 parent 1de90ed commit 3f6ac31

3 files changed

Lines changed: 426 additions & 7 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Tooltip shown when hovering a node in the graph
2+
// Positioned absolutely near the cursor
3+
4+
interface NodeTooltipProps {
5+
nodeId: string
6+
label: string
7+
directory: string
8+
imports: number
9+
dependents: number
10+
riskLevel: string
11+
position: { x: number; y: number }
12+
}
13+
14+
const RISK_COLORS: Record<string, string> = {
15+
low: 'text-emerald-400',
16+
med: 'text-yellow-400',
17+
high: 'text-rose-400',
18+
}
19+
20+
export function NodeTooltip({
21+
label,
22+
directory,
23+
imports,
24+
dependents,
25+
riskLevel,
26+
position,
27+
}: NodeTooltipProps) {
28+
return (
29+
<div
30+
className="fixed z-50 pointer-events-none bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 shadow-xl"
31+
style={{
32+
left: position.x + 12,
33+
top: position.y - 10,
34+
}}
35+
>
36+
<div className="text-sm font-medium text-zinc-100">{label}</div>
37+
<div className="text-xs text-zinc-500 mb-1.5">{directory}</div>
38+
<div className="flex gap-3 text-xs">
39+
<span className="text-zinc-400">
40+
{imports} imports
41+
</span>
42+
<span className={RISK_COLORS[riskLevel] || 'text-zinc-400'}>
43+
{dependents} dependents
44+
</span>
45+
</div>
46+
</div>
47+
)
48+
}
Lines changed: 157 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,170 @@
1-
// Sigma.js WebGL graph rendering view
2-
// TODO: OPE-45 -- full implementation with hover/click interactions
1+
// Sigma.js WebGL graph view
2+
// Renders the dependency graph using WebGL for performance
3+
// Layout + clustering computed in useGraphData, rendering handled by Sigma
34

5+
import { useState, useEffect } from 'react'
6+
import {
7+
SigmaContainer,
8+
useSigma,
9+
useRegisterEvents,
10+
useLoadGraph,
11+
} from '@react-sigma/core'
12+
import '@react-sigma/core/lib/style.css'
13+
14+
import { useGraphData } from './useGraphData'
15+
import { NodeTooltip } from './NodeTooltip'
416
import type { DependencyApiResponse } from '../types'
17+
import type Graph from 'graphology'
518

619
interface GraphViewProps {
720
data: DependencyApiResponse
821
onSelectFile?: (filePath: string) => void
922
}
1023

24+
// Dark theme settings for sigma
25+
const SIGMA_SETTINGS = {
26+
defaultNodeColor: '#6366f1',
27+
defaultEdgeColor: '#374151',
28+
defaultEdgeType: 'arrow' as const,
29+
renderEdgeLabels: false,
30+
labelFont: 'Inter, system-ui, sans-serif',
31+
labelSize: 12,
32+
labelColor: { color: '#e5e7eb' },
33+
labelRenderedSizeThreshold: 8,
34+
zIndex: true,
35+
minCameraRatio: 0.05,
36+
maxCameraRatio: 3,
37+
}
38+
39+
// Loads graph into Sigma and fits camera
40+
function LoadAndDisplay({ graph }: { graph: Graph }) {
41+
const loadGraph = useLoadGraph()
42+
const sigma = useSigma()
43+
44+
useEffect(() => {
45+
loadGraph(graph)
46+
// fit camera after graph loads
47+
requestAnimationFrame(() => {
48+
sigma.getCamera().animatedReset({ duration: 300 })
49+
})
50+
}, [graph, loadGraph, sigma])
51+
52+
return null
53+
}
54+
55+
// Handles all mouse/keyboard interactions on the graph
56+
function Interactions({ onSelectFile }: { onSelectFile?: (filePath: string) => void }) {
57+
const sigma = useSigma()
58+
const registerEvents = useRegisterEvents()
59+
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
60+
const [tooltip, setTooltip] = useState<{
61+
nodeId: string
62+
position: { x: number; y: number }
63+
} | null>(null)
64+
65+
useEffect(() => {
66+
registerEvents({
67+
enterNode: ({ node, event }) => {
68+
setHoveredNode(node)
69+
setTooltip({ nodeId: node, position: { x: event.x, y: event.y } })
70+
const el = sigma.getContainer()
71+
if (el) el.style.cursor = 'pointer'
72+
},
73+
leaveNode: () => {
74+
setHoveredNode(null)
75+
setTooltip(null)
76+
const el = sigma.getContainer()
77+
if (el) el.style.cursor = 'default'
78+
},
79+
clickNode: ({ node }) => onSelectFile?.(node),
80+
doubleClickNode: ({ node }) => {
81+
const pos = sigma.getNodeDisplayData(node)
82+
if (pos) {
83+
sigma.getCamera().animate(
84+
{ x: pos.x, y: pos.y, ratio: 0.15 },
85+
{ duration: 400 }
86+
)
87+
}
88+
},
89+
})
90+
}, [registerEvents, sigma, onSelectFile])
91+
92+
// highlight hovered node neighborhood, fade everything else
93+
useEffect(() => {
94+
const graph = sigma.getGraph()
95+
96+
if (hoveredNode && graph.hasNode(hoveredNode)) {
97+
const neighbors = new Set(graph.neighbors(hoveredNode))
98+
neighbors.add(hoveredNode)
99+
100+
sigma.setSetting('nodeReducer', (node, data) => {
101+
if (neighbors.has(node)) return { ...data, zIndex: 1 }
102+
return { ...data, color: '#1f2937', label: '', zIndex: 0 }
103+
})
104+
sigma.setSetting('edgeReducer', (edge, data) => {
105+
const src = graph.source(edge)
106+
const tgt = graph.target(edge)
107+
if (neighbors.has(src) && neighbors.has(tgt)) {
108+
return { ...data, color: '#6366f1', size: 1.5 }
109+
}
110+
return { ...data, hidden: true }
111+
})
112+
} else {
113+
sigma.setSetting('nodeReducer', null)
114+
sigma.setSetting('edgeReducer', null)
115+
}
116+
}, [hoveredNode, sigma])
117+
118+
// build tooltip data from graph attributes
119+
const tooltipData = (() => {
120+
if (!tooltip) return null
121+
const graph = sigma.getGraph()
122+
if (!graph.hasNode(tooltip.nodeId)) return null
123+
const a = graph.getNodeAttributes(tooltip.nodeId)
124+
return {
125+
nodeId: tooltip.nodeId,
126+
label: (a.label as string) || tooltip.nodeId,
127+
directory: (a.directory as string) || '',
128+
imports: (a.imports as number) || 0,
129+
dependents: (a.dependents as number) || 0,
130+
riskLevel: (a.riskLevel as string) || 'low',
131+
position: tooltip.position,
132+
}
133+
})()
134+
135+
return tooltipData ? <NodeTooltip {...tooltipData} /> : null
136+
}
137+
11138
export function GraphView({ data, onSelectFile }: GraphViewProps) {
139+
const graph = useGraphData(data)
140+
141+
if (!graph || graph.order === 0) {
142+
return (
143+
<div className="flex items-center justify-center h-[600px] text-muted-foreground">
144+
No graph data available
145+
</div>
146+
)
147+
}
148+
12149
return (
13-
<div className="flex items-center justify-center h-[600px] text-muted-foreground">
14-
GraphView placeholder -- {data.nodes?.length || 0} nodes ready for Sigma.js rendering
150+
<div className="relative h-[700px] bg-zinc-950 rounded-lg overflow-hidden">
151+
<SigmaContainer
152+
style={{ height: '100%', width: '100%' }}
153+
settings={SIGMA_SETTINGS}
154+
>
155+
<LoadAndDisplay graph={graph} />
156+
<Interactions onSelectFile={onSelectFile} />
157+
</SigmaContainer>
158+
159+
<div className="absolute bottom-4 right-4 bg-zinc-900/90 border border-zinc-700 rounded-lg p-3 text-xs">
160+
<div className="text-zinc-400 font-medium mb-2">Legend</div>
161+
<div className="space-y-1 text-zinc-500">
162+
<div>Node size = dependents count</div>
163+
<div>Node color = module cluster</div>
164+
<div>Hover to highlight neighbors</div>
165+
<div>Click to analyze impact</div>
166+
</div>
167+
</div>
15168
</div>
16169
)
17170
}

0 commit comments

Comments
 (0)