BETTER GRAPH (#398)

Co-authored-by: Dhravya Shah <dhravya@supermemory.com>
This commit is contained in:
Dhravya Shah 2025-08-29 17:10:11 -07:00 • committed by GitHub
parent 5b7f9ceb44
commit 93af958767
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 784 additions and 342 deletions

View file

@ -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>
<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>
<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>
{/* 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>
{/* 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>
<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>
<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>
<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>
{/* 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>
{/* 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)}
/>
)}
{showAddMemoryView && (
<AddMemoryView
initialTab="note"
onClose={() => setShowAddMemoryView(false)}
/>
)}
{/* Tour Alert Dialog */}
<TourAlertDialog onOpenChange={setShowTourDialog} open={showTourDialog} />
{/* Tour Alert Dialog */}
<TourAlertDialog onOpenChange={setShowTourDialog} open={showTourDialog} />
{/* Referral/Upgrade Modal */}
<ReferralUpgradeModal
isOpen={showReferralModal}
onClose={() => setShowReferralModal(false)}
/>
</div>
);
{/* 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 {

View file

@ -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

View file

@ -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"

View file

@ -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<string | null>(null);
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [draggingNodeId, setDraggingNodeId] = useState<string | null>(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<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 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,
};
}
}

View file

@ -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>
);

View file

@ -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"

View file

@ -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[];