mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-17 12:20:04 +00:00
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
471 lines
12 KiB
TypeScript
471 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { GlassMenuEffect } from "@repo/ui/other/glass-effect";
|
|
import { AnimatePresence } from "motion/react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { colors } from "./constants";
|
|
import { GraphWebGLCanvas as GraphCanvas } from "./graph-webgl-canvas";
|
|
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";
|
|
|
|
import type { MemoryGraphProps } from "./types";
|
|
|
|
export const MemoryGraph = ({
|
|
children,
|
|
documents,
|
|
isLoading,
|
|
isLoadingMore,
|
|
error,
|
|
totalLoaded,
|
|
hasMore,
|
|
loadMoreDocuments,
|
|
showSpacesSelector,
|
|
variant = "console",
|
|
legendId,
|
|
highlightDocumentIds = [],
|
|
highlightsVisible = true,
|
|
occludedRightPx = 0,
|
|
autoLoadOnViewport = true,
|
|
}: MemoryGraphProps) => {
|
|
// Derive showSpacesSelector from variant if not explicitly provided
|
|
// console variant shows spaces selector, consumer variant hides it
|
|
const finalShowSpacesSelector = showSpacesSelector ?? variant === "console";
|
|
|
|
const [selectedSpace, setSelectedSpace] = useState<string>("all");
|
|
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Create data object with dummy pagination to satisfy type requirements
|
|
const data = useMemo(() => {
|
|
return documents && documents.length > 0
|
|
? {
|
|
documents,
|
|
pagination: {
|
|
currentPage: 1,
|
|
limit: documents.length,
|
|
totalItems: documents.length,
|
|
totalPages: 1,
|
|
},
|
|
}
|
|
: null;
|
|
}, [documents]);
|
|
|
|
// Graph interactions with variant-specific settings
|
|
const {
|
|
panX,
|
|
panY,
|
|
zoom,
|
|
/** hoveredNode currently unused within this component */
|
|
hoveredNode: _hoveredNode,
|
|
selectedNode,
|
|
draggingNodeId,
|
|
nodePositions,
|
|
handlePanStart,
|
|
handlePanMove,
|
|
handlePanEnd,
|
|
handleWheel,
|
|
handleNodeHover,
|
|
handleNodeClick,
|
|
handleNodeDragStart,
|
|
handleNodeDragMove,
|
|
handleNodeDragEnd,
|
|
handleDoubleClick,
|
|
handleTouchStart,
|
|
handleTouchMove,
|
|
handleTouchEnd,
|
|
setSelectedNode,
|
|
autoFitToViewport,
|
|
centerViewportOn,
|
|
zoomIn,
|
|
zoomOut,
|
|
} = useGraphInteractions(variant);
|
|
|
|
// Graph data
|
|
const { nodes, edges } = useGraphData(
|
|
data,
|
|
selectedSpace,
|
|
nodePositions,
|
|
draggingNodeId,
|
|
);
|
|
|
|
// Auto-fit once per unique highlight set to show the full graph for context
|
|
const lastFittedHighlightKeyRef = useRef<string>("");
|
|
useEffect(() => {
|
|
const highlightKey = highlightsVisible
|
|
? highlightDocumentIds.join("|")
|
|
: "";
|
|
if (
|
|
highlightKey &&
|
|
highlightKey !== lastFittedHighlightKeyRef.current &&
|
|
containerSize.width > 0 &&
|
|
containerSize.height > 0 &&
|
|
nodes.length > 0
|
|
) {
|
|
autoFitToViewport(nodes, containerSize.width, containerSize.height, {
|
|
occludedRightPx,
|
|
animate: true,
|
|
});
|
|
lastFittedHighlightKeyRef.current = highlightKey;
|
|
}
|
|
}, [
|
|
highlightsVisible,
|
|
highlightDocumentIds,
|
|
containerSize.width,
|
|
containerSize.height,
|
|
nodes.length,
|
|
occludedRightPx,
|
|
autoFitToViewport,
|
|
]);
|
|
|
|
// Auto-fit graph when component mounts or nodes change significantly
|
|
const hasAutoFittedRef = useRef(false);
|
|
useEffect(() => {
|
|
// Only auto-fit once when we have nodes and container size
|
|
if (
|
|
!hasAutoFittedRef.current &&
|
|
nodes.length > 0 &&
|
|
containerSize.width > 0 &&
|
|
containerSize.height > 0
|
|
) {
|
|
// Auto-fit to show all content for both variants
|
|
// Add a small delay to ensure the canvas is fully initialized
|
|
const timer = setTimeout(() => {
|
|
autoFitToViewport(nodes, containerSize.width, containerSize.height);
|
|
hasAutoFittedRef.current = true;
|
|
}, 100);
|
|
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [nodes, containerSize.width, containerSize.height, autoFitToViewport]);
|
|
|
|
// Reset auto-fit flag when nodes array becomes empty (switching views)
|
|
useEffect(() => {
|
|
if (nodes.length === 0) {
|
|
hasAutoFittedRef.current = false;
|
|
}
|
|
}, [nodes.length]);
|
|
|
|
// Extract unique spaces from memories and calculate counts
|
|
const { availableSpaces, spaceMemoryCounts } = useMemo(() => {
|
|
if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} };
|
|
|
|
const spaceSet = new Set<string>();
|
|
const counts: Record<string, number> = {};
|
|
|
|
data.documents.forEach((doc) => {
|
|
doc.memoryEntries.forEach((memory) => {
|
|
const spaceId = memory.spaceContainerTag || memory.spaceId || "default";
|
|
spaceSet.add(spaceId);
|
|
counts[spaceId] = (counts[spaceId] || 0) + 1;
|
|
});
|
|
});
|
|
|
|
return {
|
|
availableSpaces: Array.from(spaceSet).sort(),
|
|
spaceMemoryCounts: counts,
|
|
};
|
|
}, [data]);
|
|
|
|
// Handle container resize
|
|
useEffect(() => {
|
|
const updateSize = () => {
|
|
if (containerRef.current) {
|
|
const newWidth = containerRef.current.clientWidth;
|
|
const newHeight = containerRef.current.clientHeight;
|
|
|
|
// Only update if size actually changed and is valid
|
|
setContainerSize((prev) => {
|
|
if (prev.width !== newWidth || prev.height !== newHeight) {
|
|
return { width: newWidth, height: newHeight };
|
|
}
|
|
return prev;
|
|
});
|
|
}
|
|
};
|
|
|
|
// Use a slight delay to ensure DOM is fully rendered
|
|
const timer = setTimeout(updateSize, 0);
|
|
updateSize(); // Also call immediately
|
|
|
|
window.addEventListener("resize", updateSize);
|
|
|
|
// Use ResizeObserver for more accurate container size detection
|
|
const resizeObserver = new ResizeObserver(updateSize);
|
|
if (containerRef.current) {
|
|
resizeObserver.observe(containerRef.current);
|
|
}
|
|
|
|
return () => {
|
|
clearTimeout(timer);
|
|
window.removeEventListener("resize", updateSize);
|
|
resizeObserver.disconnect();
|
|
};
|
|
}, []);
|
|
|
|
// Enhanced node drag start that includes nodes data
|
|
const handleNodeDragStartWithNodes = useCallback(
|
|
(nodeId: string, e: React.MouseEvent) => {
|
|
handleNodeDragStart(nodeId, e, nodes);
|
|
},
|
|
[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;
|
|
return nodes.find((n) => n.id === selectedNode) || null;
|
|
}, [selectedNode, nodes]);
|
|
|
|
// Viewport-based loading: load more when most documents are visible (optional)
|
|
const checkAndLoadMore = useCallback(() => {
|
|
if (
|
|
isLoadingMore ||
|
|
!hasMore ||
|
|
!data?.documents ||
|
|
data.documents.length === 0
|
|
)
|
|
return;
|
|
|
|
// Calculate viewport bounds
|
|
const viewportBounds = {
|
|
left: -panX / zoom - 200,
|
|
right: (-panX + containerSize.width) / zoom + 200,
|
|
top: -panY / zoom - 200,
|
|
bottom: (-panY + containerSize.height) / zoom + 200,
|
|
};
|
|
|
|
// Count visible documents
|
|
const visibleDocuments = data.documents.filter((doc) => {
|
|
const docNodes = nodes.filter(
|
|
(node) => node.type === "document" && node.data.id === doc.id,
|
|
);
|
|
return docNodes.some(
|
|
(node) =>
|
|
node.x >= viewportBounds.left &&
|
|
node.x <= viewportBounds.right &&
|
|
node.y >= viewportBounds.top &&
|
|
node.y <= viewportBounds.bottom,
|
|
);
|
|
});
|
|
|
|
// If 80% or more of documents are visible, load more
|
|
const visibilityRatio = visibleDocuments.length / data.documents.length;
|
|
if (visibilityRatio >= 0.8) {
|
|
loadMoreDocuments();
|
|
}
|
|
}, [
|
|
isLoadingMore,
|
|
hasMore,
|
|
data,
|
|
panX,
|
|
panY,
|
|
zoom,
|
|
containerSize.width,
|
|
containerSize.height,
|
|
nodes,
|
|
loadMoreDocuments,
|
|
]);
|
|
|
|
// Throttled version to avoid excessive checks
|
|
const lastLoadCheckRef = useRef(0);
|
|
const throttledCheckAndLoadMore = useCallback(() => {
|
|
const now = Date.now();
|
|
if (now - lastLoadCheckRef.current > 1000) {
|
|
// Check at most once per second
|
|
lastLoadCheckRef.current = now;
|
|
checkAndLoadMore();
|
|
}
|
|
}, [checkAndLoadMore]);
|
|
|
|
// Monitor viewport changes to trigger loading
|
|
useEffect(() => {
|
|
if (!autoLoadOnViewport) return;
|
|
throttledCheckAndLoadMore();
|
|
}, [throttledCheckAndLoadMore, autoLoadOnViewport]);
|
|
|
|
// Initial load trigger when graph is first rendered
|
|
useEffect(() => {
|
|
if (!autoLoadOnViewport) return;
|
|
if (data?.documents && data.documents.length > 0 && hasMore) {
|
|
// Start loading more documents after initial render
|
|
setTimeout(() => {
|
|
throttledCheckAndLoadMore();
|
|
}, 500); // Small delay to allow initial layout
|
|
}
|
|
}, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport]);
|
|
|
|
if (error) {
|
|
return (
|
|
<div
|
|
className="h-full flex items-center justify-center"
|
|
style={{ backgroundColor: colors.background.primary }}
|
|
>
|
|
<div className="rounded-xl overflow-hidden">
|
|
{/* Glass effect background */}
|
|
<GlassMenuEffect rounded="rounded-xl" />
|
|
|
|
<div className="relative z-10 text-slate-200 px-6 py-4">
|
|
Error loading documents: {error.message}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="h-full rounded-xl overflow-hidden"
|
|
style={{ backgroundColor: colors.background.primary }}
|
|
>
|
|
{/* Spaces selector - only shown for console */}
|
|
{finalShowSpacesSelector && availableSpaces.length > 0 && (
|
|
<div className="absolute top-4 left-4 z-10">
|
|
<SpacesDropdown
|
|
availableSpaces={availableSpaces}
|
|
onSpaceChange={setSelectedSpace}
|
|
selectedSpace={selectedSpace}
|
|
spaceMemoryCounts={spaceMemoryCounts}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Loading indicator */}
|
|
<LoadingIndicator
|
|
isLoading={isLoading}
|
|
isLoadingMore={isLoadingMore}
|
|
totalLoaded={totalLoaded}
|
|
variant={variant}
|
|
/>
|
|
|
|
{/* Legend */}
|
|
<Legend
|
|
edges={edges}
|
|
id={legendId}
|
|
isLoading={isLoading}
|
|
nodes={nodes}
|
|
variant={variant}
|
|
/>
|
|
|
|
{/* Node detail panel */}
|
|
<AnimatePresence>
|
|
{selectedNodeData && (
|
|
<NodeDetailPanel
|
|
node={selectedNodeData}
|
|
onClose={() => setSelectedNode(null)}
|
|
variant={variant}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Show welcome screen when no memories exist */}
|
|
{!isLoading &&
|
|
(!data || !nodes.some((n) => n.type === "document")) &&
|
|
children}
|
|
|
|
{/* Graph container */}
|
|
<div
|
|
className="w-full h-full relative overflow-hidden"
|
|
ref={containerRef}
|
|
style={{
|
|
touchAction: "none",
|
|
userSelect: "none",
|
|
WebkitUserSelect: "none",
|
|
}}
|
|
>
|
|
{containerSize.width > 0 && containerSize.height > 0 && (
|
|
<GraphCanvas
|
|
draggingNodeId={draggingNodeId}
|
|
edges={edges}
|
|
height={containerSize.height}
|
|
nodes={nodes}
|
|
highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []}
|
|
onDoubleClick={handleDoubleClick}
|
|
onNodeClick={handleNodeClick}
|
|
onNodeDragEnd={handleNodeDragEnd}
|
|
onNodeDragMove={handleNodeDragMove}
|
|
onNodeDragStart={handleNodeDragStartWithNodes}
|
|
onNodeHover={handleNodeHover}
|
|
onPanEnd={handlePanEnd}
|
|
onPanMove={handlePanMove}
|
|
onPanStart={handlePanStart}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
onWheel={handleWheel}
|
|
panX={panX}
|
|
panY={panY}
|
|
width={containerSize.width}
|
|
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>
|
|
);
|
|
};
|