diff --git a/extension/src/components/Graph.tsx b/extension/src/components/Graph.tsx index fb3d8bb7c..f28652ef3 100644 --- a/extension/src/components/Graph.tsx +++ b/extension/src/components/Graph.tsx @@ -13,6 +13,13 @@ import { updatesStore } from "../models/UpdatesModel"; export function GraphVisualization() { const updates = updatesStore.updates; const svgRef = useRef(null); + const containerRef = useRef(null); + + // Pan and zoom state using signals + const panOffset = useSignal({ x: 0, y: 0 }); + const zoom = useSignal(1); + const isPanning = useSignal(false); + const startPan = useSignal({ x: 0, y: 0 }); // Build graph data from updates signal using a computed const graphData = useComputed(() => { @@ -122,6 +129,58 @@ export function GraphVisualization() { }; }); + // Mouse event handlers for panning + const handleMouseDown = (e: MouseEvent) => { + if (e.button !== 0) return; // Only left mouse button + isPanning.value = true; + startPan.value = { + x: e.clientX - panOffset.value.x, + y: e.clientY - panOffset.value.y, + }; + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!isPanning.value) return; + panOffset.value = { + x: e.clientX - startPan.value.x, + y: e.clientY - startPan.value.y, + }; + }; + + const handleMouseUp = () => { + isPanning.value = false; + }; + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + + const container = containerRef.current; + if (!container) return; + + // Get mouse position relative to container + const rect = container.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + // Calculate zoom change + const delta = e.deltaY > 0 ? 0.9 : 1.1; + const newZoom = Math.min(Math.max(0.1, zoom.value * delta), 5); + + // Adjust pan offset to zoom towards mouse cursor + const zoomRatio = newZoom / zoom.value; + panOffset.value = { + x: mouseX - (mouseX - panOffset.value.x) * zoomRatio, + y: mouseY - (mouseY - panOffset.value.y) * zoomRatio, + }; + + zoom.value = newZoom; + }; + + const resetView = () => { + panOffset.value = { x: 0, y: 0 }; + zoom.value = 1; + }; + if (graphData.value.nodes.length === 0) { return (
@@ -142,7 +201,16 @@ export function GraphVisualization() { return (
-
+
- {/* Links */} - - {graphData.value.links.map((link, index) => { - const sourceNode = graphData.value.nodes.find( - n => n.id === link.source - ); - const targetNode = graphData.value.nodes.find( - n => n.id === link.target - ); - - if (!sourceNode || !targetNode) return null; - - // Use curved paths for better visual flow - const sourceX = sourceNode.x + 25; - const sourceY = sourceNode.y; - const targetX = targetNode.x - 25; - const targetY = targetNode.y; - - const midX = sourceX + (targetX - sourceX) * 0.6; - const pathData = `M ${sourceX} ${sourceY} Q ${midX} ${sourceY} ${targetX} ${targetY}`; - - return ( - - ); - })} - + + {/* Links */} + + {graphData.value.links.map((link, index) => { + const sourceNode = graphData.value.nodes.find( + n => n.id === link.source + ); + const targetNode = graphData.value.nodes.find( + n => n.id === link.target + ); - {/* Nodes */} - - {graphData.value.nodes.map(node => { - const radius = node.type === "component" ? 40 : 30; - // For circles, use a smaller character limit to fit within the circle with padding - const maxChars = node.type === "component" ? 10 : 7; - const displayName = - node.name.length > maxChars - ? node.name.slice(0, maxChars) + "..." - : node.name; - const isTextTruncated = node.name.length > maxChars; - - return ( - - {node.type === "component" ? ( - // Rectangular shape for components - - {isTextTruncated && {node.name}} - - ) : ( - // Circular shape for signals/computed/effects - + ); + })} + + + {/* Nodes */} + + {graphData.value.nodes.map(node => { + const radius = node.type === "component" ? 40 : 30; + // For circles, use a smaller character limit to fit within the circle with padding + const maxChars = node.type === "component" ? 10 : 7; + const displayName = + node.name.length > maxChars + ? node.name.slice(0, maxChars) + "..." + : node.name; + const isTextTruncated = node.name.length > maxChars; + + return ( + + {node.type === "component" ? ( + // Rectangular shape for components + + {isTextTruncated && {node.name}} + + ) : ( + // Circular shape for signals/computed/effects + + {isTextTruncated && {node.name}} + + )} + + {displayName} {isTextTruncated && {node.name}} - - )} - - {displayName} - {isTextTruncated && {node.name}} - - - ); - })} + + + ); + })} + + {/* Reset view button */} + + {/* Legend */}
diff --git a/extension/styles/panel.css b/extension/styles/panel.css index 81d6fb90e..205e07de7 100644 --- a/extension/styles/panel.css +++ b/extension/styles/panel.css @@ -413,7 +413,8 @@ body { flex: 1; position: relative; background: #fafafa; - overflow: auto; + overflow: hidden; + user-select: none; } .graph-svg { @@ -422,6 +423,32 @@ body { min-height: 500px; } +.graph-reset-button { + position: absolute; + top: 16px; + left: 16px; + background: white; + border: 1px solid #e0e0e0; + border-radius: 4px; + padding: 8px 12px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + transition: all 0.2s; + z-index: 10; +} + +.graph-reset-button:hover { + background: #f5f5f5; + box-shadow: 0 2px 6px rgba(0,0,0,0.15); +} + +.graph-reset-button:active { + background: #eeeeee; + transform: translateY(1px); +} + .graph-node { cursor: pointer; transition: all 0.2s;