aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDhravya Shah <[email protected]>2025-08-29 17:10:11 -0700
committerGitHub <[email protected]>2025-08-29 17:10:11 -0700
commit93af95876773a2751af6332165f4fbf5d1fa2e76 (patch)
tree66efec53d8a67973d48e08576f3f50051387fda4
parentfeat: migrate from react-markdown to streamdown (#394) (diff)
downloadsupermemory-93af95876773a2751af6332165f4fbf5d1fa2e76.tar.xz
supermemory-93af95876773a2751af6332165f4fbf5d1fa2e76.zip
BETTER GRAPH (#398)
Co-authored-by: Dhravya Shah <[email protected]>
-rw-r--r--apps/web/app/page.tsx567
-rw-r--r--packages/ui/memory-graph/graph-canvas.tsx10
-rw-r--r--packages/ui/memory-graph/graph-webgl-canvas.tsx10
-rw-r--r--packages/ui/memory-graph/hooks/use-graph-interactions.ts444
-rw-r--r--packages/ui/memory-graph/memory-graph.tsx53
-rw-r--r--packages/ui/memory-graph/navigation-controls.tsx67
-rw-r--r--packages/ui/memory-graph/types.ts3
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[];