Skip to content

Commit f8bfb48

Browse files
authored
Add panning and zooming (#765)
1 parent 39621b5 commit f8bfb48

File tree

2 files changed

+194
-86
lines changed

2 files changed

+194
-86
lines changed

extension/src/components/Graph.tsx

Lines changed: 166 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ import { updatesStore } from "../models/UpdatesModel";
1313
export function GraphVisualization() {
1414
const updates = updatesStore.updates;
1515
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 });
1623

1724
// Build graph data from updates signal using a computed
1825
const graphData = useComputed<GraphData>(() => {
@@ -122,6 +129,58 @@ export function GraphVisualization() {
122129
};
123130
});
124131

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+
125184
if (graphData.value.nodes.length === 0) {
126185
return (
127186
<div className="graph-empty">
@@ -142,7 +201,16 @@ export function GraphVisualization() {
142201

143202
return (
144203
<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+
>
146214
<svg
147215
ref={svgRef}
148216
className="graph-svg"
@@ -164,96 +232,109 @@ export function GraphVisualization() {
164232
</marker>
165233
</defs>
166234

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+
);
201247

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"
235318
>
319+
{displayName}
236320
{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>
254326
</g>
255327
</svg>
256328

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+
257338
{/* Legend */}
258339
<div className="graph-legend">
259340
<div className="legend-item">

extension/styles/panel.css

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,8 @@ body {
413413
flex: 1;
414414
position: relative;
415415
background: #fafafa;
416-
overflow: auto;
416+
overflow: hidden;
417+
user-select: none;
417418
}
418419

419420
.graph-svg {
@@ -422,6 +423,32 @@ body {
422423
min-height: 500px;
423424
}
424425

426+
.graph-reset-button {
427+
position: absolute;
428+
top: 16px;
429+
left: 16px;
430+
background: white;
431+
border: 1px solid #e0e0e0;
432+
border-radius: 4px;
433+
padding: 8px 12px;
434+
font-size: 12px;
435+
font-weight: 500;
436+
cursor: pointer;
437+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
438+
transition: all 0.2s;
439+
z-index: 10;
440+
}
441+
442+
.graph-reset-button:hover {
443+
background: #f5f5f5;
444+
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
445+
}
446+
447+
.graph-reset-button:active {
448+
background: #eeeeee;
449+
transform: translateY(1px);
450+
}
451+
425452
.graph-node {
426453
cursor: pointer;
427454
transition: all 0.2s;

0 commit comments

Comments
 (0)