mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-09 03:18:04 +00:00
BETTER GRAPH (#398)
Co-authored-by: Dhravya Shah <dhravya@supermemory.com>
This commit is contained in:
parent
5b7f9ceb44
commit
93af958767
7 changed files with 784 additions and 342 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
67
packages/ui/memory-graph/navigation-controls.tsx
Normal file
67
packages/ui/memory-graph/navigation-controls.tsx
Normal 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"
|
||||
|
|
@ -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[];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue