Skip to content

Commit 1de90ed

Browse files
committed
feat: scaffold dependency graph overhaul with sigma.js + graphology
- Remove reactflow, dagre, @types/dagre - Install sigma, graphology, graphology-layout-forceatlas2, graphology-communities-louvain, @react-sigma/core - Create new DependencyGraph structure: GraphView/, MatrixView/, types.ts - Build useGraphData hook (API -> graphology Graph + ForceAtlas2 + Louvain) - Build useMatrixData hook (API -> adjacency matrix + cycle detection) - Rewrite index.tsx with Graph/Matrix tab switcher + StatsBar - Wire existing ImpactPanel + useImpactAnalysis (unchanged) - Placeholder GraphView and MatrixView components (to be implemented) Closes OPE-41, OPE-42, OPE-43, OPE-44
1 parent b42c6af commit 1de90ed

8 files changed

Lines changed: 444 additions & 687 deletions

File tree

frontend/bun.lock

Lines changed: 28 additions & 113 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,22 +24,25 @@
2424
"@radix-ui/react-switch": "^1.2.6",
2525
"@radix-ui/react-tabs": "^1.1.13",
2626
"@radix-ui/react-tooltip": "^1.2.8",
27+
"@react-sigma/core": "^5.0.6",
2728
"@supabase/supabase-js": "^2.39.0",
2829
"@tanstack/react-query": "^5.90.12",
29-
"@types/dagre": "^0.7.53",
3030
"@types/react-syntax-highlighter": "^15.5.13",
3131
"class-variance-authority": "^0.7.1",
3232
"clsx": "^2.1.1",
3333
"cmdk": "^1.1.1",
34-
"dagre": "^0.8.5",
3534
"framer-motion": "^12.29.0",
35+
"graphology": "^0.26.0",
36+
"graphology-communities-louvain": "^2.0.2",
37+
"graphology-layout-forceatlas2": "^0.10.1",
38+
"graphology-types": "^0.24.8",
3639
"lucide-react": "^0.554.0",
3740
"next-themes": "^0.4.6",
3841
"react": "^18.2.0",
3942
"react-dom": "^18.2.0",
4043
"react-router-dom": "^7.12.0",
4144
"react-syntax-highlighter": "^16.1.0",
42-
"reactflow": "^11.11.4",
45+
"sigma": "^3.0.2",
4346
"sonner": "^2.0.7",
4447
"tailwind-merge": "^3.4.0",
4548
"tailwindcss-animate": "^1.0.7"
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Sigma.js WebGL graph rendering view
2+
// TODO: OPE-45 -- full implementation with hover/click interactions
3+
4+
import type { DependencyApiResponse } from '../types'
5+
6+
interface GraphViewProps {
7+
data: DependencyApiResponse
8+
onSelectFile?: (filePath: string) => void
9+
}
10+
11+
export function GraphView({ data, onSelectFile }: GraphViewProps) {
12+
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
15+
</div>
16+
)
17+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Transforms API dependency response into a graphology Graph instance
2+
// with ForceAtlas2 layout positions and Louvain community colors
3+
4+
import { useMemo } from 'react'
5+
import Graph from 'graphology'
6+
import forceAtlas2 from 'graphology-layout-forceatlas2'
7+
import louvain from 'graphology-communities-louvain'
8+
import type { DependencyApiResponse } from '../types'
9+
10+
// Community color palette -- distinct, accessible on dark backgrounds
11+
const COMMUNITY_COLORS = [
12+
'#6366f1', // indigo
13+
'#22c55e', // green
14+
'#f59e0b', // amber
15+
'#ec4899', // pink
16+
'#06b6d4', // cyan
17+
'#f97316', // orange
18+
'#8b5cf6', // violet
19+
'#14b8a6', // teal
20+
'#ef4444', // red
21+
'#84cc16', // lime
22+
]
23+
24+
function getDirectory(filePath: string): string {
25+
const parts = filePath.split('/')
26+
return parts.length > 1 ? parts.slice(0, -1).join('/') : '.'
27+
}
28+
29+
function getRiskLevel(dependentCount: number): 'low' | 'med' | 'high' {
30+
if (dependentCount >= 4) return 'high'
31+
if (dependentCount >= 1) return 'med'
32+
return 'low'
33+
}
34+
35+
export function useGraphData(apiData: DependencyApiResponse | undefined) {
36+
return useMemo(() => {
37+
if (!apiData?.nodes?.length) return null
38+
39+
const graph = new Graph({ type: 'directed' })
40+
41+
// Build in-degree map to know dependents count per node
42+
const inDegree: Record<string, number> = {}
43+
for (const edge of apiData.edges) {
44+
inDegree[edge.target] = (inDegree[edge.target] || 0) + 1
45+
}
46+
47+
// Add nodes
48+
for (const node of apiData.nodes) {
49+
const dependents = inDegree[node.id] || 0
50+
const imports = node.import_count ?? node.imports ?? 0
51+
52+
graph.addNode(node.id, {
53+
label: node.label || node.id.split('/').pop() || node.id,
54+
size: Math.max(4, Math.min(20, 4 + dependents * 2)),
55+
directory: getDirectory(node.id),
56+
imports,
57+
dependents,
58+
riskLevel: getRiskLevel(dependents),
59+
language: node.language || 'unknown',
60+
// x/y will be set by ForceAtlas2
61+
x: Math.random() * 100,
62+
y: Math.random() * 100,
63+
})
64+
}
65+
66+
// Add edges (skip if source or target missing)
67+
for (const edge of apiData.edges) {
68+
if (graph.hasNode(edge.source) && graph.hasNode(edge.target)) {
69+
// Avoid duplicate edges
70+
if (!graph.hasEdge(edge.source, edge.target)) {
71+
graph.addEdge(edge.source, edge.target, {
72+
weight: 1,
73+
type: 'arrow',
74+
})
75+
}
76+
}
77+
}
78+
79+
// Run Louvain community detection for cluster coloring
80+
try {
81+
const communities = louvain(graph)
82+
const communityIds = [...new Set(Object.values(communities))]
83+
84+
graph.forEachNode((node) => {
85+
const communityIndex = communityIds.indexOf(communities[node])
86+
graph.setNodeAttribute(
87+
node,
88+
'color',
89+
COMMUNITY_COLORS[communityIndex % COMMUNITY_COLORS.length]
90+
)
91+
graph.setNodeAttribute(node, 'community', communities[node])
92+
})
93+
} catch {
94+
// Louvain can fail on disconnected graphs -- fall back to directory-based coloring
95+
const directories = [...new Set(graph.mapNodes((_, attrs) => attrs.directory))]
96+
graph.forEachNode((node, attrs) => {
97+
const dirIndex = directories.indexOf(attrs.directory)
98+
graph.setNodeAttribute(
99+
node,
100+
'color',
101+
COMMUNITY_COLORS[dirIndex % COMMUNITY_COLORS.length]
102+
)
103+
})
104+
}
105+
106+
// Run ForceAtlas2 for layout positions
107+
// Use synchronous version with fixed iterations for deterministic layout
108+
forceAtlas2.assign(graph, {
109+
iterations: 300,
110+
settings: {
111+
gravity: 1,
112+
scalingRatio: 10,
113+
barnesHutOptimize: graph.order > 100,
114+
barnesHutTheta: 0.5,
115+
slowDown: 5,
116+
strongGravityMode: false,
117+
adjustSizes: true,
118+
},
119+
})
120+
121+
return graph
122+
}, [apiData])
123+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Dependency Structure Matrix (DSM) view
2+
// TODO: OPE-46 -- full implementation with cycle detection + directory grouping
3+
4+
import type { DependencyApiResponse } from '../types'
5+
6+
interface MatrixViewProps {
7+
data: DependencyApiResponse
8+
onSelectFile?: (filePath: string) => void
9+
}
10+
11+
export function MatrixView({ data, onSelectFile }: MatrixViewProps) {
12+
return (
13+
<div className="flex items-center justify-center h-[600px] text-muted-foreground">
14+
MatrixView placeholder -- {data.nodes?.length || 0} files ready for DSM rendering
15+
</div>
16+
)
17+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Transforms API dependency response into a 2D adjacency matrix
2+
// for the Dependency Structure Matrix (DSM) view
3+
4+
import { useMemo } from 'react'
5+
import type { DependencyApiResponse, MatrixData } from '../types'
6+
7+
function getDirectory(filePath: string): string {
8+
const parts = filePath.split('/')
9+
return parts.length > 1 ? parts.slice(0, -1).join('/') : '.'
10+
}
11+
12+
function getShortLabel(filePath: string): string {
13+
return filePath.split('/').pop() || filePath
14+
}
15+
16+
export function useMatrixData(apiData: DependencyApiResponse | undefined): MatrixData | null {
17+
return useMemo(() => {
18+
if (!apiData?.nodes?.length) return null
19+
20+
// Sort files by directory so same-directory files are adjacent
21+
const sortedFiles = [...apiData.nodes]
22+
.map((n) => n.id)
23+
.sort((a, b) => {
24+
const dirA = getDirectory(a)
25+
const dirB = getDirectory(b)
26+
if (dirA !== dirB) return dirA.localeCompare(dirB)
27+
return a.localeCompare(b)
28+
})
29+
30+
// Build index lookup: file path -> matrix index
31+
const indexMap = new Map<string, number>()
32+
sortedFiles.forEach((file, idx) => {
33+
indexMap.set(file, idx)
34+
})
35+
36+
const size = sortedFiles.length
37+
38+
// Build adjacency matrix
39+
// matrix[row][col] = number of imports from row -> col
40+
const matrix: number[][] = Array.from({ length: size }, () =>
41+
new Array(size).fill(0)
42+
)
43+
44+
for (const edge of apiData.edges) {
45+
const sourceIdx = indexMap.get(edge.source)
46+
const targetIdx = indexMap.get(edge.target)
47+
if (sourceIdx !== undefined && targetIdx !== undefined) {
48+
matrix[sourceIdx][targetIdx] += 1
49+
}
50+
}
51+
52+
// Detect circular dependencies: both directions have imports
53+
const cycles: [number, number][] = []
54+
for (let i = 0; i < size; i++) {
55+
for (let j = i + 1; j < size; j++) {
56+
if (matrix[i][j] > 0 && matrix[j][i] > 0) {
57+
cycles.push([i, j])
58+
}
59+
}
60+
}
61+
62+
// Build directory grouping and find separator positions
63+
const directories = new Map<string, number[]>()
64+
const directorySeparators: number[] = []
65+
let prevDir = ''
66+
67+
sortedFiles.forEach((file, idx) => {
68+
const dir = getDirectory(file)
69+
if (!directories.has(dir)) {
70+
directories.set(dir, [])
71+
}
72+
directories.get(dir)!.push(idx)
73+
74+
if (dir !== prevDir && idx > 0) {
75+
directorySeparators.push(idx)
76+
}
77+
prevDir = dir
78+
})
79+
80+
const totalDeps = apiData.edges.length
81+
const totalCycles = cycles.length
82+
83+
return {
84+
labels: sortedFiles,
85+
shortLabels: sortedFiles.map(getShortLabel),
86+
matrix,
87+
directories,
88+
directorySeparators,
89+
cycles,
90+
totalDeps,
91+
totalCycles,
92+
}
93+
}, [apiData])
94+
}

0 commit comments

Comments
 (0)