|
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 |
3 | 4 |
|
| 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' |
4 | 16 | import type { DependencyApiResponse } from '../types' |
| 17 | +import type Graph from 'graphology' |
5 | 18 |
|
6 | 19 | interface GraphViewProps { |
7 | 20 | data: DependencyApiResponse |
8 | 21 | onSelectFile?: (filePath: string) => void |
9 | 22 | } |
10 | 23 |
|
| 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 | + |
11 | 138 | 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 | + |
12 | 149 | 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> |
15 | 168 | </div> |
16 | 169 | ) |
17 | 170 | } |
0 commit comments