diff options
Diffstat (limited to 'packages/ui/memory-graph/hooks/use-graph-interactions.ts')
| -rw-r--r-- | packages/ui/memory-graph/hooks/use-graph-interactions.ts | 564 |
1 files changed, 564 insertions, 0 deletions
diff --git a/packages/ui/memory-graph/hooks/use-graph-interactions.ts b/packages/ui/memory-graph/hooks/use-graph-interactions.ts new file mode 100644 index 00000000..ec44e83e --- /dev/null +++ b/packages/ui/memory-graph/hooks/use-graph-interactions.ts @@ -0,0 +1,564 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { GRAPH_SETTINGS } from "../constants"; +import type { GraphNode } from "../types"; + +export function useGraphInteractions( + variant: "console" | "consumer" = "console", +) { + const settings = GRAPH_SETTINGS[variant]; + + const [panX, setPanX] = useState(settings.initialPanX); + const [panY, setPanY] = useState(settings.initialPanY); + const [zoom, setZoom] = useState(settings.initialZoom); + const [isPanning, setIsPanning] = useState(false); + const [panStart, setPanStart] = useState({ x: 0, y: 0 }); + const [hoveredNode, setHoveredNode] = useState<string | null>(null); + const [selectedNode, setSelectedNode] = useState<string | null>(null); + const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null); + const [dragStart, setDragStart] = useState({ + x: 0, + y: 0, + nodeX: 0, + nodeY: 0, + }); + const [nodePositions, setNodePositions] = useState< + Map<string, { x: number; y: number }> + >(new Map()); + + // Touch gesture state + const [touchState, setTouchState] = useState<{ + touches: { id: number; x: number; y: number }[]; + lastDistance: number; + lastCenter: { x: number; y: number }; + isGesturing: boolean; + }>({ + touches: [], + lastDistance: 0, + lastCenter: { x: 0, y: 0 }, + isGesturing: false, + }); + + // Animation state for smooth transitions + const animationRef = useRef<number | null>(null); + const [isAnimating, setIsAnimating] = useState(false); + + // Smooth animation helper + const animateToViewState = useCallback( + ( + targetPanX: number, + targetPanY: number, + targetZoom: number, + duration = 300, + ) => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + + const startPanX = panX; + const startPanY = panY; + const startZoom = zoom; + const startTime = Date.now(); + + setIsAnimating(true); + + const animate = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / duration, 1); + + // Ease out cubic function for smooth transitions + const easeOut = 1 - (1 - progress) ** 3; + + const currentPanX = startPanX + (targetPanX - startPanX) * easeOut; + const currentPanY = startPanY + (targetPanY - startPanY) * easeOut; + const currentZoom = startZoom + (targetZoom - startZoom) * easeOut; + + setPanX(currentPanX); + setPanY(currentPanY); + setZoom(currentZoom); + + if (progress < 1) { + animationRef.current = requestAnimationFrame(animate); + } else { + setIsAnimating(false); + animationRef.current = null; + } + }; + + animate(); + }, + [panX, panY, zoom], + ); + + // Node drag handlers + const handleNodeDragStart = useCallback( + (nodeId: string, e: React.MouseEvent, nodes?: GraphNode[]) => { + const node = nodes?.find((n) => n.id === nodeId); + if (!node) return; + + setDraggingNodeId(nodeId); + setDragStart({ + x: e.clientX, + y: e.clientY, + nodeX: node.x, + nodeY: node.y, + }); + }, + [], + ); + + const handleNodeDragMove = useCallback( + (e: React.MouseEvent) => { + if (!draggingNodeId) return; + + const deltaX = (e.clientX - dragStart.x) / zoom; + const deltaY = (e.clientY - dragStart.y) / zoom; + + const newX = dragStart.nodeX + deltaX; + const newY = dragStart.nodeY + deltaY; + + setNodePositions((prev) => + new Map(prev).set(draggingNodeId, { x: newX, y: newY }), + ); + }, + [draggingNodeId, dragStart, zoom], + ); + + const handleNodeDragEnd = useCallback(() => { + setDraggingNodeId(null); + }, []); + + // Pan handlers + const handlePanStart = useCallback( + (e: React.MouseEvent) => { + setIsPanning(true); + setPanStart({ x: e.clientX - panX, y: e.clientY - panY }); + }, + [panX, panY], + ); + + const handlePanMove = useCallback( + (e: React.MouseEvent) => { + if (!isPanning || draggingNodeId) return; + + const newPanX = e.clientX - panStart.x; + const newPanY = e.clientY - panStart.y; + setPanX(newPanX); + setPanY(newPanY); + }, + [isPanning, panStart, draggingNodeId], + ); + + const handlePanEnd = useCallback(() => { + setIsPanning(false); + }, []); + + // Zoom handlers + const handleWheel = useCallback( + (e: React.WheelEvent) => { + // Always prevent default to stop browser navigation + e.preventDefault(); + e.stopPropagation(); + + // Handle horizontal scrolling (trackpad swipe) by converting to pan + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + // Horizontal scroll - pan the graph instead of zooming + const panDelta = e.deltaX * 0.5; + setPanX((prev) => prev - panDelta); + return; + } + + // Vertical scroll - zoom behavior + const delta = e.deltaY > 0 ? 0.97 : 1.03; + const newZoom = Math.max(0.05, Math.min(3, zoom * delta)); + + // Get mouse position relative to the viewport + let mouseX = e.clientX; + let mouseY = e.clientY; + + // Try to get the container bounds to make coordinates relative to the graph container + const target = e.currentTarget; + if (target && "getBoundingClientRect" in target) { + const rect = target.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + mouseY = e.clientY - rect.top; + } + + // Calculate the world position of the mouse cursor + const worldX = (mouseX - panX) / zoom; + const worldY = (mouseY - panY) / zoom; + + // Calculate new pan to keep the mouse position stationary + const newPanX = mouseX - worldX * newZoom; + const newPanY = mouseY - worldY * newZoom; + + setZoom(newZoom); + setPanX(newPanX); + setPanY(newPanY); + }, + [zoom, panX, panY], + ); + + const zoomIn = useCallback( + (centerX?: number, centerY?: number, animate = true) => { + const zoomFactor = 1.2; + const newZoom = Math.min(3, zoom * zoomFactor); // Increased max zoom to 3x + + if (centerX !== undefined && centerY !== undefined) { + // Mouse-centered zoom for programmatic zoom in + const worldX = (centerX - panX) / zoom; + const worldY = (centerY - panY) / zoom; + const newPanX = centerX - worldX * newZoom; + const newPanY = centerY - worldY * newZoom; + + if (animate && !isAnimating) { + animateToViewState(newPanX, newPanY, newZoom, 200); + } else { + setZoom(newZoom); + setPanX(newPanX); + setPanY(newPanY); + } + } else { + if (animate && !isAnimating) { + animateToViewState(panX, panY, newZoom, 200); + } else { + setZoom(newZoom); + } + } + }, + [zoom, panX, panY, isAnimating, animateToViewState], + ); + + const zoomOut = useCallback( + (centerX?: number, centerY?: number, animate = true) => { + const zoomFactor = 0.8; + const newZoom = Math.max(0.05, zoom * zoomFactor); // Decreased min zoom to 0.05x + + if (centerX !== undefined && centerY !== undefined) { + // Mouse-centered zoom for programmatic zoom out + const worldX = (centerX - panX) / zoom; + const worldY = (centerY - panY) / zoom; + const newPanX = centerX - worldX * newZoom; + const newPanY = centerY - worldY * newZoom; + + if (animate && !isAnimating) { + animateToViewState(newPanX, newPanY, newZoom, 200); + } else { + setZoom(newZoom); + setPanX(newPanX); + setPanY(newPanY); + } + } else { + if (animate && !isAnimating) { + animateToViewState(panX, panY, newZoom, 200); + } else { + setZoom(newZoom); + } + } + }, + [zoom, panX, panY, isAnimating, animateToViewState], + ); + + const resetView = useCallback(() => { + setPanX(settings.initialPanX); + setPanY(settings.initialPanY); + setZoom(settings.initialZoom); + setNodePositions(new Map()); + }, [settings]); + + // Auto-fit graph to viewport + const autoFitToViewport = useCallback( + ( + nodes: GraphNode[], + viewportWidth: number, + viewportHeight: number, + options?: { occludedRightPx?: number; animate?: boolean }, + ) => { + if (nodes.length === 0) return; + + // Find the bounds of all nodes + let minX = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + + nodes.forEach((node) => { + minX = Math.min(minX, node.x - node.size / 2); + maxX = Math.max(maxX, node.x + node.size / 2); + minY = Math.min(minY, node.y - node.size / 2); + maxY = Math.max(maxY, node.y + node.size / 2); + }); + + // Calculate the center of the content + const contentCenterX = (minX + maxX) / 2; + const contentCenterY = (minY + maxY) / 2; + + // Calculate the size of the content + const contentWidth = maxX - minX; + const contentHeight = maxY - minY; + + // Add padding (20% on each side) + const paddingFactor = 1.4; + const paddedWidth = contentWidth * paddingFactor; + const paddedHeight = contentHeight * paddingFactor; + + // Account for occluded area on the right (e.g., chat panel) + const occludedRightPx = Math.max(0, options?.occludedRightPx ?? 0); + const availableWidth = Math.max(1, viewportWidth - occludedRightPx); + + // Calculate the zoom needed to fit the content within available width + const zoomX = availableWidth / paddedWidth; + const zoomY = viewportHeight / paddedHeight; + const newZoom = Math.min(Math.max(0.05, Math.min(zoomX, zoomY)), 3); + + // Calculate pan to center the content within available area + const availableCenterX = availableWidth / 2; + const newPanX = availableCenterX - contentCenterX * newZoom; + const newPanY = viewportHeight / 2 - contentCenterY * newZoom; + + // Apply the new view (optional animation) + if (options?.animate) { + const steps = 8; + const durationMs = 160; // snappy + const intervalMs = Math.max(1, Math.floor(durationMs / steps)); + const startZoom = zoom; + const startPanX = panX; + const startPanY = panY; + let i = 0; + const ease = (t: number) => 1 - (1 - t) ** 2; // ease-out quad + const timer = setInterval(() => { + i++; + const t = ease(i / steps); + setZoom(startZoom + (newZoom - startZoom) * t); + setPanX(startPanX + (newPanX - startPanX) * t); + setPanY(startPanY + (newPanY - startPanY) * t); + if (i >= steps) clearInterval(timer); + }, intervalMs); + } else { + setZoom(newZoom); + setPanX(newPanX); + setPanY(newPanY); + } + }, + [zoom, panX, panY], + ); + + // Touch gesture handlers for mobile pinch-to-zoom + const handleTouchStart = useCallback((e: React.TouchEvent) => { + const touches = Array.from(e.touches).map((touch) => ({ + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + })); + + if (touches.length >= 2) { + // Start gesture with two or more fingers + const touch1 = touches[0]!; + const touch2 = touches[1]!; + + const distance = Math.sqrt( + (touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2, + ); + + const center = { + x: (touch1.x + touch2.x) / 2, + y: (touch1.y + touch2.y) / 2, + }; + + setTouchState({ + touches, + lastDistance: distance, + lastCenter: center, + isGesturing: true, + }); + } else { + setTouchState((prev) => ({ ...prev, touches, isGesturing: false })); + } + }, []); + + const handleTouchMove = useCallback( + (e: React.TouchEvent) => { + e.preventDefault(); + + const touches = Array.from(e.touches).map((touch) => ({ + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + })); + + if (touches.length >= 2 && touchState.isGesturing) { + const touch1 = touches[0]!; + const touch2 = touches[1]!; + + const distance = Math.sqrt( + (touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2, + ); + + const center = { + x: (touch1.x + touch2.x) / 2, + y: (touch1.y + touch2.y) / 2, + }; + + // Calculate zoom change based on pinch distance change + const distanceChange = distance / touchState.lastDistance; + const newZoom = Math.max(0.05, Math.min(3, zoom * distanceChange)); + + // Get canvas bounds for center calculation + const canvas = e.currentTarget as HTMLElement; + const rect = canvas.getBoundingClientRect(); + const centerX = center.x - rect.left; + const centerY = center.y - rect.top; + + // Calculate the world position of the pinch center + const worldX = (centerX - panX) / zoom; + const worldY = (centerY - panY) / zoom; + + // Calculate new pan to keep the pinch center stationary + const newPanX = centerX - worldX * newZoom; + const newPanY = centerY - worldY * newZoom; + + // Calculate pan change based on center movement + const centerDx = center.x - touchState.lastCenter.x; + const centerDy = center.y - touchState.lastCenter.y; + + setZoom(newZoom); + setPanX(newPanX + centerDx); + setPanY(newPanY + centerDy); + + setTouchState({ + touches, + lastDistance: distance, + lastCenter: center, + isGesturing: true, + }); + } else if (touches.length === 1 && !touchState.isGesturing && isPanning) { + // Single finger pan (only if not in gesture mode) + const touch = touches[0]!; + const newPanX = touch.x - panStart.x; + const newPanY = touch.y - panStart.y; + setPanX(newPanX); + setPanY(newPanY); + } + }, + [touchState, zoom, panX, panY, isPanning, panStart], + ); + + const handleTouchEnd = useCallback((e: React.TouchEvent) => { + const touches = Array.from(e.touches).map((touch) => ({ + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + })); + + if (touches.length < 2) { + setTouchState((prev) => ({ ...prev, touches, isGesturing: false })); + } else { + setTouchState((prev) => ({ ...prev, touches })); + } + + if (touches.length === 0) { + setIsPanning(false); + } + }, []); + + // Center viewport on a specific world position (with animation) + const centerViewportOn = useCallback( + ( + worldX: number, + worldY: number, + viewportWidth: number, + viewportHeight: number, + animate = true, + ) => { + const newPanX = viewportWidth / 2 - worldX * zoom; + const newPanY = viewportHeight / 2 - worldY * zoom; + + if (animate && !isAnimating) { + animateToViewState(newPanX, newPanY, zoom, 400); + } else { + setPanX(newPanX); + setPanY(newPanY); + } + }, + [zoom, isAnimating, animateToViewState], + ); + + // Node interaction handlers + const handleNodeHover = useCallback((nodeId: string | null) => { + setHoveredNode(nodeId); + }, []); + + const handleNodeClick = useCallback( + (nodeId: string) => { + setSelectedNode(selectedNode === nodeId ? null : nodeId); + }, + [selectedNode], + ); + + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + // Calculate new zoom (zoom in by 1.5x) + const zoomFactor = 1.5; + const newZoom = Math.min(3, zoom * zoomFactor); + + // Get mouse position relative to the container + let mouseX = e.clientX; + let mouseY = e.clientY; + + // Try to get the container bounds to make coordinates relative to the graph container + const target = e.currentTarget; + if (target && "getBoundingClientRect" in target) { + const rect = target.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + mouseY = e.clientY - rect.top; + } + + // Calculate the world position of the clicked point + const worldX = (mouseX - panX) / zoom; + const worldY = (mouseY - panY) / zoom; + + // Calculate new pan to keep the clicked point in the same screen position + const newPanX = mouseX - worldX * newZoom; + const newPanY = mouseY - worldY * newZoom; + + setZoom(newZoom); + setPanX(newPanX); + setPanY(newPanY); + }, + [zoom, panX, panY], + ); + + return { + // State + panX, + panY, + zoom, + hoveredNode, + selectedNode, + draggingNodeId, + nodePositions, + // Handlers + handlePanStart, + handlePanMove, + handlePanEnd, + handleWheel, + handleNodeHover, + handleNodeClick, + handleNodeDragStart, + handleNodeDragMove, + handleNodeDragEnd, + handleDoubleClick, + // Touch handlers + handleTouchStart, + handleTouchMove, + handleTouchEnd, + // Controls + zoomIn, + zoomOut, + resetView, + autoFitToViewport, + centerViewportOn, + setSelectedNode, + }; +} |