diff options
| author | nexxeln <[email protected]> | 2025-11-19 18:57:55 +0000 |
|---|---|---|
| committer | nexxeln <[email protected]> | 2025-11-19 18:57:56 +0000 |
| commit | 5e24eb66c3ca7d2224d0d1f7837cda17015f5fcb (patch) | |
| tree | 60336fd37b41e3597065729d098877483eba73b6 /packages/memory-graph/src/hooks | |
| parent | Fix: Prevent multiple prompts while AI response is generated (fixes #538) (#583) (diff) | |
| download | archived-supermemory-5e24eb66c3ca7d2224d0d1f7837cda17015f5fcb.tar.xz archived-supermemory-5e24eb66c3ca7d2224d0d1f7837cda17015f5fcb.zip | |
package the graph (#563)shoubhit/eng-358-packaging-graph-component
includes:
- a package that contains a MemoryGraph component which handles fetching data and rendering the graph
- a playground to test the package
problems:
- the bundle size is huge
- the styles are kinda broken? we are using [https://www.npmjs.com/package/vite-plugin-libgi-inject-css](https://www.npmjs.com/package/vite-plugin-lib-inject-css) to inject the styles

Diffstat (limited to 'packages/memory-graph/src/hooks')
| -rw-r--r-- | packages/memory-graph/src/hooks/use-documents-query.ts | 113 | ||||
| -rw-r--r-- | packages/memory-graph/src/hooks/use-graph-data.ts | 308 | ||||
| -rw-r--r-- | packages/memory-graph/src/hooks/use-graph-interactions.ts | 564 | ||||
| -rw-r--r-- | packages/memory-graph/src/hooks/use-mobile.ts | 19 |
4 files changed, 1004 insertions, 0 deletions
diff --git a/packages/memory-graph/src/hooks/use-documents-query.ts b/packages/memory-graph/src/hooks/use-documents-query.ts new file mode 100644 index 00000000..eb9ab892 --- /dev/null +++ b/packages/memory-graph/src/hooks/use-documents-query.ts @@ -0,0 +1,113 @@ +"use client"; + +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { fetchDocuments, type FetchDocumentsOptions } from "@/lib/api-client"; +import type { DocumentsResponse } from "@/api-types"; + +export interface UseDocumentsQueryOptions { + apiKey: string; + baseUrl?: string; + id?: string; // Optional document ID to filter by + containerTags?: string[]; + limit?: number; + sort?: "createdAt" | "updatedAt"; + order?: "asc" | "desc"; + enabled?: boolean; // Whether to enable the query +} + +/** + * Hook for fetching a single page of documents + * Useful when you don't need pagination + */ +export function useDocumentsQuery(options: UseDocumentsQueryOptions) { + const { + apiKey, + baseUrl, + containerTags, + limit = 50, + sort = "createdAt", + order = "desc", + enabled = true, + } = options; + + return useQuery({ + queryKey: ["documents", { apiKey, baseUrl, containerTags, limit, sort, order }], + queryFn: async () => { + return fetchDocuments({ + apiKey, + baseUrl, + page: 1, + limit, + sort, + order, + containerTags, + }); + }, + enabled: enabled && !!apiKey, + staleTime: 1000 * 60 * 5, // 5 minutes + retry: 2, + }); +} + +/** + * Hook for fetching documents with infinite scroll/pagination support + * Automatically handles loading more pages + */ +export function useInfiniteDocumentsQuery(options: UseDocumentsQueryOptions) { + const { + apiKey, + baseUrl, + containerTags, + limit = 500, + sort = "createdAt", + order = "desc", + enabled = true, + } = options; + + return useInfiniteQuery({ + queryKey: ["documents", "infinite", { apiKey, baseUrl, containerTags, limit, sort, order }], + queryFn: async ({ pageParam = 1 }) => { + return fetchDocuments({ + apiKey, + baseUrl, + page: pageParam, + limit, + sort, + order, + containerTags, + }); + }, + initialPageParam: 1, + getNextPageParam: (lastPage: DocumentsResponse) => { + const { currentPage, totalPages } = lastPage.pagination; + return currentPage < totalPages ? currentPage + 1 : undefined; + }, + enabled: enabled && !!apiKey, + staleTime: 1000 * 60 * 5, // 5 minutes + retry: 2, + }); +} + +/** + * Helper to flatten infinite query results into a single documents array + */ +export function flattenDocuments(data: { pages: DocumentsResponse[] } | undefined) { + if (!data?.pages) return []; + return data.pages.flatMap((page) => page.documents); +} + +/** + * Helper to get total documents count from infinite query + */ +export function getTotalDocuments(data: { pages: DocumentsResponse[] } | undefined) { + if (!data?.pages?.[0]) return 0; + return data.pages[0].pagination.totalItems; +} + +/** + * Helper to get current loaded count from infinite query + */ +export function getLoadedCount(data: { pages: DocumentsResponse[] } | undefined) { + if (!data?.pages) return 0; + return data.pages.reduce((sum, page) => sum + page.documents.length, 0); +} diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts new file mode 100644 index 00000000..030eea61 --- /dev/null +++ b/packages/memory-graph/src/hooks/use-graph-data.ts @@ -0,0 +1,308 @@ +"use client"; + +import { + calculateSemanticSimilarity, + getConnectionVisualProps, + getMagicalConnectionColor, +} from "@/lib/similarity"; +import { useMemo } from "react"; +import { colors, LAYOUT_CONSTANTS } from "@/constants"; +import type { + DocumentsResponse, + DocumentWithMemories, + GraphEdge, + GraphNode, + MemoryEntry, + MemoryRelation, +} from "@/types"; + +export function useGraphData( + data: DocumentsResponse | null, + selectedSpace: string, + nodePositions: Map<string, { x: number; y: number }>, + draggingNodeId: string | null, +) { + return useMemo(() => { + if (!data?.documents) return { nodes: [], edges: [] }; + + const allNodes: GraphNode[] = []; + const allEdges: GraphEdge[] = []; + + // Filter documents that have memories in selected space + const filteredDocuments = data.documents + .map((doc) => ({ + ...doc, + memoryEntries: + selectedSpace === "all" + ? doc.memoryEntries + : doc.memoryEntries.filter( + (memory) => + (memory.spaceContainerTag ?? memory.spaceId ?? "default") === + selectedSpace, + ), + })) + .filter((doc) => doc.memoryEntries.length > 0); + + // Group documents by space for better clustering + const documentsBySpace = new Map<string, typeof filteredDocuments>(); + filteredDocuments.forEach((doc) => { + const docSpace = + doc.memoryEntries[0]?.spaceContainerTag ?? + doc.memoryEntries[0]?.spaceId ?? + "default"; + if (!documentsBySpace.has(docSpace)) { + documentsBySpace.set(docSpace, []); + } + const spaceDocsArr = documentsBySpace.get(docSpace); + if (spaceDocsArr) { + spaceDocsArr.push(doc); + } + }); + + // Enhanced Layout with Space Separation + const { centerX, centerY, clusterRadius, spaceSpacing, documentSpacing } = + LAYOUT_CONSTANTS; + + /* 1. Build DOCUMENT nodes with space-aware clustering */ + 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; + + 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; + + // Find which ring this document belongs to + while (totalDocsInPreviousRings + docsInCurrentRing <= docIndex) { + 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; + + // Radius increases significantly with each ring + const baseRadius = documentSpacing * 0.8; + const radius = + currentRing === 0 + ? baseRadius + : baseRadius + currentRing * documentSpacing * 1.2; + + const defaultX = spaceCenterX + Math.cos(angleInRing) * radius; + const defaultY = spaceCenterY + Math.sin(angleInRing) * radius; + + const customPos = nodePositions.get(doc.id); + + documentNodes.push({ + id: doc.id, + type: "document", + x: customPos?.x ?? defaultX, + y: customPos?.y ?? defaultY, + data: doc, + size: 58, + color: colors.document.primary, + isHovered: false, + isDragging: draggingNodeId === doc.id, + } satisfies GraphNode); + }); + + spaceIndex++; + }); + + /* 2. Gentle document collision avoidance with dampening */ + 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; + + // Only repel documents in the same space + const spaceA = + (nodeA.data as DocumentWithMemories).memoryEntries[0] + ?.spaceContainerTag ?? + (nodeA.data as DocumentWithMemories).memoryEntries[0]?.spaceId ?? + "default"; + const spaceB = + (nodeB.data as DocumentWithMemories).memoryEntries[0] + ?.spaceContainerTag ?? + (nodeB.data as DocumentWithMemories).memoryEntries[0]?.spaceId ?? + "default"; + + 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; + + 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; + } + }); + }); + } + + 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; + + doc.memoryEntries.forEach((memory, memIndex) => { + 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 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; + + const defaultMemX = + docNode.x + Math.cos(clusterAngle) * distance + offsetX; + const defaultMemY = + docNode.y + Math.sin(clusterAngle) * distance + offsetY; + + if (!memoryNodeMap.has(memoryId)) { + const memoryNode: GraphNode = { + id: memoryId, + type: "memory", + x: customMemPos?.x ?? defaultMemX, + y: customMemPos?.y ?? defaultMemY, + data: memory, + size: Math.max( + 32, + Math.min(48, (memory.memory?.length || 50) * 0.5), + ), + color: colors.memory.primary, + isHovered: false, + isDragging: draggingNodeId === memoryId, + }; + memoryNodeMap.set(memoryId, memoryNode); + allNodes.push(memoryNode); + } + + // Create doc-memory edge with similarity + allEdges.push({ + id: `edge-${docNode.id}-${memory.id}`, + source: docNode.id, + target: memoryId, + similarity: 1, + visualProps: getConnectionVisualProps(1), + color: colors.connection.memory, + edgeType: "doc-memory", + }); + }); + }); + + // Build mapping of memoryId -> nodeId for version chains + const memNodeIdMap = new Map<string, string>(); + allNodes.forEach((n) => { + if (n.type === "memory") { + 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> = {}; + + if ( + mem.memoryRelations && + Array.isArray(mem.memoryRelations) && + 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>); + } 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); + if (fromId && toId) { + allEdges.push({ + id: `version-${fromId}-${toId}`, + source: fromId, + target: toId, + similarity: 1, + visualProps: { + opacity: 0.8, + thickness: 1, + glow: 0, + pulseDuration: 3000, + }, + // choose color based on relation type + 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; + + for (let j = i + 1; j < filteredDocuments.length; j++) { + 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}`, + source: docI.id, + target: docJ.id, + similarity: sim, + visualProps: getConnectionVisualProps(sim), + color: getMagicalConnectionColor(sim, 200), + edgeType: "doc-doc", + }); + } + } + } + + return { nodes: allNodes, edges: allEdges }; + }, [data, selectedSpace, nodePositions, draggingNodeId]); +} diff --git a/packages/memory-graph/src/hooks/use-graph-interactions.ts b/packages/memory-graph/src/hooks/use-graph-interactions.ts new file mode 100644 index 00000000..fa794397 --- /dev/null +++ b/packages/memory-graph/src/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, + }; +} diff --git a/packages/memory-graph/src/hooks/use-mobile.ts b/packages/memory-graph/src/hooks/use-mobile.ts new file mode 100644 index 00000000..283bbb4c --- /dev/null +++ b/packages/memory-graph/src/hooks/use-mobile.ts @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener("change", onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener("change", onChange) + }, []) + + return !!isMobile +} |