diff options
| author | Dhravya Shah <[email protected]> | 2025-08-29 17:10:11 -0700 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-08-29 17:10:11 -0700 |
| commit | 93af95876773a2751af6332165f4fbf5d1fa2e76 (patch) | |
| tree | 66efec53d8a67973d48e08576f3f50051387fda4 | |
| parent | feat: migrate from react-markdown to streamdown (#394) (diff) | |
| download | supermemory-93af95876773a2751af6332165f4fbf5d1fa2e76.tar.xz supermemory-93af95876773a2751af6332165f4fbf5d1fa2e76.zip | |
BETTER GRAPH (#398)
Co-authored-by: Dhravya Shah <[email protected]>
| -rw-r--r-- | apps/web/app/page.tsx | 567 | ||||
| -rw-r--r-- | packages/ui/memory-graph/graph-canvas.tsx | 10 | ||||
| -rw-r--r-- | packages/ui/memory-graph/graph-webgl-canvas.tsx | 10 | ||||
| -rw-r--r-- | packages/ui/memory-graph/hooks/use-graph-interactions.ts | 444 | ||||
| -rw-r--r-- | packages/ui/memory-graph/memory-graph.tsx | 53 | ||||
| -rw-r--r-- | packages/ui/memory-graph/navigation-controls.tsx | 67 | ||||
| -rw-r--r-- | packages/ui/memory-graph/types.ts | 3 |
7 files changed, 798 insertions, 356 deletions
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 ( - <div className="relative h-screen bg-[#0f1419] overflow-hidden touch-none"> - {/* Main content area */} - <motion.div - animate={{ - marginRight: isOpen && !isMobile ? 600 : 0, - }} - className="h-full relative" - transition={{ - duration: 0.2, - ease: [0.4, 0, 0.2, 1], // Material Design easing - snappy but smooth - }} - > - <motion.div - animate={{ opacity: 1, y: 0 }} - className="absolute md:top-4 md:right-4 md:bottom-auto md:left-auto bottom-8 left-6 z-20 rounded-xl overflow-hidden" - id={TOUR_STEP_IDS.VIEW_TOGGLE} - initial={{ opacity: 0, y: -20 }} - transition={{ type: 'spring', stiffness: 300, damping: 25 }} - > - <GlassMenuEffect rounded="rounded-xl" /> - <div className="relative z-10 p-2 flex gap-1"> - <motion.button - animate={{ - color: viewMode === 'graph' ? '#93c5fd' : '#cbd5e1', - }} - className="relative h-8 px-3 flex items-center gap-2 text-sm font-medium rounded-md transition-colors" - onClick={() => handleViewModeChange('graph')} - transition={{ duration: 0.2 }} - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - > - {viewMode === 'graph' && ( - <motion.div - className="absolute inset-0 bg-blue-500/20 rounded-md" - layoutId="activeBackground" - transition={{ - type: 'spring', - stiffness: 400, - damping: 30, - }} - /> - )} - <span className="relative z-10 flex items-center gap-2"> - <LayoutGrid className="w-4 h-4" /> - <span className="hidden md:inline">Graph</span> - </span> - </motion.button> - - <motion.button - animate={{ - color: viewMode === 'list' ? '#93c5fd' : '#cbd5e1', - }} - className="relative h-8 px-3 flex items-center gap-2 text-sm font-medium rounded-md transition-colors" - onClick={() => handleViewModeChange('list')} - transition={{ duration: 0.2 }} - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - > - {viewMode === 'list' && ( - <motion.div - className="absolute inset-0 bg-blue-500/20 rounded-md" - layoutId="activeBackground" - transition={{ - type: 'spring', - stiffness: 400, - damping: 30, - }} - /> - )} - <span className="relative z-10 flex items-center gap-2"> - <List className="w-4 h-4" /> - <span className="hidden md:inline">List</span> - </span> - </motion.button> - </div> - </motion.div> - - {/* Animated content switching */} - <AnimatePresence mode="wait"> - {viewMode === 'graph' ? ( - <motion.div - animate={{ opacity: 1, scale: 1 }} - className="absolute inset-0" - exit={{ opacity: 0, scale: 0.95 }} - id={TOUR_STEP_IDS.MEMORY_GRAPH} - initial={{ opacity: 0, scale: 0.95 }} - key="graph" - transition={{ - type: 'spring', - stiffness: 500, - damping: 30, - }} - > - <MemoryGraph - documents={allDocuments} - error={error} - hasMore={hasMore} - isLoading={isPending} - isLoadingMore={isLoadingMore} - legendId={TOUR_STEP_IDS.LEGEND} - loadMoreDocuments={loadMoreDocuments} - showSpacesSelector={false} - totalLoaded={totalLoaded} - variant="consumer" - highlightDocumentIds={allHighlightDocumentIds} - highlightsVisible={isOpen} - occludedRightPx={isOpen && !isMobile ? 600 : 0} - autoLoadOnViewport={false} - isExperimental={isCurrentProjectExperimental} - > - <div className="absolute inset-0 flex items-center justify-center"> - <div className="rounded-xl overflow-hidden"> - <div className="relative z-10 text-slate-200 px-6 py-4 text-center"> - <p className="text-lg font-medium mb-2"> - No Memories to Visualize - </p> - <button - type="button" - className="text-sm text-blue-400 hover:text-blue-300 transition-colors cursor-pointer underline" - onClick={() => setShowAddMemoryView(true)} - > - Create one? - </button> - </div> - </div> - </div> - </MemoryGraph> - </motion.div> - ) : ( - <motion.div - animate={{ opacity: 1, scale: 1 }} - className="absolute inset-0 md:ml-18" - exit={{ opacity: 0, scale: 0.95 }} - id={TOUR_STEP_IDS.MEMORY_LIST} - initial={{ opacity: 0, scale: 0.95 }} - key="list" - transition={{ - type: 'spring', - stiffness: 500, - damping: 30, - }} - > - <MemoryListView - documents={allDocuments} - error={error} - hasMore={hasMore} - isLoading={isPending} - isLoadingMore={isLoadingMore} - loadMoreDocuments={loadMoreDocuments} - totalLoaded={totalLoaded} - > - <div className="absolute inset-0 flex items-center justify-center"> - <div className="rounded-xl overflow-hidden"> - <div className="relative z-10 text-slate-200 px-6 py-4 text-center"> - <p className="text-lg font-medium mb-2"> - No Memories to Visualize - </p> - <button - className="text-sm text-blue-400 hover:text-blue-300 transition-colors cursor-pointer underline" - onClick={() => setShowAddMemoryView(true)} - type="button" - > - Create one? - </button> - </div> - </div> - </div> - </MemoryListView> - </motion.div> - )} - </AnimatePresence> - - {/* Top Bar */} - <div className="absolute top-2 left-0 right-0 z-10 p-4 flex items-center justify-between"> - <div className="flex items-center gap-3 justify-between w-full md:w-fit md:justify-start"> - <Link - className="pointer-events-auto" - href="https://supermemory.ai" - rel="noopener noreferrer" - target="_blank" - > - <LogoFull - className="h-8 hidden md:block" - id={TOUR_STEP_IDS.LOGO} - /> - <Logo className="h-8 md:hidden" id={TOUR_STEP_IDS.LOGO} /> - </Link> - - <div className="hidden sm:block"> - <ProjectSelector /> - </div> - - <ConnectAIModal> - <Button - variant="outline" - size="sm" - className="bg-white/5 hover:bg-white/10 border-white/20 text-white hover:text-white px-2 sm:px-3" - > - <Unplug className="h-4 w-4" /> - <span className="hidden sm:inline ml-2"> - Connect to your AI - </span> - <span className="sm:hidden ml-1">Connect AI</span> - </Button> - </ConnectAIModal> - </div> - - <div> - <Menu /> - </div> - </div> - - {/* Floating Open Chat Button */} - {!isOpen && !isMobile && ( - <motion.div - animate={{ opacity: 1, scale: 1 }} - className="fixed bottom-6 right-6 z-50" - initial={{ opacity: 0, scale: 0.8 }} - transition={{ - type: 'spring', - stiffness: 300, - damping: 25, - }} - > - <Button - className="px-4 bg-white hover:bg-white/80 text-[#001A39] shadow-lg hover:shadow-xl transition-all duration-200 rounded-full flex items-center gap-2 cursor-pointer" - onClick={() => setIsOpen(true)} - size="lg" - > - <MessageSquare className="h-5 w-5" /> - <span className="font-medium">Open Chat</span> - </Button> - </motion.div> - )} - </motion.div> - - {/* Chat panel - positioned absolutely */} - <motion.div - className="fixed top-0 right-0 h-full z-50 md:z-auto" - style={{ - width: isOpen ? (isMobile ? '100vw' : '600px') : 0, - pointerEvents: isOpen ? 'auto' : 'none', - }} - id={TOUR_STEP_IDS.FLOATING_CHAT} - > - <motion.div - animate={{ x: isOpen ? 0 : isMobile ? '100%' : 600 }} - className="absolute inset-0" - exit={{ x: isMobile ? '100%' : 600 }} - initial={{ x: isMobile ? '100%' : 600 }} - key="chat" - transition={{ - type: 'spring', - stiffness: 500, - damping: 40, - }} - > - <ChatRewrite /> - </motion.div> - </motion.div> - - {showAddMemoryView && ( - <AddMemoryView - initialTab="note" - onClose={() => setShowAddMemoryView(false)} - /> - )} - - {/* Tour Alert Dialog */} - <TourAlertDialog onOpenChange={setShowTourDialog} open={showTourDialog} /> - - {/* Referral/Upgrade Modal */} - <ReferralUpgradeModal - isOpen={showReferralModal} - onClose={() => setShowReferralModal(false)} - /> - </div> - ); + <div className="relative h-screen bg-[#0f1419] overflow-hidden touch-none"> + {/* Main content area */} + <motion.div + animate={{ + marginRight: isOpen && !isMobile ? 600 : 0, + }} + className="h-full relative" + transition={{ + duration: 0.2, + ease: [0.4, 0, 0.2, 1], // Material Design easing - snappy but smooth + }} + > + <motion.div + animate={{ opacity: 1, y: 0 }} + className="absolute md:top-4 md:right-4 md:bottom-auto md:left-auto bottom-8 left-6 z-20 rounded-xl overflow-hidden" + id={TOUR_STEP_IDS.VIEW_TOGGLE} + initial={{ opacity: 0, y: -20 }} + transition={{ type: "spring", stiffness: 300, damping: 25 }} + > + <GlassMenuEffect rounded="rounded-xl" /> + <div className="relative z-10 p-2 flex gap-1"> + <motion.button + animate={{ + color: viewMode === "graph" ? "#93c5fd" : "#cbd5e1", + }} + className="relative h-8 px-3 flex items-center gap-2 text-sm font-medium rounded-md transition-colors" + onClick={() => handleViewModeChange("graph")} + transition={{ duration: 0.2 }} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > + {viewMode === "graph" && ( + <motion.div + className="absolute inset-0 bg-blue-500/20 rounded-md" + layoutId="activeBackground" + transition={{ + type: "spring", + stiffness: 400, + damping: 30, + }} + /> + )} + <span className="relative z-10 flex items-center gap-2"> + <LayoutGrid className="w-4 h-4" /> + <span className="hidden md:inline">Graph</span> + </span> + </motion.button> + + <motion.button + animate={{ + color: viewMode === "list" ? "#93c5fd" : "#cbd5e1", + }} + className="relative h-8 px-3 flex items-center gap-2 text-sm font-medium rounded-md transition-colors" + onClick={() => handleViewModeChange("list")} + transition={{ duration: 0.2 }} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > + {viewMode === "list" && ( + <motion.div + className="absolute inset-0 bg-blue-500/20 rounded-md" + layoutId="activeBackground" + transition={{ + type: "spring", + stiffness: 400, + damping: 30, + }} + /> + )} + <span className="relative z-10 flex items-center gap-2"> + <List className="w-4 h-4" /> + <span className="hidden md:inline">List</span> + </span> + </motion.button> + </div> + </motion.div> + + {/* Animated content switching */} + <AnimatePresence mode="wait"> + {viewMode === "graph" ? ( + <motion.div + animate={{ opacity: 1, scale: 1 }} + className="absolute inset-0" + exit={{ opacity: 0, scale: 0.95 }} + id={TOUR_STEP_IDS.MEMORY_GRAPH} + initial={{ opacity: 0, scale: 0.95 }} + key="graph" + transition={{ + type: "spring", + stiffness: 500, + damping: 30, + }} + > + <MemoryGraph + autoLoadOnViewport={false} + documents={allDocuments} + error={error} + hasMore={hasMore} + highlightDocumentIds={allHighlightDocumentIds} + highlightsVisible={isOpen} + isExperimental={isCurrentProjectExperimental} + isLoading={isPending} + isLoadingMore={isLoadingMore} + legendId={TOUR_STEP_IDS.LEGEND} + loadMoreDocuments={loadMoreDocuments} + occludedRightPx={isOpen && !isMobile ? 600 : 0} + showSpacesSelector={false} + totalLoaded={totalLoaded} + variant="consumer" + > + <div className="absolute inset-0 flex items-center justify-center"> + <div className="rounded-xl overflow-hidden"> + <div className="relative z-10 text-slate-200 px-6 py-4 text-center"> + <p className="text-lg font-medium mb-2"> + No Memories to Visualize + </p> + <button + className="text-sm text-blue-400 hover:text-blue-300 transition-colors cursor-pointer underline" + onClick={() => setShowAddMemoryView(true)} + type="button" + > + Create one? + </button> + </div> + </div> + </div> + </MemoryGraph> + </motion.div> + ) : ( + <motion.div + animate={{ opacity: 1, scale: 1 }} + className="absolute inset-0 md:ml-18" + exit={{ opacity: 0, scale: 0.95 }} + id={TOUR_STEP_IDS.MEMORY_LIST} + initial={{ opacity: 0, scale: 0.95 }} + key="list" + transition={{ + type: "spring", + stiffness: 500, + damping: 30, + }} + > + <MemoryListView + documents={allDocuments} + error={error} + hasMore={hasMore} + isLoading={isPending} + isLoadingMore={isLoadingMore} + loadMoreDocuments={loadMoreDocuments} + totalLoaded={totalLoaded} + > + <div className="absolute inset-0 flex items-center justify-center"> + <div className="rounded-xl overflow-hidden"> + <div className="relative z-10 text-slate-200 px-6 py-4 text-center"> + <p className="text-lg font-medium mb-2"> + No Memories to Visualize + </p> + <button + className="text-sm text-blue-400 hover:text-blue-300 transition-colors cursor-pointer underline" + onClick={() => setShowAddMemoryView(true)} + type="button" + > + Create one? + </button> + </div> + </div> + </div> + </MemoryListView> + </motion.div> + )} + </AnimatePresence> + + {/* Top Bar */} + <div className="absolute top-2 left-0 right-0 z-10 p-4 flex items-center justify-between"> + <div className="flex items-center gap-3 justify-between w-full md:w-fit md:justify-start"> + <Link + className="pointer-events-auto" + href="https://supermemory.ai" + rel="noopener noreferrer" + target="_blank" + > + <LogoFull + className="h-8 hidden md:block" + id={TOUR_STEP_IDS.LOGO} + /> + <Logo className="h-8 md:hidden" id={TOUR_STEP_IDS.LOGO} /> + </Link> + + <div className="hidden sm:block"> + <ProjectSelector /> + </div> + + <ConnectAIModal> + <Button + className="bg-white/5 hover:bg-white/10 border-white/20 text-white hover:text-white px-2 sm:px-3" + size="sm" + variant="outline" + > + <Unplug className="h-4 w-4" /> + <span className="hidden sm:inline ml-2"> + Connect to your AI + </span> + <span className="sm:hidden ml-1">Connect AI</span> + </Button> + </ConnectAIModal> + </div> + + <div> + <Menu /> + </div> + </div> + + {/* Floating Open Chat Button */} + {!isOpen && !isMobile && ( + <motion.div + animate={{ opacity: 1, scale: 1 }} + className="fixed bottom-6 right-6 z-50" + initial={{ opacity: 0, scale: 0.8 }} + transition={{ + type: "spring", + stiffness: 300, + damping: 25, + }} + > + <Button + className="px-4 bg-white hover:bg-white/80 text-[#001A39] shadow-lg hover:shadow-xl transition-all duration-200 rounded-full flex items-center gap-2 cursor-pointer" + onClick={() => setIsOpen(true)} + size="lg" + > + <MessageSquare className="h-5 w-5" /> + <span className="font-medium">Open Chat</span> + </Button> + </motion.div> + )} + </motion.div> + + {/* Chat panel - positioned absolutely */} + <motion.div + className="fixed top-0 right-0 h-full z-50 md:z-auto" + id={TOUR_STEP_IDS.FLOATING_CHAT} + style={{ + width: isOpen ? (isMobile ? "100vw" : "600px") : 0, + pointerEvents: isOpen ? "auto" : "none", + }} + > + <motion.div + animate={{ x: isOpen ? 0 : isMobile ? "100%" : 600 }} + className="absolute inset-0" + exit={{ x: isMobile ? "100%" : 600 }} + initial={{ x: isMobile ? "100%" : 600 }} + key="chat" + transition={{ + type: "spring", + stiffness: 500, + damping: 40, + }} + > + <ChatRewrite /> + </motion.div> + </motion.div> + + {showAddMemoryView && ( + <AddMemoryView + initialTab="note" + onClose={() => setShowAddMemoryView(false)} + /> + )} + + {/* Tour Alert Dialog */} + <TourAlertDialog onOpenChange={setShowTourDialog} open={showTourDialog} /> + + {/* Referral/Upgrade Modal */} + <ReferralUpgradeModal + isOpen={showReferralModal} + onClose={() => setShowReferralModal(false)} + /> + </div> + ); }; // 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<GraphCanvasProps>( onPanEnd, onWheel, onDoubleClick, + onTouchStart, + onTouchMove, + onTouchEnd, draggingNodeId, highlightDocumentIds, }) => { @@ -657,6 +660,10 @@ export const GraphCanvas = memo<GraphCanvasProps>( 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<GraphCanvasProps>( 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<GraphCanvasProps>( onPanEnd, onWheel, onDoubleClick, + onTouchStart, + onTouchMove, + onTouchEnd, draggingNodeId, }) => { const containerRef = useRef<HTMLDivElement>(null); @@ -697,6 +700,10 @@ export const GraphWebGLCanvas = memo<GraphCanvasProps>( 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<GraphCanvasProps>( }} 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 [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: [], + 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: 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 zoomIn = useCallback(() => { - setZoom((prev) => Math.min(2, prev * 1.1)); - }, []); - - const zoomOut = useCallback(() => { - setZoom((prev) => Math.max(0.1, prev / 1.1)); - }, []); + 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: 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( + (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 && ( + <NavigationControls + onCenter={handleCenter} + onZoomIn={() => 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" + /> + )} </div> </div> ); 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<NavigationControlsProps>(({ + onCenter, + onZoomIn, + onZoomOut, + onAutoFit, + nodes, + className = "", +}) => { + if (nodes.length === 0) { + return null + } + + return ( + <div className={`flex flex-col gap-1 ${className}`}> + <button + type="button" + onClick={onAutoFit} + className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16" + title="Auto-fit graph to viewport" + > + Fit + </button> + <button + type="button" + onClick={onCenter} + className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16" + title="Center view on graph" + > + Center + </button> + <div className="flex flex-col"> + <button + type="button" + onClick={onZoomIn} + className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-t-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16 border-b-0" + title="Zoom in" + > + + + </button> + <button + type="button" + onClick={onZoomOut} + className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-b-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16" + title="Zoom out" + > + − + </button> + </div> + </div> + ) +}) + +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[]; |