diff options
| author | nexxeln <[email protected]> | 2025-12-02 18:37:24 +0000 |
|---|---|---|
| committer | nexxeln <[email protected]> | 2025-12-02 18:37:24 +0000 |
| commit | dfb0c05ab33cb20537002eaeb896e6b2ab35af25 (patch) | |
| tree | 49ecaa46903671d96f2f9ebc5af688ab2ea2c7bd /packages/memory-graph/src/hooks | |
| parent | Fix: Update discord links in README.md and CONTRIBUTING.md (#598) (diff) | |
| download | supermemory-update-memory-graph.tar.xz supermemory-update-memory-graph.zip | |
add spaces selector with search (#600)update-memory-graph
relevant files to review:
\- memory-graph.tsx
\- spaces-dropdown.tsx
\- spaces-dropdown.css.ts
Diffstat (limited to 'packages/memory-graph/src/hooks')
| -rw-r--r-- | packages/memory-graph/src/hooks/use-graph-data.ts | 231 | ||||
| -rw-r--r-- | packages/memory-graph/src/hooks/use-graph-interactions.ts | 466 |
2 files changed, 355 insertions, 342 deletions
diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts index 030eea61..526277bb 100644 --- a/packages/memory-graph/src/hooks/use-graph-data.ts +++ b/packages/memory-graph/src/hooks/use-graph-data.ts @@ -1,12 +1,12 @@ -"use client"; +"use client" import { calculateSemanticSimilarity, getConnectionVisualProps, getMagicalConnectionColor, -} from "@/lib/similarity"; -import { useMemo } from "react"; -import { colors, LAYOUT_CONSTANTS } from "@/constants"; +} from "@/lib/similarity" +import { useMemo } from "react" +import { colors, LAYOUT_CONSTANTS } from "@/constants" import type { DocumentsResponse, DocumentWithMemories, @@ -14,95 +14,106 @@ import type { GraphNode, MemoryEntry, MemoryRelation, -} from "@/types"; +} from "@/types" export function useGraphData( data: DocumentsResponse | null, selectedSpace: string, nodePositions: Map<string, { x: number; y: number }>, draggingNodeId: string | null, + memoryLimit?: number, ) { return useMemo(() => { - if (!data?.documents) return { nodes: [], edges: [] }; + if (!data?.documents) return { nodes: [], edges: [] } - const allNodes: GraphNode[] = []; - const allEdges: GraphEdge[] = []; + const allNodes: GraphNode[] = [] + const allEdges: GraphEdge[] = [] // Filter documents that have memories in selected space + // AND limit memories per document when memoryLimit is provided const filteredDocuments = data.documents - .map((doc) => ({ - ...doc, - memoryEntries: + .map((doc) => { + let memories = selectedSpace === "all" ? doc.memoryEntries : doc.memoryEntries.filter( (memory) => (memory.spaceContainerTag ?? memory.spaceId ?? "default") === selectedSpace, - ), - })) - .filter((doc) => doc.memoryEntries.length > 0); + ) + + // Apply memory limit if provided and a specific space is selected + if (selectedSpace !== "all" && memoryLimit && memoryLimit > 0) { + memories = memories.slice(0, memoryLimit) + } + + return { + ...doc, + memoryEntries: memories, + } + }) + .filter((doc) => doc.memoryEntries.length > 0) // Group documents by space for better clustering - const documentsBySpace = new Map<string, typeof filteredDocuments>(); + const documentsBySpace = new Map<string, typeof filteredDocuments>() filteredDocuments.forEach((doc) => { const docSpace = doc.memoryEntries[0]?.spaceContainerTag ?? doc.memoryEntries[0]?.spaceId ?? - "default"; + "default" if (!documentsBySpace.has(docSpace)) { - documentsBySpace.set(docSpace, []); + documentsBySpace.set(docSpace, []) } - const spaceDocsArr = documentsBySpace.get(docSpace); + const spaceDocsArr = documentsBySpace.get(docSpace) if (spaceDocsArr) { - spaceDocsArr.push(doc); + spaceDocsArr.push(doc) } - }); + }) // Enhanced Layout with Space Separation const { centerX, centerY, clusterRadius, spaceSpacing, documentSpacing } = - LAYOUT_CONSTANTS; + LAYOUT_CONSTANTS /* 1. Build DOCUMENT nodes with space-aware clustering */ - const documentNodes: GraphNode[] = []; - let spaceIndex = 0; + const documentNodes: GraphNode[] = [] + let spaceIndex = 0 documentsBySpace.forEach((spaceDocs) => { - const spaceAngle = (spaceIndex / documentsBySpace.size) * Math.PI * 2; - const spaceOffsetX = Math.cos(spaceAngle) * spaceSpacing; - const spaceOffsetY = Math.sin(spaceAngle) * spaceSpacing; - const spaceCenterX = centerX + spaceOffsetX; - const spaceCenterY = centerY + spaceOffsetY; + const spaceAngle = (spaceIndex / documentsBySpace.size) * Math.PI * 2 + const spaceOffsetX = Math.cos(spaceAngle) * spaceSpacing + const spaceOffsetY = Math.sin(spaceAngle) * spaceSpacing + const spaceCenterX = centerX + spaceOffsetX + const spaceCenterY = centerY + spaceOffsetY spaceDocs.forEach((doc, docIndex) => { // Create proper circular layout with concentric rings - const docsPerRing = 6; // Start with 6 docs in inner ring - let currentRing = 0; - let docsInCurrentRing = docsPerRing; - let totalDocsInPreviousRings = 0; + const docsPerRing = 6 // Start with 6 docs in inner ring + let currentRing = 0 + let docsInCurrentRing = docsPerRing + let totalDocsInPreviousRings = 0 // Find which ring this document belongs to while (totalDocsInPreviousRings + docsInCurrentRing <= docIndex) { - totalDocsInPreviousRings += docsInCurrentRing; - currentRing++; - docsInCurrentRing = docsPerRing + currentRing * 4; // Each ring has more docs + totalDocsInPreviousRings += docsInCurrentRing + currentRing++ + docsInCurrentRing = docsPerRing + currentRing * 4 // Each ring has more docs } // Position within the ring - const positionInRing = docIndex - totalDocsInPreviousRings; - const angleInRing = (positionInRing / docsInCurrentRing) * Math.PI * 2; + const positionInRing = docIndex - totalDocsInPreviousRings + const angleInRing = (positionInRing / docsInCurrentRing) * Math.PI * 2 // Radius increases significantly with each ring - const baseRadius = documentSpacing * 0.8; + const baseRadius = documentSpacing * 0.8 const radius = currentRing === 0 ? baseRadius - : baseRadius + currentRing * documentSpacing * 1.2; + : baseRadius + currentRing * documentSpacing * 1.2 - const defaultX = spaceCenterX + Math.cos(angleInRing) * radius; - const defaultY = spaceCenterY + Math.sin(angleInRing) * radius; + const defaultX = spaceCenterX + Math.cos(angleInRing) * radius + const defaultY = spaceCenterY + Math.sin(angleInRing) * radius - const customPos = nodePositions.get(doc.id); + const customPos = nodePositions.get(doc.id) documentNodes.push({ id: doc.id, @@ -114,81 +125,80 @@ export function useGraphData( color: colors.document.primary, isHovered: false, isDragging: draggingNodeId === doc.id, - } satisfies GraphNode); - }); + } satisfies GraphNode) + }) - spaceIndex++; - }); + spaceIndex++ + }) /* 2. Gentle document collision avoidance with dampening */ - const minDocDist = LAYOUT_CONSTANTS.minDocDist; + const minDocDist = LAYOUT_CONSTANTS.minDocDist // Reduced iterations and gentler repulsion for smoother movement for (let iter = 0; iter < 2; iter++) { documentNodes.forEach((nodeA) => { documentNodes.forEach((nodeB) => { - if (nodeA.id >= nodeB.id) return; + if (nodeA.id >= nodeB.id) return // Only repel documents in the same space const spaceA = (nodeA.data as DocumentWithMemories).memoryEntries[0] ?.spaceContainerTag ?? (nodeA.data as DocumentWithMemories).memoryEntries[0]?.spaceId ?? - "default"; + "default" const spaceB = (nodeB.data as DocumentWithMemories).memoryEntries[0] ?.spaceContainerTag ?? (nodeB.data as DocumentWithMemories).memoryEntries[0]?.spaceId ?? - "default"; + "default" - if (spaceA !== spaceB) return; + if (spaceA !== spaceB) return - const dx = nodeB.x - nodeA.x; - const dy = nodeB.y - nodeA.y; - const dist = Math.sqrt(dx * dx + dy * dy) || 1; + const dx = nodeB.x - nodeA.x + const dy = nodeB.y - nodeA.y + const dist = Math.sqrt(dx * dx + dy * dy) || 1 if (dist < minDocDist) { // Much gentler push with dampening - const push = (minDocDist - dist) / 8; - const dampening = Math.max(0.1, Math.min(1, dist / minDocDist)); - const smoothPush = push * dampening * 0.5; - - const nx = dx / dist; - const ny = dy / dist; - nodeA.x -= nx * smoothPush; - nodeA.y -= ny * smoothPush; - nodeB.x += nx * smoothPush; - nodeB.y += ny * smoothPush; + const push = (minDocDist - dist) / 8 + const dampening = Math.max(0.1, Math.min(1, dist / minDocDist)) + const smoothPush = push * dampening * 0.5 + + const nx = dx / dist + const ny = dy / dist + nodeA.x -= nx * smoothPush + nodeA.y -= ny * smoothPush + nodeB.x += nx * smoothPush + nodeB.y += ny * smoothPush } - }); - }); + }) + }) } - allNodes.push(...documentNodes); + allNodes.push(...documentNodes) /* 3. Add memories around documents WITH doc-memory connections */ documentNodes.forEach((docNode) => { - const memoryNodeMap = new Map<string, GraphNode>(); - const doc = docNode.data as DocumentWithMemories; + const memoryNodeMap = new Map<string, GraphNode>() + const doc = docNode.data as DocumentWithMemories doc.memoryEntries.forEach((memory, memIndex) => { - const memoryId = `${memory.id}`; - const customMemPos = nodePositions.get(memoryId); + const memoryId = `${memory.id}` + const customMemPos = nodePositions.get(memoryId) - const clusterAngle = - (memIndex / doc.memoryEntries.length) * Math.PI * 2; - const variation = Math.sin(memIndex * 2.5) * 0.3 + 0.7; - const distance = clusterRadius * variation; + const clusterAngle = (memIndex / doc.memoryEntries.length) * Math.PI * 2 + const variation = Math.sin(memIndex * 2.5) * 0.3 + 0.7 + const distance = clusterRadius * variation const seed = - memIndex * 12345 + Number.parseInt(docNode.id.slice(0, 6), 36); - const offsetX = Math.sin(seed) * 0.5 * 40; - const offsetY = Math.cos(seed) * 0.5 * 40; + memIndex * 12345 + Number.parseInt(docNode.id.slice(0, 6), 36) + const offsetX = Math.sin(seed) * 0.5 * 40 + const offsetY = Math.cos(seed) * 0.5 * 40 const defaultMemX = - docNode.x + Math.cos(clusterAngle) * distance + offsetX; + docNode.x + Math.cos(clusterAngle) * distance + offsetX const defaultMemY = - docNode.y + Math.sin(clusterAngle) * distance + offsetY; + docNode.y + Math.sin(clusterAngle) * distance + offsetY if (!memoryNodeMap.has(memoryId)) { const memoryNode: GraphNode = { @@ -204,9 +214,9 @@ export function useGraphData( color: colors.memory.primary, isHovered: false, isDragging: draggingNodeId === memoryId, - }; - memoryNodeMap.set(memoryId, memoryNode); - allNodes.push(memoryNode); + } + memoryNodeMap.set(memoryId, memoryNode) + allNodes.push(memoryNode) } // Create doc-memory edge with similarity @@ -218,23 +228,23 @@ export function useGraphData( visualProps: getConnectionVisualProps(1), color: colors.connection.memory, edgeType: "doc-memory", - }); - }); - }); + }) + }) + }) // Build mapping of memoryId -> nodeId for version chains - const memNodeIdMap = new Map<string, string>(); + const memNodeIdMap = new Map<string, string>() allNodes.forEach((n) => { if (n.type === "memory") { - memNodeIdMap.set((n.data as MemoryEntry).id, n.id); + memNodeIdMap.set((n.data as MemoryEntry).id, n.id) } - }); + }) // Add version-chain edges (old -> new) data.documents.forEach((doc) => { doc.memoryEntries.forEach((mem: MemoryEntry) => { // Support both new object structure and legacy array/single parent fields - let parentRelations: Record<string, MemoryRelation> = {}; + let parentRelations: Record<string, MemoryRelation> = {} if ( mem.memoryRelations && @@ -242,18 +252,21 @@ export function useGraphData( mem.memoryRelations.length > 0 ) { // Convert array to Record - parentRelations = mem.memoryRelations.reduce((acc, rel) => { - acc[rel.targetMemoryId] = rel.relationType; - return acc; - }, {} as Record<string, MemoryRelation>); + parentRelations = mem.memoryRelations.reduce( + (acc, rel) => { + acc[rel.targetMemoryId] = rel.relationType + return acc + }, + {} as Record<string, MemoryRelation>, + ) } else if (mem.parentMemoryId) { parentRelations = { [mem.parentMemoryId]: "updates" as MemoryRelation, - }; + } } Object.entries(parentRelations).forEach(([pid, relationType]) => { - const fromId = memNodeIdMap.get(pid); - const toId = memNodeIdMap.get(mem.id); + const fromId = memNodeIdMap.get(pid) + const toId = memNodeIdMap.get(mem.id) if (fromId && toId) { allEdges.push({ id: `version-${fromId}-${toId}`, @@ -270,25 +283,25 @@ export function useGraphData( color: colors.relations[relationType] ?? colors.relations.updates, edgeType: "version", relationType: relationType as MemoryRelation, - }); + }) } - }); - }); - }); + }) + }) + }) // Document-to-document similarity edges for (let i = 0; i < filteredDocuments.length; i++) { - const docI = filteredDocuments[i]; - if (!docI) continue; + const docI = filteredDocuments[i] + if (!docI) continue for (let j = i + 1; j < filteredDocuments.length; j++) { - const docJ = filteredDocuments[j]; - if (!docJ) continue; + const docJ = filteredDocuments[j] + if (!docJ) continue const sim = calculateSemanticSimilarity( docI.summaryEmbedding ? Array.from(docI.summaryEmbedding) : null, docJ.summaryEmbedding ? Array.from(docJ.summaryEmbedding) : null, - ); + ) if (sim > 0.725) { allEdges.push({ id: `doc-doc-${docI.id}-${docJ.id}`, @@ -298,11 +311,11 @@ export function useGraphData( visualProps: getConnectionVisualProps(sim), color: getMagicalConnectionColor(sim, 200), edgeType: "doc-doc", - }); + }) } } } - return { nodes: allNodes, edges: allEdges }; - }, [data, selectedSpace, nodePositions, draggingNodeId]); + return { nodes: allNodes, edges: allEdges } + }, [data, selectedSpace, nodePositions, draggingNodeId, memoryLimit]) } diff --git a/packages/memory-graph/src/hooks/use-graph-interactions.ts b/packages/memory-graph/src/hooks/use-graph-interactions.ts index fa794397..94fc88ee 100644 --- a/packages/memory-graph/src/hooks/use-graph-interactions.ts +++ b/packages/memory-graph/src/hooks/use-graph-interactions.ts @@ -1,48 +1,48 @@ -"use client"; +"use client" -import { useCallback, useRef, useState } from "react"; -import { GRAPH_SETTINGS } from "@/constants"; -import type { GraphNode } from "@/types"; +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 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()); + >(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: { 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); + const animationRef = useRef<number | null>(null) + const [isAnimating, setIsAnimating] = useState(false) // Smooth animation helper const animateToViewState = useCallback( @@ -53,219 +53,219 @@ export function useGraphInteractions( duration = 300, ) => { if (animationRef.current) { - cancelAnimationFrame(animationRef.current); + cancelAnimationFrame(animationRef.current) } - const startPanX = panX; - const startPanY = panY; - const startZoom = zoom; - const startTime = Date.now(); + const startPanX = panX + const startPanY = panY + const startZoom = zoom + const startTime = Date.now() - setIsAnimating(true); + setIsAnimating(true) const animate = () => { - const elapsed = Date.now() - startTime; - const progress = Math.min(elapsed / duration, 1); + 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 easeOut = 1 - (1 - progress) ** 3 - const currentPanX = startPanX + (targetPanX - startPanX) * easeOut; - const currentPanY = startPanY + (targetPanY - startPanY) * easeOut; - const currentZoom = startZoom + (targetZoom - startZoom) * easeOut; + const currentPanX = startPanX + (targetPanX - startPanX) * easeOut + const currentPanY = startPanY + (targetPanY - startPanY) * easeOut + const currentZoom = startZoom + (targetZoom - startZoom) * easeOut - setPanX(currentPanX); - setPanY(currentPanY); - setZoom(currentZoom); + setPanX(currentPanX) + setPanY(currentPanY) + setZoom(currentZoom) if (progress < 1) { - animationRef.current = requestAnimationFrame(animate); + animationRef.current = requestAnimationFrame(animate) } else { - setIsAnimating(false); - animationRef.current = null; + setIsAnimating(false) + animationRef.current = null } - }; + } - animate(); + 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; + const node = nodes?.find((n) => n.id === nodeId) + if (!node) return - setDraggingNodeId(nodeId); + setDraggingNodeId(nodeId) setDragStart({ x: e.clientX, y: e.clientY, nodeX: node.x, nodeY: node.y, - }); + }) }, [], - ); + ) const handleNodeDragMove = useCallback( (e: React.MouseEvent) => { - if (!draggingNodeId) return; + if (!draggingNodeId) return - const deltaX = (e.clientX - dragStart.x) / zoom; - const deltaY = (e.clientY - dragStart.y) / zoom; + const deltaX = (e.clientX - dragStart.x) / zoom + const deltaY = (e.clientY - dragStart.y) / zoom - const newX = dragStart.nodeX + deltaX; - const newY = dragStart.nodeY + deltaY; + 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); - }, []); + setDraggingNodeId(null) + }, []) // Pan handlers const handlePanStart = useCallback( (e: React.MouseEvent) => { - setIsPanning(true); - setPanStart({ x: e.clientX - panX, y: e.clientY - panY }); + setIsPanning(true) + setPanStart({ x: e.clientX - panX, y: e.clientY - panY }) }, [panX, panY], - ); + ) const handlePanMove = useCallback( (e: React.MouseEvent) => { - if (!isPanning || draggingNodeId) return; + if (!isPanning || draggingNodeId) return - const newPanX = e.clientX - panStart.x; - const newPanY = e.clientY - panStart.y; - setPanX(newPanX); - setPanY(newPanY); + const newPanX = e.clientX - panStart.x + const newPanY = e.clientY - panStart.y + setPanX(newPanX) + setPanY(newPanY) }, [isPanning, panStart, draggingNodeId], - ); + ) const handlePanEnd = useCallback(() => { - setIsPanning(false); - }, []); + setIsPanning(false) + }, []) // Zoom handlers const handleWheel = useCallback( (e: React.WheelEvent) => { // Always prevent default to stop browser navigation - e.preventDefault(); - e.stopPropagation(); + 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; + 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)); + 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; + 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; + const target = e.currentTarget if (target && "getBoundingClientRect" in target) { - const rect = target.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - mouseY = e.clientY - rect.top; + 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; + 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; + const newPanX = mouseX - worldX * newZoom + const newPanY = mouseY - worldY * newZoom - setZoom(newZoom); - setPanX(newPanX); - setPanY(newPanY); + 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 + 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; + 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); + animateToViewState(newPanX, newPanY, newZoom, 200) } else { - setZoom(newZoom); - setPanX(newPanX); - setPanY(newPanY); + setZoom(newZoom) + setPanX(newPanX) + setPanY(newPanY) } } else { if (animate && !isAnimating) { - animateToViewState(panX, panY, newZoom, 200); + animateToViewState(panX, panY, newZoom, 200) } else { - setZoom(newZoom); + 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 + 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; + 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); + animateToViewState(newPanX, newPanY, newZoom, 200) } else { - setZoom(newZoom); - setPanX(newPanX); - setPanY(newPanY); + setZoom(newZoom) + setPanX(newPanX) + setPanY(newPanY) } } else { if (animate && !isAnimating) { - animateToViewState(panX, panY, newZoom, 200); + animateToViewState(panX, panY, newZoom, 200) } else { - setZoom(newZoom); + setZoom(newZoom) } } }, [zoom, panX, panY, isAnimating, animateToViewState], - ); + ) const resetView = useCallback(() => { - setPanX(settings.initialPanX); - setPanY(settings.initialPanY); - setZoom(settings.initialZoom); - setNodePositions(new Map()); - }, [settings]); + setPanX(settings.initialPanX) + setPanY(settings.initialPanY) + setZoom(settings.initialZoom) + setNodePositions(new Map()) + }, [settings]) // Auto-fit graph to viewport const autoFitToViewport = useCallback( @@ -275,74 +275,74 @@ export function useGraphInteractions( viewportHeight: number, options?: { occludedRightPx?: number; animate?: boolean }, ) => { - if (nodes.length === 0) return; + 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; + 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); - }); + 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; + const contentCenterX = (minX + maxX) / 2 + const contentCenterY = (minY + maxY) / 2 // Calculate the size of the content - const contentWidth = maxX - minX; - const contentHeight = maxY - minY; + 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; + 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); + 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); + 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; + 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 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); + 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); + setZoom(newZoom) + setPanX(newPanX) + setPanY(newPanY) } }, [zoom, panX, panY], - ); + ) // Touch gesture handlers for mobile pinch-to-zoom const handleTouchStart = useCallback((e: React.TouchEvent) => { @@ -350,117 +350,117 @@ export function useGraphInteractions( 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 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 })); + setTouchState((prev) => ({ ...prev, touches, isGesturing: false })) } - }, []); + }, []) const handleTouchMove = useCallback( (e: React.TouchEvent) => { - e.preventDefault(); + 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 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)); + 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; + 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; + 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; + 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; + const centerDx = center.x - touchState.lastCenter.x + const centerDy = center.y - touchState.lastCenter.y - setZoom(newZoom); - setPanX(newPanX + centerDx); - setPanY(newPanY + centerDy); + 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); + 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 })); + setTouchState((prev) => ({ ...prev, touches, isGesturing: false })) } else { - setTouchState((prev) => ({ ...prev, touches })); + setTouchState((prev) => ({ ...prev, touches })) } if (touches.length === 0) { - setIsPanning(false); + setIsPanning(false) } - }, []); + }, []) // Center viewport on a specific world position (with animation) const centerViewportOn = useCallback( @@ -471,63 +471,63 @@ export function useGraphInteractions( viewportHeight: number, animate = true, ) => { - const newPanX = viewportWidth / 2 - worldX * zoom; - const newPanY = viewportHeight / 2 - worldY * zoom; + const newPanX = viewportWidth / 2 - worldX * zoom + const newPanY = viewportHeight / 2 - worldY * zoom if (animate && !isAnimating) { - animateToViewState(newPanX, newPanY, zoom, 400); + animateToViewState(newPanX, newPanY, zoom, 400) } else { - setPanX(newPanX); - setPanY(newPanY); + setPanX(newPanX) + setPanY(newPanY) } }, [zoom, isAnimating, animateToViewState], - ); + ) // Node interaction handlers const handleNodeHover = useCallback((nodeId: string | null) => { - setHoveredNode(nodeId); - }, []); + setHoveredNode(nodeId) + }, []) const handleNodeClick = useCallback( (nodeId: string) => { - setSelectedNode(selectedNode === nodeId ? null : nodeId); + 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); + 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; + 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; + const target = e.currentTarget if (target && "getBoundingClientRect" in target) { - const rect = target.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - mouseY = e.clientY - rect.top; + 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; + 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; + const newPanX = mouseX - worldX * newZoom + const newPanY = mouseY - worldY * newZoom - setZoom(newZoom); - setPanX(newPanX); - setPanY(newPanY); + setZoom(newZoom) + setPanX(newPanX) + setPanY(newPanY) }, [zoom, panX, panY], - ); + ) return { // State @@ -560,5 +560,5 @@ export function useGraphInteractions( autoFitToViewport, centerViewportOn, setSelectedNode, - }; + } } |