Skip to content

Commit e7212e3

Browse files
committed
switch to cytoscape
1 parent 918d1a4 commit e7212e3

4 files changed

Lines changed: 147 additions & 75 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ jspm_packages/
8585
ehthumbs.db
8686
Thumbs.db
8787

88+
# IDE
89+
.idea/
90+
8891
# Local Claude workspace settings
8992
.claude/
9093

app/page.js

Lines changed: 118 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import dynamic from 'next/dynamic'
66
import ReactMarkdown from 'react-markdown'
77
import { NEGATIVE_FEEDBACK_REASON_CODES } from '../lib/feedback.js'
88

9-
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false })
9+
const cytoscape = typeof window !== 'undefined' ? require('cytoscape') : null
1010

1111
const FEEDBACK_REASON_LABELS = {
1212
helpful: 'Helpful',
@@ -55,7 +55,8 @@ function getGraphNodeRole(stats = {}, directed = true) {
5555

5656
const BasicGraphView = memo(function BasicGraphView({ graph }) {
5757
const containerRef = useRef(null)
58-
const fgRef = useRef(null)
58+
const graphRef = useRef(null)
59+
const cyRef = useRef(null)
5960
const [dimensions, setDimensions] = useState({ width: 640, height: 400 })
6061

6162
const nodes = useMemo(() => (Array.isArray(graph?.nodes) ? graph.nodes : []), [graph?.nodes])
@@ -146,30 +147,36 @@ const BasicGraphView = memo(function BasicGraphView({ graph }) {
146147
}
147148
}, [nodes, edges, isDirected])
148149

149-
const graphData = useMemo(() => {
150+
const elements = useMemo(() => {
150151
const nodeIds = new Set(nodes.map(n => String(n.id)))
151-
return {
152-
nodes: nodes.map(n => {
153-
const id = String(n.id)
154-
const visualGroup = visualGrouping.byNodeId[id] || {}
155-
return {
152+
const maxWeight = Math.max(1, ...edges.map(e => Number(e.weight) || 1))
153+
const cyNodes = nodes.map(n => {
154+
const id = String(n.id)
155+
const visualGroup = visualGrouping.byNodeId[id] || {}
156+
return {
157+
data: {
156158
id,
157159
label: n.label || n.id,
158160
group: visualGroup.label || normalizeGraphGroup(n.group),
159161
originalGroup: normalizeGraphGroup(n.group),
160162
color: visualGroup.color || n.color || GRAPH_PALETTE[hashString(n.label || n.id) % GRAPH_PALETTE.length],
161-
size: n.size || 1
163+
size: Math.max(20, 20 + (n.size || 1) * 10)
162164
}
163-
}),
164-
links: edges
165-
.filter(e => nodeIds.has(String(e.source)) && nodeIds.has(String(e.target)))
166-
.map(e => ({
165+
}
166+
})
167+
const cyEdges = edges
168+
.filter(e => nodeIds.has(String(e.source)) && nodeIds.has(String(e.target)))
169+
.map((e, i) => ({
170+
data: {
171+
id: `e${i}`,
167172
source: String(e.source),
168173
target: String(e.target),
169174
label: e.label || (Number.isFinite(Number(e.weight)) ? String(e.weight) : ''),
170-
weight: Number(e.weight) || 1
171-
}))
172-
}
175+
weight: Number(e.weight) || 1,
176+
width: Math.max(1, 1 + ((Number(e.weight) || 1) / maxWeight) * 4)
177+
}
178+
}))
179+
return [...cyNodes, ...cyEdges]
173180
}, [nodes, edges, visualGrouping])
174181

175182
// Measure container width
@@ -185,17 +192,102 @@ const BasicGraphView = memo(function BasicGraphView({ graph }) {
185192
return () => ro.disconnect()
186193
}, [])
187194

188-
// Zoom to fit after initial layout settles
195+
// Initialize and update Cytoscape instance
196+
useEffect(() => {
197+
if (!graphRef.current || !cytoscape || elements.length === 0) return
198+
199+
if (cyRef.current) {
200+
cyRef.current.destroy()
201+
}
202+
203+
const cy = cytoscape({
204+
container: graphRef.current,
205+
elements: elements,
206+
style: [
207+
{
208+
selector: 'node',
209+
style: {
210+
'background-color': 'data(color)',
211+
'label': 'data(label)',
212+
'width': 'data(size)',
213+
'height': 'data(size)',
214+
'font-size': '11px',
215+
'color': '#e5e7eb',
216+
'text-valign': 'bottom',
217+
'text-margin-y': 5,
218+
'text-outline-color': '#0f0f12',
219+
'text-outline-width': 2,
220+
'border-width': 1,
221+
'border-color': '#1a1a2e'
222+
}
223+
},
224+
{
225+
selector: 'edge',
226+
style: {
227+
'width': 'data(width)',
228+
'line-color': '#4b5563',
229+
'target-arrow-color': '#4b5563',
230+
'target-arrow-shape': isDirected ? 'triangle' : 'none',
231+
'curve-style': 'bezier',
232+
'label': 'data(label)',
233+
'font-size': '9px',
234+
'color': '#8f9aad',
235+
'text-outline-color': '#0f0f12',
236+
'text-outline-width': 1.5,
237+
'text-rotation': 'autorotate'
238+
}
239+
},
240+
{
241+
selector: 'node:selected',
242+
style: {
243+
'border-width': 3,
244+
'border-color': '#fff'
245+
}
246+
},
247+
{
248+
selector: 'edge:selected',
249+
style: {
250+
'line-color': '#9ecbff',
251+
'target-arrow-color': '#9ecbff',
252+
'width': 4
253+
}
254+
}
255+
],
256+
layout: {
257+
name: isDirected ? 'breadthfirst' : 'cose',
258+
directed: isDirected,
259+
spacingFactor: 1.2,
260+
animate: true,
261+
animationDuration: 500,
262+
padding: 30,
263+
nodeDimensionsIncludeLabels: true,
264+
...(isDirected ? {} : {
265+
idealEdgeLength: 100,
266+
nodeRepulsion: 4500,
267+
gravity: 0.25
268+
})
269+
},
270+
userZoomingEnabled: true,
271+
userPanningEnabled: true,
272+
boxSelectionEnabled: false
273+
})
274+
275+
cy.on('layoutstop', () => cy.fit(undefined, 30))
276+
cyRef.current = cy
277+
278+
return () => {
279+
cy.destroy()
280+
cyRef.current = null
281+
}
282+
}, [elements, isDirected])
283+
284+
// Resize cytoscape when dimensions change
189285
useEffect(() => {
190-
const timer = setTimeout(() => {
191-
if (fgRef.current) fgRef.current.zoomToFit(300, 40)
192-
}, 800)
193-
return () => clearTimeout(timer)
194-
}, [graphData])
286+
if (cyRef.current) cyRef.current.resize()
287+
}, [dimensions])
195288

196289
if (nodes.length === 0 || edges.length === 0) return null
197290

198-
const maxWeight = Math.max(1, ...graphData.links.map(l => l.weight))
199291
const legendEntries = visualGrouping.legend
200292

201293
return (
@@ -232,58 +324,9 @@ const BasicGraphView = memo(function BasicGraphView({ graph }) {
232324
))}
233325
</div>
234326
)}
235-
<ForceGraph2D
236-
ref={fgRef}
237-
graphData={graphData}
238-
width={dimensions.width - 20}
239-
height={dimensions.height}
240-
backgroundColor="#0f0f12"
241-
nodeRelSize={6}
242-
nodeVal={n => Math.max(1, (n.size || 1) * 1.5)}
243-
nodeColor={n => n.color}
244-
nodeLabel={n => {
245-
const details = []
246-
if (n.originalGroup) details.push(`type: ${n.originalGroup}`)
247-
if (visualGrouping.useStructuralColoring && n.group && n.group !== n.originalGroup) {
248-
details.push(`role: ${n.group}`)
249-
}
250-
return details.length > 0 ? `${n.label} (${details.join('; ')})` : n.label
251-
}}
252-
nodeCanvasObject={(node, ctx, globalScale) => {
253-
const r = Math.max(3, 4 + (node.size || 1) * 2)
254-
ctx.beginPath()
255-
ctx.arc(node.x, node.y, r, 0, 2 * Math.PI)
256-
ctx.fillStyle = node.color
257-
ctx.fill()
258-
ctx.strokeStyle = '#1a1a2e'
259-
ctx.lineWidth = 0.5
260-
ctx.stroke()
261-
// Draw label when zoomed in enough
262-
if (globalScale > 0.7) {
263-
const label = node.label || node.id
264-
const fontSize = Math.max(3, 10 / globalScale)
265-
ctx.font = `${fontSize}px sans-serif`
266-
ctx.textAlign = 'center'
267-
ctx.textBaseline = 'top'
268-
ctx.fillStyle = '#e5e7eb'
269-
ctx.fillText(label, node.x, node.y + r + 2)
270-
}
271-
}}
272-
linkColor={() => '#4b5563'}
273-
linkWidth={link => Math.max(0.5, 1 + (link.weight / maxWeight) * 3)}
274-
linkDirectionalArrowLength={isDirected ? 5 : 0}
275-
linkDirectionalArrowRelPos={1}
276-
linkLabel={link => link.label}
277-
linkCurvature={link => {
278-
// Curve parallel edges between same node pairs
279-
const key = [link.source?.id || link.source, link.target?.id || link.target].sort().join('-')
280-
const rev = [link.target?.id || link.target, link.source?.id || link.source].sort().join('-')
281-
return key === rev ? 0 : 0.15
282-
}}
283-
d3VelocityDecay={0.3}
284-
cooldownTicks={80}
285-
enableZoomInteraction={true}
286-
enablePanInteraction={true}
327+
<div
328+
ref={graphRef}
329+
style={{ width: dimensions.width - 20, height: dimensions.height, backgroundColor: '#0f0f12' }}
287330
/>
288331
</div>
289332
)

package-lock.json

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

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
"dependencies": {
1212
"@modelcontextprotocol/sdk": "^1.26.0",
1313
"axios": "^1.13.5",
14+
"cytoscape": "^3.33.1",
1415
"eventsource": "^4.1.0",
1516
"next": "14.0.0",
1617
"react": "^18",
18+
"react-cytoscapejs": "^2.0.0",
1719
"react-dom": "^18",
1820
"react-force-graph-2d": "^1.29.1",
1921
"react-markdown": "^10.1.0"

0 commit comments

Comments
 (0)