diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index d6edc122..8c931b98 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -227,7 +227,7 @@ const MemoryGraphPage = () => {
// Progressive loading via useInfiniteQuery
const IS_DEV = process.env.NODE_ENV === "development";
- const PAGE_SIZE = IS_DEV ? 3 : 100;
+ const PAGE_SIZE = IS_DEV ? 100 : 100;
const MAX_TOTAL = 1000;
const {
@@ -244,7 +244,7 @@ const MemoryGraphPage = () => {
const response = await $fetch("@post/memories/documents", {
body: {
page: pageParam as number,
- limit: (pageParam as number) === 1 ? (IS_DEV ? 3 : 500) : PAGE_SIZE,
+ limit: (pageParam as number) === 1 ? (IS_DEV ? 500 : 500) : PAGE_SIZE,
sort: "createdAt",
order: "desc",
containerTags: selectedProject ? [selectedProject] : undefined,
@@ -388,284 +388,284 @@ const MemoryGraphPage = () => {
}, []);
return (
-
- {/* Main content area */}
-
-
-
-
-
handleViewModeChange('graph')}
- transition={{ duration: 0.2 }}
- whileHover={{ scale: 1.02 }}
- whileTap={{ scale: 0.98 }}
- >
- {viewMode === 'graph' && (
-
- )}
-
-
- Graph
-
-
+
+ {/* Main content area */}
+
+
+
+
+ handleViewModeChange("graph")}
+ transition={{ duration: 0.2 }}
+ whileHover={{ scale: 1.02 }}
+ whileTap={{ scale: 0.98 }}
+ >
+ {viewMode === "graph" && (
+
+ )}
+
+
+ Graph
+
+
- handleViewModeChange('list')}
- transition={{ duration: 0.2 }}
- whileHover={{ scale: 1.02 }}
- whileTap={{ scale: 0.98 }}
- >
- {viewMode === 'list' && (
-
- )}
-
-
- List
-
-
-
-
+ handleViewModeChange("list")}
+ transition={{ duration: 0.2 }}
+ whileHover={{ scale: 1.02 }}
+ whileTap={{ scale: 0.98 }}
+ >
+ {viewMode === "list" && (
+
+ )}
+
+
+ List
+
+
+
+
- {/* Animated content switching */}
-
- {viewMode === 'graph' ? (
-
-
-
-
-
-
- No Memories to Visualize
-
-
-
-
-
-
-
- ) : (
-
-
-
-
-
-
- No Memories to Visualize
-
-
-
-
-
-
-
- )}
-
+ {/* Animated content switching */}
+
+ {viewMode === "graph" ? (
+
+
+
+
+
+
+ No Memories to Visualize
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+ No Memories to Visualize
+
+
+
+
+
+
+
+ )}
+
- {/* Top Bar */}
-
-
-
-
-
-
+ {/* Top Bar */}
+
+
+
+
+
+
-
+
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
- {/* Floating Open Chat Button */}
- {!isOpen && !isMobile && (
-
-
-
- )}
-
+ {/* Floating Open Chat Button */}
+ {!isOpen && !isMobile && (
+
+
+
+ )}
+
- {/* Chat panel - positioned absolutely */}
-
-
-
-
-
+ {/* Chat panel - positioned absolutely */}
+
+
+
+
+
- {showAddMemoryView && (
-
setShowAddMemoryView(false)}
- />
- )}
+ {showAddMemoryView && (
+ setShowAddMemoryView(false)}
+ />
+ )}
- {/* Tour Alert Dialog */}
-
+ {/* Tour Alert Dialog */}
+
- {/* Referral/Upgrade Modal */}
- setShowReferralModal(false)}
- />
-
- );
+ {/* Referral/Upgrade Modal */}
+ setShowReferralModal(false)}
+ />
+
+ );
};
// Wrapper component to handle auth and waitlist checks
@@ -673,12 +673,7 @@ export default function Page() {
const router = useRouter();
const { user } = useAuth();
- // Check waitlist status
- const {
- data: waitlistStatus,
- isLoading: isCheckingWaitlist,
- error: waitlistError,
- } = useQuery({
+ const { data: waitlistStatus, isLoading: isCheckingWaitlist } = useQuery({
queryKey: ["waitlist-status", user?.id],
queryFn: async () => {
try {
diff --git a/packages/ui/memory-graph/graph-canvas.tsx b/packages/ui/memory-graph/graph-canvas.tsx
index b29288ad..c4623c85 100644
--- a/packages/ui/memory-graph/graph-canvas.tsx
+++ b/packages/ui/memory-graph/graph-canvas.tsx
@@ -35,6 +35,9 @@ export const GraphCanvas = memo(
onPanEnd,
onWheel,
onDoubleClick,
+ onTouchStart,
+ onTouchMove,
+ onTouchEnd,
draggingNodeId,
highlightDocumentIds,
}) => {
@@ -657,6 +660,10 @@ export const GraphCanvas = memo(
onWheel({
deltaY: e.deltaY,
deltaX: e.deltaX,
+ clientX: e.clientX,
+ clientY: e.clientY,
+ currentTarget: canvas,
+ nativeEvent: e,
preventDefault: () => {},
stopPropagation: () => {},
} as React.WheelEvent);
@@ -732,6 +739,9 @@ export const GraphCanvas = memo(
onPanEnd();
}
}}
+ onTouchStart={onTouchStart}
+ onTouchMove={onTouchMove}
+ onTouchEnd={onTouchEnd}
ref={canvasRef}
style={{
cursor: draggingNodeId
diff --git a/packages/ui/memory-graph/graph-webgl-canvas.tsx b/packages/ui/memory-graph/graph-webgl-canvas.tsx
index 9d775c2b..d45e75c8 100644
--- a/packages/ui/memory-graph/graph-webgl-canvas.tsx
+++ b/packages/ui/memory-graph/graph-webgl-canvas.tsx
@@ -28,6 +28,9 @@ export const GraphWebGLCanvas = memo(
onPanEnd,
onWheel,
onDoubleClick,
+ onTouchStart,
+ onTouchMove,
+ onTouchEnd,
draggingNodeId,
}) => {
const containerRef = useRef(null);
@@ -697,6 +700,10 @@ export const GraphWebGLCanvas = memo(
onWheel({
deltaY: dy,
deltaX: dx,
+ clientX: e.clientX,
+ clientY: e.clientY,
+ currentTarget: containerRef.current,
+ nativeEvent: e.nativeEvent,
preventDefault: () => {},
stopPropagation: () => {},
} as React.WheelEvent);
@@ -739,6 +746,9 @@ export const GraphWebGLCanvas = memo(
}}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
+ onTouchStart={onTouchStart}
+ onTouchMove={onTouchMove}
+ onTouchEnd={onTouchEnd}
onWheel={handleWheel}
ref={containerRef}
role="application"
diff --git a/packages/ui/memory-graph/hooks/use-graph-interactions.ts b/packages/ui/memory-graph/hooks/use-graph-interactions.ts
index 62216068..6f0317d2 100644
--- a/packages/ui/memory-graph/hooks/use-graph-interactions.ts
+++ b/packages/ui/memory-graph/hooks/use-graph-interactions.ts
@@ -1,31 +1,95 @@
-"use client";
+"use client"
-import { useCallback, 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 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(null);
- const [selectedNode, setSelectedNode] = useState(null);
- const [draggingNodeId, setDraggingNodeId] = useState(null);
+ 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(null)
+ const [selectedNode, setSelectedNode] = useState(null)
+ const [draggingNodeId, setDraggingNodeId] = useState(null)
const [dragStart, setDragStart] = useState({
x: 0,
y: 0,
nodeX: 0,
nodeY: 0,
- });
+ })
const [nodePositions, setNodePositions] = useState<
Map
- >(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: [],
+ lastDistance: 0,
+ lastCenter: { x: 0, y: 0 },
+ isGesturing: false,
+ })
+
+ // Animation state for smooth transitions
+ const animationRef = useRef(null)
+ const [isAnimating, setIsAnimating] = useState(false)
+
+ // Smooth animation helper
+ const animateToViewState = useCallback(
+ (
+ targetPanX: number,
+ targetPanY: number,
+ targetZoom: number,
+ duration: number = 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 - Math.pow(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(
@@ -91,19 +155,110 @@ export function useGraphInteractions(
}, []);
// Zoom handlers
- const handleWheel = useCallback((e: React.WheelEvent) => {
- e.preventDefault();
- const delta = e.deltaY > 0 ? 0.97 : 1.03;
- setZoom((prev) => Math.max(0.1, Math.min(2, prev * delta)));
- }, []);
+ 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(() => {
- setZoom((prev) => Math.min(2, prev * 1.1));
- }, []);
+ const zoomIn = useCallback(
+ (centerX?: number, centerY?: number, animate: boolean = 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(() => {
- setZoom((prev) => Math.max(0.1, prev / 1.1));
- }, []);
+ const zoomOut = useCallback(
+ (centerX?: number, centerY?: number, animate: boolean = 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);
@@ -153,41 +308,178 @@ export function useGraphInteractions(
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.1, Math.min(zoomX, zoomY)), 2);
+ 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) => {
+ 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(
+ Math.pow(touch2.x - touch1.x, 2) + Math.pow(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(
+ Math.pow(touch2.x - touch1.x, 2) + Math.pow(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: boolean = 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) => {
@@ -203,29 +495,36 @@ export function useGraphInteractions(
const handleDoubleClick = useCallback(
(e: React.MouseEvent) => {
- const canvas = e.currentTarget as HTMLCanvasElement;
- const rect = canvas.getBoundingClientRect();
- const x = e.clientX - rect.left;
- const y = e.clientY - rect.top;
-
// Calculate new zoom (zoom in by 1.5x)
- const zoomFactor = 1.5;
- const newZoom = Math.min(2, 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
+
+ // 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 = (x - panX) / zoom;
- const worldY = (y - 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 = x - worldX * newZoom;
- const newPanY = y - 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
@@ -247,11 +546,16 @@ export function useGraphInteractions(
handleNodeDragMove,
handleNodeDragEnd,
handleDoubleClick,
+ // Touch handlers
+ handleTouchStart,
+ handleTouchMove,
+ handleTouchEnd,
// Controls
zoomIn,
zoomOut,
resetView,
autoFitToViewport,
+ centerViewportOn,
setSelectedNode,
- };
+ }
}
diff --git a/packages/ui/memory-graph/memory-graph.tsx b/packages/ui/memory-graph/memory-graph.tsx
index 75ada513..912a741a 100644
--- a/packages/ui/memory-graph/memory-graph.tsx
+++ b/packages/ui/memory-graph/memory-graph.tsx
@@ -9,6 +9,7 @@ import { useGraphData } from "./hooks/use-graph-data";
import { useGraphInteractions } from "./hooks/use-graph-interactions";
import { Legend } from "./legend";
import { LoadingIndicator } from "./loading-indicator";
+import { NavigationControls } from "./navigation-controls";
import { NodeDetailPanel } from "./node-detail-panel";
import { SpacesDropdown } from "./spaces-dropdown";
@@ -71,8 +72,14 @@ export const MemoryGraph = ({
handleNodeDragMove,
handleNodeDragEnd,
handleDoubleClick,
+ handleTouchStart,
+ handleTouchMove,
+ handleTouchEnd,
setSelectedNode,
autoFitToViewport,
+ centerViewportOn,
+ zoomIn,
+ zoomOut,
} = useGraphInteractions(variant);
// Graph data
@@ -188,6 +195,37 @@ export const MemoryGraph = ({
[handleNodeDragStart, nodes],
);
+ // Navigation callbacks
+ const handleCenter = useCallback(() => {
+ if (nodes.length > 0) {
+ // Calculate center of all nodes
+ let sumX = 0
+ let sumY = 0
+ let count = 0
+
+ nodes.forEach((node) => {
+ sumX += node.x
+ sumY += node.y
+ count++
+ })
+
+ if (count > 0) {
+ const centerX = sumX / count
+ const centerY = sumY / count
+ centerViewportOn(centerX, centerY, containerSize.width, containerSize.height)
+ }
+ }
+ }, [nodes, centerViewportOn, containerSize.width, containerSize.height])
+
+ const handleAutoFit = useCallback(() => {
+ if (nodes.length > 0 && containerSize.width > 0 && containerSize.height > 0) {
+ autoFitToViewport(nodes, containerSize.width, containerSize.height, {
+ occludedRightPx,
+ animate: true,
+ })
+ }
+ }, [nodes, containerSize.width, containerSize.height, occludedRightPx, autoFitToViewport])
+
// Get selected node data
const selectedNodeData = useMemo(() => {
if (!selectedNode) return null;
@@ -368,6 +406,9 @@ export const MemoryGraph = ({
onPanEnd={handlePanEnd}
onPanMove={handlePanMove}
onPanStart={handlePanStart}
+ onTouchStart={handleTouchStart}
+ onTouchMove={handleTouchMove}
+ onTouchEnd={handleTouchEnd}
onWheel={handleWheel}
panX={panX}
panY={panY}
@@ -375,6 +416,18 @@ export const MemoryGraph = ({
zoom={zoom}
/>
)}
+
+ {/* Navigation controls */}
+ {containerSize.width > 0 && (
+ zoomIn(containerSize.width / 2, containerSize.height / 2)}
+ onZoomOut={() => zoomOut(containerSize.width / 2, containerSize.height / 2)}
+ onAutoFit={handleAutoFit}
+ nodes={nodes}
+ className="absolute bottom-4 left-4"
+ />
+ )}
);
diff --git a/packages/ui/memory-graph/navigation-controls.tsx b/packages/ui/memory-graph/navigation-controls.tsx
new file mode 100644
index 00000000..b2abd67f
--- /dev/null
+++ b/packages/ui/memory-graph/navigation-controls.tsx
@@ -0,0 +1,67 @@
+"use client"
+
+import { memo } from "react"
+import type { GraphNode } from "./types"
+
+interface NavigationControlsProps {
+ onCenter: () => void
+ onZoomIn: () => void
+ onZoomOut: () => void
+ onAutoFit: () => void
+ nodes: GraphNode[]
+ className?: string
+}
+
+export const NavigationControls = memo(({
+ onCenter,
+ onZoomIn,
+ onZoomOut,
+ onAutoFit,
+ nodes,
+ className = "",
+}) => {
+ if (nodes.length === 0) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ )
+})
+
+NavigationControls.displayName = "NavigationControls"
\ No newline at end of file
diff --git a/packages/ui/memory-graph/types.ts b/packages/ui/memory-graph/types.ts
index f1af3ac2..4692d2c0 100644
--- a/packages/ui/memory-graph/types.ts
+++ b/packages/ui/memory-graph/types.ts
@@ -68,6 +68,9 @@ export interface GraphCanvasProps {
onPanEnd: () => void;
onWheel: (e: React.WheelEvent) => void;
onDoubleClick: (e: React.MouseEvent) => void;
+ onTouchStart?: (e: React.TouchEvent) => void;
+ onTouchMove?: (e: React.TouchEvent) => void;
+ onTouchEnd?: (e: React.TouchEvent) => void;
draggingNodeId: string | null;
// Optional list of document IDs (customId or internal id) to highlight
highlightDocumentIds?: string[];