aboutsummaryrefslogtreecommitdiff
path: root/packages/memory-graph/src/hooks
diff options
context:
space:
mode:
authornexxeln <[email protected]>2025-11-19 18:57:55 +0000
committernexxeln <[email protected]>2025-11-19 18:57:56 +0000
commit5e24eb66c3ca7d2224d0d1f7837cda17015f5fcb (patch)
tree60336fd37b41e3597065729d098877483eba73b6 /packages/memory-graph/src/hooks
parentFix: Prevent multiple prompts while AI response is generated (fixes #538) (#583) (diff)
downloadarchived-supermemory-5e24eb66c3ca7d2224d0d1f7837cda17015f5fcb.tar.xz
archived-supermemory-5e24eb66c3ca7d2224d0d1f7837cda17015f5fcb.zip
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 ![image.png](https://app.graphite.com/user-attachments/assets/cb1822c5-850a-45a2-9bfa-72b73436659f.png)
Diffstat (limited to 'packages/memory-graph/src/hooks')
-rw-r--r--packages/memory-graph/src/hooks/use-documents-query.ts113
-rw-r--r--packages/memory-graph/src/hooks/use-graph-data.ts308
-rw-r--r--packages/memory-graph/src/hooks/use-graph-interactions.ts564
-rw-r--r--packages/memory-graph/src/hooks/use-mobile.ts19
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
+}