diff options
Diffstat (limited to 'packages/memory-graph/src/components/memory-graph.tsx')
| -rw-r--r-- | packages/memory-graph/src/components/memory-graph.tsx | 284 |
1 files changed, 159 insertions, 125 deletions
diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index 21d4a08f..8f356d2f 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -1,21 +1,21 @@ -"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 { injectStyles } from "@/lib/inject-styles"; -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 { defaultTheme } from "@/styles/theme.css"; - -import type { MemoryGraphProps } from "@/types"; +"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 { injectStyles } from "@/lib/inject-styles" +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 { defaultTheme } from "@/styles/theme.css" + +import type { MemoryGraphProps } from "@/types" export const MemoryGraph = ({ children, @@ -34,23 +34,45 @@ export const MemoryGraph = ({ occludedRightPx = 0, autoLoadOnViewport = true, themeClassName, + selectedSpace: externalSelectedSpace, + onSpaceChange: externalOnSpaceChange, + memoryLimit, + isExperimental, }: MemoryGraphProps) => { // Inject styles on first render (client-side only) useEffect(() => { - injectStyles(); - }, []); + injectStyles() + }, []) // Derive totalLoaded from documents if not provided - const effectiveTotalLoaded = totalLoaded ?? documents.length; + const effectiveTotalLoaded = totalLoaded ?? documents.length // No-op for loadMoreDocuments if not provided - const effectiveLoadMoreDocuments = loadMoreDocuments ?? (async () => {}); + const effectiveLoadMoreDocuments = loadMoreDocuments ?? (async () => {}) // Derive showSpacesSelector from variant if not explicitly provided // console variant shows spaces selector, consumer variant hides it - const finalShowSpacesSelector = showSpacesSelector ?? (variant === "console"); + const finalShowSpacesSelector = showSpacesSelector ?? variant === "console" - const [selectedSpace, setSelectedSpace] = useState<string>("all"); - const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); - const containerRef = useRef<HTMLDivElement>(null); + // Internal state for controlled/uncontrolled pattern + const [internalSelectedSpace, setInternalSelectedSpace] = + useState<string>("all") + + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) + const containerRef = useRef<HTMLDivElement>(null) + + // Use external state if provided, otherwise use internal state + const selectedSpace = externalSelectedSpace ?? internalSelectedSpace + + // Handle space change + const handleSpaceChange = useCallback( + (spaceId: string) => { + if (externalOnSpaceChange) { + externalOnSpaceChange(spaceId) + } else { + setInternalSelectedSpace(spaceId) + } + }, + [externalOnSpaceChange], + ) // Create data object with pagination to satisfy type requirements const data = useMemo(() => { @@ -64,8 +86,8 @@ export const MemoryGraph = ({ totalPages: 1, }, } - : null; - }, [documents]); + : null + }, [documents]) // Graph interactions with variant-specific settings const { @@ -95,7 +117,7 @@ export const MemoryGraph = ({ centerViewportOn, zoomIn, zoomOut, - } = useGraphInteractions(variant); + } = useGraphInteractions(variant) // Graph data const { nodes, edges } = useGraphData( @@ -103,14 +125,13 @@ export const MemoryGraph = ({ selectedSpace, nodePositions, draggingNodeId, - ); + memoryLimit, + ) // Auto-fit once per unique highlight set to show the full graph for context - const lastFittedHighlightKeyRef = useRef<string>(""); + const lastFittedHighlightKeyRef = useRef<string>("") useEffect(() => { - const highlightKey = highlightsVisible - ? highlightDocumentIds.join("|") - : ""; + const highlightKey = highlightsVisible ? highlightDocumentIds.join("|") : "" if ( highlightKey && highlightKey !== lastFittedHighlightKeyRef.current && @@ -121,8 +142,8 @@ export const MemoryGraph = ({ autoFitToViewport(nodes, containerSize.width, containerSize.height, { occludedRightPx, animate: true, - }); - lastFittedHighlightKeyRef.current = highlightKey; + }) + lastFittedHighlightKeyRef.current = highlightKey } }, [ highlightsVisible, @@ -132,10 +153,10 @@ export const MemoryGraph = ({ nodes.length, occludedRightPx, autoFitToViewport, - ]); + ]) // Auto-fit graph when component mounts or nodes change significantly - const hasAutoFittedRef = useRef(false); + const hasAutoFittedRef = useRef(false) useEffect(() => { // Only auto-fit once when we have nodes and container size if ( @@ -147,90 +168,85 @@ export const MemoryGraph = ({ // 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); + autoFitToViewport(nodes, containerSize.width, containerSize.height) + hasAutoFittedRef.current = true + }, 100) + + return () => clearTimeout(timer) } - }, [ - nodes, - containerSize.width, - containerSize.height, - autoFitToViewport, - ]); + }, [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; + hasAutoFittedRef.current = false } - }, [nodes.length]); + }, [nodes.length]) // Extract unique spaces from memories and calculate counts const { availableSpaces, spaceMemoryCounts } = useMemo(() => { - if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} }; + if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} } - const spaceSet = new Set<string>(); - const counts: Record<string, number> = {}; + 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; - }); - }); + 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]); + } + }, [data]) // Handle container resize useEffect(() => { const updateSize = () => { if (containerRef.current) { - const newWidth = containerRef.current.clientWidth; - const newHeight = containerRef.current.clientHeight; - + 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 { width: newWidth, height: newHeight } } - return prev; - }); + 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); - + 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); + const resizeObserver = new ResizeObserver(updateSize) if (containerRef.current) { - resizeObserver.observe(containerRef.current); + resizeObserver.observe(containerRef.current) } - + return () => { - clearTimeout(timer); - window.removeEventListener("resize", updateSize); - resizeObserver.disconnect(); - }; - }, []); + 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(nodeId, e, nodes) }, [handleNodeDragStart, nodes], - ); + ) // Navigation callbacks const handleCenter = useCallback(() => { @@ -239,35 +255,50 @@ export const MemoryGraph = ({ 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) + 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) { + 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]) + }, [ + 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]); + 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(() => { @@ -277,7 +308,7 @@ export const MemoryGraph = ({ !data?.documents || data.documents.length === 0 ) - return; + return // Calculate viewport bounds const viewportBounds = { @@ -285,26 +316,26 @@ export const MemoryGraph = ({ 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; + const visibilityRatio = visibleDocuments.length / data.documents.length if (visibilityRatio >= 0.8) { - effectiveLoadMoreDocuments(); + effectiveLoadMoreDocuments() } }, [ isLoadingMore, @@ -317,35 +348,35 @@ export const MemoryGraph = ({ containerSize.height, nodes, effectiveLoadMoreDocuments, - ]); + ]) // Throttled version to avoid excessive checks - const lastLoadCheckRef = useRef(0); + const lastLoadCheckRef = useRef(0) const throttledCheckAndLoadMore = useCallback(() => { - const now = Date.now(); + const now = Date.now() if (now - lastLoadCheckRef.current > 1000) { // Check at most once per second - lastLoadCheckRef.current = now; - checkAndLoadMore(); + lastLoadCheckRef.current = now + checkAndLoadMore() } - }, [checkAndLoadMore]); + }, [checkAndLoadMore]) // Monitor viewport changes to trigger loading useEffect(() => { - if (!autoLoadOnViewport) return; - throttledCheckAndLoadMore(); - }, [throttledCheckAndLoadMore, autoLoadOnViewport]); + if (!autoLoadOnViewport) return + throttledCheckAndLoadMore() + }, [throttledCheckAndLoadMore, autoLoadOnViewport]) // Initial load trigger when graph is first rendered useEffect(() => { - if (!autoLoadOnViewport) return; + 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 + throttledCheckAndLoadMore() + }, 500) // Small delay to allow initial layout } - }, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport]); + }, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport]) if (error) { return ( @@ -359,17 +390,19 @@ export const MemoryGraph = ({ </div> </div> </div> - ); + ) } return ( - <div className={`${themeClassName ?? defaultTheme} ${styles.mainContainer}`}> - {/* Spaces selector - only shown for console */} - {finalShowSpacesSelector && availableSpaces.length > 0 && ( + <div + className={`${themeClassName ?? defaultTheme} ${styles.mainContainer}`} + > + {/* Spaces selector - only shown for console variant */} + {variant === "console" && availableSpaces.length > 0 && ( <div className={styles.spacesSelectorContainer}> <SpacesDropdown availableSpaces={availableSpaces} - onSpaceChange={setSelectedSpace} + onSpaceChange={handleSpaceChange} selectedSpace={selectedSpace} spaceMemoryCounts={spaceMemoryCounts} /> @@ -411,11 +444,8 @@ export const MemoryGraph = ({ )} {/* Graph container */} - <div - className={styles.graphContainer} - ref={containerRef} - > - {(containerSize.width > 0 && containerSize.height > 0) && ( + <div className={styles.graphContainer} ref={containerRef}> + {containerSize.width > 0 && containerSize.height > 0 && ( <GraphCanvas draggingNodeId={draggingNodeId} edges={edges} @@ -446,8 +476,12 @@ export const MemoryGraph = ({ {containerSize.width > 0 && ( <NavigationControls onCenter={handleCenter} - onZoomIn={() => zoomIn(containerSize.width / 2, containerSize.height / 2)} - onZoomOut={() => zoomOut(containerSize.width / 2, containerSize.height / 2)} + onZoomIn={() => + zoomIn(containerSize.width / 2, containerSize.height / 2) + } + onZoomOut={() => + zoomOut(containerSize.width / 2, containerSize.height / 2) + } onAutoFit={handleAutoFit} nodes={nodes} className={styles.navControlsContainer} @@ -455,5 +489,5 @@ export const MemoryGraph = ({ )} </div> </div> - ); -}; + ) +} |