diff options
| author | nexxeln <[email protected]> | 2025-11-19 18:57:55 +0000 |
|---|---|---|
| committer | nexxeln <[email protected]> | 2025-11-19 18:57:56 +0000 |
| commit | 5e24eb66c3ca7d2224d0d1f7837cda17015f5fcb (patch) | |
| tree | 60336fd37b41e3597065729d098877483eba73b6 /packages/memory-graph/src/components/memory-graph.tsx | |
| parent | Fix: Prevent multiple prompts while AI response is generated (fixes #538) (#583) (diff) | |
| download | supermemory-5e24eb66c3ca7d2224d0d1f7837cda17015f5fcb.tar.xz supermemory-5e24eb66c3ca7d2224d0d1f7837cda17015f5fcb.zip | |
package the graph (#563)shoubhit/eng-358-packaging-graph-component
includes:
- a package that contains a MemoryGraph component which handles fetching data and rendering the graph
- a playground to test the package
problems:
- the bundle size is huge
- the styles are kinda broken? we are using [https://www.npmjs.com/package/vite-plugin-libgi-inject-css](https://www.npmjs.com/package/vite-plugin-lib-inject-css) to inject the styles

Diffstat (limited to 'packages/memory-graph/src/components/memory-graph.tsx')
| -rw-r--r-- | packages/memory-graph/src/components/memory-graph.tsx | 448 |
1 files changed, 448 insertions, 0 deletions
diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx new file mode 100644 index 00000000..3eeed37b --- /dev/null +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -0,0 +1,448 @@ +"use client"; + +import { GlassMenuEffect } from "@/ui/glass-effect"; +import { AnimatePresence } from "motion/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { GraphCanvas } from "./graph-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 * as styles from "./memory-graph.css"; + +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, + themeClassName, +}: 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 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={styles.errorContainer}> + <div className={styles.errorCard}> + {/* Glass effect background */} + <GlassMenuEffect rounded="xl" /> + + <div className={styles.errorContent}> + Error loading documents: {error.message} + </div> + </div> + </div> + ); + } + + return ( + <div className={themeClassName ? `${themeClassName} ${styles.mainContainer}` : styles.mainContainer}> + {/* Spaces selector - only shown for console */} + {finalShowSpacesSelector && availableSpaces.length > 0 && ( + <div className={styles.spacesSelectorContainer}> + <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.filter((n) => n.type === "document").length === 0) && ( + <>{children}</> + )} + + {/* Graph container */} + <div + className={styles.graphContainer} + ref={containerRef} + > + {(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={styles.navControlsContainer} + /> + )} + </div> + </div> + ); +}; |