@@ -13,6 +13,13 @@ import { updatesStore } from "../models/UpdatesModel";
13
13
export function GraphVisualization ( ) {
14
14
const updates = updatesStore . updates ;
15
15
const svgRef = useRef < SVGSVGElement > ( null ) ;
16
+ const containerRef = useRef < HTMLDivElement > ( null ) ;
17
+
18
+ // Pan and zoom state using signals
19
+ const panOffset = useSignal ( { x : 0 , y : 0 } ) ;
20
+ const zoom = useSignal ( 1 ) ;
21
+ const isPanning = useSignal ( false ) ;
22
+ const startPan = useSignal ( { x : 0 , y : 0 } ) ;
16
23
17
24
// Build graph data from updates signal using a computed
18
25
const graphData = useComputed < GraphData > ( ( ) => {
@@ -122,6 +129,58 @@ export function GraphVisualization() {
122
129
} ;
123
130
} ) ;
124
131
132
+ // Mouse event handlers for panning
133
+ const handleMouseDown = ( e : MouseEvent ) => {
134
+ if ( e . button !== 0 ) return ; // Only left mouse button
135
+ isPanning . value = true ;
136
+ startPan . value = {
137
+ x : e . clientX - panOffset . value . x ,
138
+ y : e . clientY - panOffset . value . y ,
139
+ } ;
140
+ } ;
141
+
142
+ const handleMouseMove = ( e : MouseEvent ) => {
143
+ if ( ! isPanning . value ) return ;
144
+ panOffset . value = {
145
+ x : e . clientX - startPan . value . x ,
146
+ y : e . clientY - startPan . value . y ,
147
+ } ;
148
+ } ;
149
+
150
+ const handleMouseUp = ( ) => {
151
+ isPanning . value = false ;
152
+ } ;
153
+
154
+ const handleWheel = ( e : WheelEvent ) => {
155
+ e . preventDefault ( ) ;
156
+
157
+ const container = containerRef . current ;
158
+ if ( ! container ) return ;
159
+
160
+ // Get mouse position relative to container
161
+ const rect = container . getBoundingClientRect ( ) ;
162
+ const mouseX = e . clientX - rect . left ;
163
+ const mouseY = e . clientY - rect . top ;
164
+
165
+ // Calculate zoom change
166
+ const delta = e . deltaY > 0 ? 0.9 : 1.1 ;
167
+ const newZoom = Math . min ( Math . max ( 0.1 , zoom . value * delta ) , 5 ) ;
168
+
169
+ // Adjust pan offset to zoom towards mouse cursor
170
+ const zoomRatio = newZoom / zoom . value ;
171
+ panOffset . value = {
172
+ x : mouseX - ( mouseX - panOffset . value . x ) * zoomRatio ,
173
+ y : mouseY - ( mouseY - panOffset . value . y ) * zoomRatio ,
174
+ } ;
175
+
176
+ zoom . value = newZoom ;
177
+ } ;
178
+
179
+ const resetView = ( ) => {
180
+ panOffset . value = { x : 0 , y : 0 } ;
181
+ zoom . value = 1 ;
182
+ } ;
183
+
125
184
if ( graphData . value . nodes . length === 0 ) {
126
185
return (
127
186
< div className = "graph-empty" >
@@ -142,7 +201,16 @@ export function GraphVisualization() {
142
201
143
202
return (
144
203
< div className = "graph-container" >
145
- < div className = "graph-content" >
204
+ < div
205
+ ref = { containerRef }
206
+ className = "graph-content"
207
+ onMouseDown = { handleMouseDown }
208
+ onMouseMove = { handleMouseMove }
209
+ onMouseUp = { handleMouseUp }
210
+ onMouseLeave = { handleMouseUp }
211
+ onWheel = { handleWheel }
212
+ style = { { cursor : isPanning . value ? "grabbing" : "grab" } }
213
+ >
146
214
< svg
147
215
ref = { svgRef }
148
216
className = "graph-svg"
@@ -164,96 +232,109 @@ export function GraphVisualization() {
164
232
</ marker >
165
233
</ defs >
166
234
167
- { /* Links */ }
168
- < g className = "links" >
169
- { graphData . value . links . map ( ( link , index ) => {
170
- const sourceNode = graphData . value . nodes . find (
171
- n => n . id === link . source
172
- ) ;
173
- const targetNode = graphData . value . nodes . find (
174
- n => n . id === link . target
175
- ) ;
176
-
177
- if ( ! sourceNode || ! targetNode ) return null ;
178
-
179
- // Use curved paths for better visual flow
180
- const sourceX = sourceNode . x + 25 ;
181
- const sourceY = sourceNode . y ;
182
- const targetX = targetNode . x - 25 ;
183
- const targetY = targetNode . y ;
184
-
185
- const midX = sourceX + ( targetX - sourceX ) * 0.6 ;
186
- const pathData = `M ${ sourceX } ${ sourceY } Q ${ midX } ${ sourceY } ${ targetX } ${ targetY } ` ;
187
-
188
- return (
189
- < path
190
- key = { `link-${ index } ` }
191
- className = "graph-link"
192
- d = { pathData }
193
- fill = "none"
194
- stroke = "#666"
195
- strokeWidth = "2"
196
- markerEnd = "url(#arrowhead)"
197
- />
198
- ) ;
199
- } ) }
200
- </ g >
235
+ < g
236
+ transform = { `translate(${ panOffset . value . x } , ${ panOffset . value . y } ) scale(${ zoom . value } )` }
237
+ >
238
+ { /* Links */ }
239
+ < g className = "links" >
240
+ { graphData . value . links . map ( ( link , index ) => {
241
+ const sourceNode = graphData . value . nodes . find (
242
+ n => n . id === link . source
243
+ ) ;
244
+ const targetNode = graphData . value . nodes . find (
245
+ n => n . id === link . target
246
+ ) ;
201
247
202
- { /* Nodes */ }
203
- < g className = "nodes" >
204
- { graphData . value . nodes . map ( node => {
205
- const radius = node . type === "component" ? 40 : 30 ;
206
- // For circles, use a smaller character limit to fit within the circle with padding
207
- const maxChars = node . type === "component" ? 10 : 7 ;
208
- const displayName =
209
- node . name . length > maxChars
210
- ? node . name . slice ( 0 , maxChars ) + "..."
211
- : node . name ;
212
- const isTextTruncated = node . name . length > maxChars ;
213
-
214
- return (
215
- < g key = { node . id } className = "graph-node-group" >
216
- { node . type === "component" ? (
217
- // Rectangular shape for components
218
- < rect
219
- className = { `graph-node ${ node . type } ` }
220
- x = { node . x - radius }
221
- y = { node . y - 22 }
222
- width = { radius * 2 }
223
- height = { 44 }
224
- rx = "10"
225
- >
226
- { isTextTruncated && < title > { node . name } </ title > }
227
- </ rect >
228
- ) : (
229
- // Circular shape for signals/computed/effects
230
- < circle
231
- className = { `graph-node ${ node . type } ` }
232
- cx = { node . x }
233
- cy = { node . y }
234
- r = { radius }
248
+ if ( ! sourceNode || ! targetNode ) return null ;
249
+
250
+ // Use curved paths for better visual flow
251
+ const sourceX = sourceNode . x + 25 ;
252
+ const sourceY = sourceNode . y ;
253
+ const targetX = targetNode . x - 25 ;
254
+ const targetY = targetNode . y ;
255
+
256
+ const midX = sourceX + ( targetX - sourceX ) * 0.6 ;
257
+ const pathData = `M ${ sourceX } ${ sourceY } Q ${ midX } ${ sourceY } ${ targetX } ${ targetY } ` ;
258
+
259
+ return (
260
+ < path
261
+ key = { `link-${ index } ` }
262
+ className = "graph-link"
263
+ d = { pathData }
264
+ fill = "none"
265
+ stroke = "#666"
266
+ strokeWidth = "2"
267
+ markerEnd = "url(#arrowhead)"
268
+ />
269
+ ) ;
270
+ } ) }
271
+ </ g >
272
+
273
+ { /* Nodes */ }
274
+ < g className = "nodes" >
275
+ { graphData . value . nodes . map ( node => {
276
+ const radius = node . type === "component" ? 40 : 30 ;
277
+ // For circles, use a smaller character limit to fit within the circle with padding
278
+ const maxChars = node . type === "component" ? 10 : 7 ;
279
+ const displayName =
280
+ node . name . length > maxChars
281
+ ? node . name . slice ( 0 , maxChars ) + "..."
282
+ : node . name ;
283
+ const isTextTruncated = node . name . length > maxChars ;
284
+
285
+ return (
286
+ < g key = { node . id } className = "graph-node-group" >
287
+ { node . type === "component" ? (
288
+ // Rectangular shape for components
289
+ < rect
290
+ className = { `graph-node ${ node . type } ` }
291
+ x = { node . x - radius }
292
+ y = { node . y - 22 }
293
+ width = { radius * 2 }
294
+ height = { 44 }
295
+ rx = "10"
296
+ >
297
+ { isTextTruncated && < title > { node . name } </ title > }
298
+ </ rect >
299
+ ) : (
300
+ // Circular shape for signals/computed/effects
301
+ < circle
302
+ className = { `graph-node ${ node . type } ` }
303
+ cx = { node . x }
304
+ cy = { node . y }
305
+ r = { radius }
306
+ >
307
+ { isTextTruncated && < title > { node . name } </ title > }
308
+ </ circle >
309
+ ) }
310
+ < text
311
+ className = "graph-text"
312
+ x = { node . x }
313
+ y = { node . y + 4 }
314
+ textAnchor = "middle"
315
+ dominantBaseline = "middle"
316
+ fontSize = "12"
317
+ fontWeight = "500"
235
318
>
319
+ { displayName }
236
320
{ isTextTruncated && < title > { node . name } </ title > }
237
- </ circle >
238
- ) }
239
- < text
240
- className = "graph-text"
241
- x = { node . x }
242
- y = { node . y + 4 }
243
- textAnchor = "middle"
244
- dominantBaseline = "middle"
245
- fontSize = "12"
246
- fontWeight = "500"
247
- >
248
- { displayName }
249
- { isTextTruncated && < title > { node . name } </ title > }
250
- </ text >
251
- </ g >
252
- ) ;
253
- } ) }
321
+ </ text >
322
+ </ g >
323
+ ) ;
324
+ } ) }
325
+ </ g >
254
326
</ g >
255
327
</ svg >
256
328
329
+ { /* Reset view button */ }
330
+ < button
331
+ className = "graph-reset-button"
332
+ onClick = { resetView }
333
+ title = "Reset view"
334
+ >
335
+ ⟲ Reset View
336
+ </ button >
337
+
257
338
{ /* Legend */ }
258
339
< div className = "graph-legend" >
259
340
< div className = "legend-item" >
0 commit comments