diff options
| author | Vidya Rupak <[email protected]> | 2025-12-28 11:02:26 -0800 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-12-29 00:32:26 +0530 |
| commit | d93ffbb93f448236631bb39b7c8cc8dd6b99a573 (patch) | |
| tree | 187800546d5bdddb61d78682f7207e97023ac94e /packages/memory-graph/src/components/memory-graph.tsx | |
| parent | icon in overview (diff) | |
| download | supermemory-d93ffbb93f448236631bb39b7c8cc8dd6b99a573.tar.xz supermemory-d93ffbb93f448236631bb39b7c8cc8dd6b99a573.zip | |
MemoryGraph - revamped (#627)
Diffstat (limited to 'packages/memory-graph/src/components/memory-graph.tsx')
| -rw-r--r-- | packages/memory-graph/src/components/memory-graph.tsx | 313 |
1 files changed, 297 insertions, 16 deletions
diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index 8f356d2f..b8dd493d 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -2,15 +2,17 @@ import { GlassMenuEffect } from "@/ui/glass-effect" import { AnimatePresence } from "motion/react" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react" import { GraphCanvas } from "./graph-canvas" import { useGraphData } from "@/hooks/use-graph-data" import { useGraphInteractions } from "@/hooks/use-graph-interactions" +import { useForceSimulation } from "@/hooks/use-force-simulation" 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 { NodePopover } from "./node-popover" import { SpacesDropdown } from "./spaces-dropdown" import * as styles from "./memory-graph.css" import { defaultTheme } from "@/styles/theme.css" @@ -37,7 +39,12 @@ export const MemoryGraph = ({ selectedSpace: externalSelectedSpace, onSpaceChange: externalOnSpaceChange, memoryLimit, + maxNodes, isExperimental, + // Slideshow control + isSlideshowActive = false, + onSlideshowNodeChange, + onSlideshowStop, }: MemoryGraphProps) => { // Inject styles on first render (client-side only) useEffect(() => { @@ -126,6 +133,31 @@ export const MemoryGraph = ({ nodePositions, draggingNodeId, memoryLimit, + maxNodes, + ) + + // State to trigger re-renders when simulation ticks + const [, forceRender] = useReducer((x: number) => x + 1, 0) + + // Track drag state for physics integration + const dragStateRef = useRef<{ + nodeId: string | null + startX: number + startY: number + nodeStartX: number + nodeStartY: number + }>({ nodeId: null, startX: 0, startY: 0, nodeStartX: 0, nodeStartY: 0 }) + + // Force simulation - only runs during interactions (drag) + const forceSimulation = useForceSimulation( + nodes, + edges, + () => { + // On each tick, trigger a re-render + // D3 directly mutates node.x and node.y + forceRender() + }, + true, // enabled ) // Auto-fit once per unique highlight set to show the full graph for context @@ -240,12 +272,91 @@ export const MemoryGraph = ({ } }, []) - // Enhanced node drag start that includes nodes data + // Physics-enabled node drag start const handleNodeDragStartWithNodes = useCallback( (nodeId: string, e: React.MouseEvent) => { + // Find the node being dragged + const node = nodes.find((n) => n.id === nodeId) + if (node) { + // Store drag start state + dragStateRef.current = { + nodeId, + startX: e.clientX, + startY: e.clientY, + nodeStartX: node.x, + nodeStartY: node.y, + } + + // Pin the node at its current position (d3-force pattern) + node.fx = node.x + node.fy = node.y + + // Reheat simulation immediately (like d3 reference code) + forceSimulation.reheat() + } + + // Set dragging state (still need this for visual feedback) handleNodeDragStart(nodeId, e, nodes) }, - [handleNodeDragStart, nodes], + [handleNodeDragStart, nodes, forceSimulation], + ) + + // Physics-enabled node drag move + const handleNodeDragMoveWithNodes = useCallback( + (e: React.MouseEvent) => { + if (draggingNodeId && dragStateRef.current.nodeId === draggingNodeId) { + // Update the fixed position during drag (this is what d3 uses) + const node = nodes.find((n) => n.id === draggingNodeId) + if (node) { + // Calculate new position based on drag delta + const deltaX = (e.clientX - dragStateRef.current.startX) / zoom + const deltaY = (e.clientY - dragStateRef.current.startY) / zoom + + // Update subject position (matches d3 reference code pattern) + // Only update fx/fy, let simulation handle x/y + node.fx = dragStateRef.current.nodeStartX + deltaX + node.fy = dragStateRef.current.nodeStartY + deltaY + } + } + }, + [nodes, draggingNodeId, zoom], + ) + + // Physics-enabled node drag end + const handleNodeDragEndWithPhysics = useCallback(() => { + if (draggingNodeId) { + // Unpin the node (allow physics to take over) - matches d3 reference code + const node = nodes.find((n) => n.id === draggingNodeId) + if (node) { + node.fx = null + node.fy = null + } + + // Cool down the simulation (restore target alpha to 0) + forceSimulation.coolDown() + + // Reset drag state + dragStateRef.current = { + nodeId: null, + startX: 0, + startY: 0, + nodeStartX: 0, + nodeStartY: 0, + } + } + + // Call original handler to clear dragging state + handleNodeDragEnd() + }, [draggingNodeId, nodes, forceSimulation, handleNodeDragEnd]) + + // Physics-aware node click - let simulation continue naturally + const handleNodeClickWithPhysics = useCallback( + (nodeId: string) => { + // Just call original handler to update selected node state + // Don't stop the simulation - let it cool down naturally + handleNodeClick(nodeId) + }, + [handleNodeClick], ) // Navigation callbacks @@ -300,6 +411,54 @@ export const MemoryGraph = ({ return nodes.find((n) => n.id === selectedNode) || null }, [selectedNode, nodes]) + // Calculate popover position (memoized for performance) + const popoverPosition = useMemo(() => { + if (!selectedNodeData) return null + + // Calculate screen position of the node + const screenX = selectedNodeData.x * zoom + panX + const screenY = selectedNodeData.y * zoom + panY + + // Popover dimensions (estimated) + const popoverWidth = 320 + const popoverHeight = 400 + const padding = 16 + + // Calculate node dimensions to position popover with proper gap + const nodeSize = selectedNodeData.size * zoom + const nodeWidth = selectedNodeData.type === "document" ? nodeSize * 1.4 : nodeSize + const nodeHeight = selectedNodeData.type === "document" ? nodeSize * 0.9 : nodeSize + const gap = 20 // Gap between node and popover + + // Smart positioning: flip to other side if would go off-screen + let popoverX = screenX + nodeWidth / 2 + gap + let popoverY = screenY - popoverHeight / 2 + + // Check right edge + if (popoverX + popoverWidth > containerSize.width - padding) { + // Flip to left side of node + popoverX = screenX - nodeWidth / 2 - gap - popoverWidth + } + + // Check left edge + if (popoverX < padding) { + popoverX = padding + } + + // Check bottom edge + if (popoverY + popoverHeight > containerSize.height - padding) { + // Move up + popoverY = containerSize.height - popoverHeight - padding + } + + // Check top edge + if (popoverY < padding) { + popoverY = padding + } + + return { x: popoverX, y: popoverY } + }, [selectedNodeData, zoom, panX, panY, containerSize.width, containerSize.height]) + // Viewport-based loading: load more when most documents are visible (optional) const checkAndLoadMore = useCallback(() => { if ( @@ -378,6 +537,125 @@ export const MemoryGraph = ({ } }, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport]) + // Slideshow logic - simulate actual node clicks with physics + const slideshowIntervalRef = useRef<NodeJS.Timeout | null>(null) + const physicsTimeoutRef = useRef<NodeJS.Timeout | null>(null) + const lastSelectedIndexRef = useRef<number>(-1) + const isSlideshowActiveRef = useRef(isSlideshowActive) + + // Update slideshow active ref + useEffect(() => { + isSlideshowActiveRef.current = isSlideshowActive + }, [isSlideshowActive]) + + // Use refs to store current values without triggering re-renders + const nodesRef = useRef(nodes) + const handleNodeClickRef = useRef(handleNodeClick) + const centerViewportOnRef = useRef(centerViewportOn) + const containerSizeRef = useRef(containerSize) + const onSlideshowNodeChangeRef = useRef(onSlideshowNodeChange) + const forceSimulationRef = useRef(forceSimulation) + + // Update refs when values change + useEffect(() => { + nodesRef.current = nodes + handleNodeClickRef.current = handleNodeClick + centerViewportOnRef.current = centerViewportOn + containerSizeRef.current = containerSize + onSlideshowNodeChangeRef.current = onSlideshowNodeChange + forceSimulationRef.current = forceSimulation + }, [nodes, handleNodeClick, centerViewportOn, containerSize, onSlideshowNodeChange, forceSimulation]) + + useEffect(() => { + // Clear any existing interval and timeout when isSlideshowActive changes + if (slideshowIntervalRef.current) { + clearInterval(slideshowIntervalRef.current) + slideshowIntervalRef.current = null + } + if (physicsTimeoutRef.current) { + clearTimeout(physicsTimeoutRef.current) + physicsTimeoutRef.current = null + } + + if (!isSlideshowActive) { + // Close the popover when stopping slideshow + setSelectedNode(null) + // Explicitly cool down physics simulation in case timeout hasn't fired yet + forceSimulation.coolDown() + return + } + + // Select a random node (avoid selecting the same one twice in a row) + const selectRandomNode = () => { + // Double-check slideshow is still active + if (!isSlideshowActiveRef.current) return + + const currentNodes = nodesRef.current + if (currentNodes.length === 0) return + + let randomIndex: number + // If we have more than one node, avoid selecting the same one + if (currentNodes.length > 1) { + do { + randomIndex = Math.floor(Math.random() * currentNodes.length) + } while (randomIndex === lastSelectedIndexRef.current) + } else { + randomIndex = 0 + } + + lastSelectedIndexRef.current = randomIndex + const randomNode = currentNodes[randomIndex] + + if (randomNode) { + // Smoothly pan to the node first + centerViewportOnRef.current( + randomNode.x, + randomNode.y, + containerSizeRef.current.width, + containerSizeRef.current.height, + ) + + // Simulate the actual node click (triggers dimming and popover) + handleNodeClickRef.current(randomNode.id) + + // Trigger physics animation briefly + forceSimulationRef.current.reheat() + + // Cool down physics after 1 second (cleanup old timeout first) + if (physicsTimeoutRef.current) { + clearTimeout(physicsTimeoutRef.current) + } + physicsTimeoutRef.current = setTimeout(() => { + // Only cool down if slideshow is still active or if this is cleanup + forceSimulationRef.current.coolDown() + physicsTimeoutRef.current = null + }, 1000) + + // Notify parent component + onSlideshowNodeChangeRef.current?.(randomNode.id) + } + } + + // Start immediately + selectRandomNode() + + // Set interval for subsequent selections (3.5 seconds) + slideshowIntervalRef.current = setInterval(() => { + selectRandomNode() + }, 3500) + + return () => { + if (slideshowIntervalRef.current) { + clearInterval(slideshowIntervalRef.current) + slideshowIntervalRef.current = null + } + if (physicsTimeoutRef.current) { + clearTimeout(physicsTimeoutRef.current) + physicsTimeoutRef.current = null + } + } + }, [isSlideshowActive]) // Only depend on isSlideshowActive + if (error) { return ( <div className={styles.errorContainer}> @@ -426,16 +704,17 @@ export const MemoryGraph = ({ variant={variant} /> - {/* Node detail panel */} - <AnimatePresence> - {selectedNodeData && ( - <NodeDetailPanel - node={selectedNodeData} - onClose={() => setSelectedNode(null)} - variant={variant} - /> - )} - </AnimatePresence> + {/* Node popover - positioned near clicked node */} + {selectedNodeData && popoverPosition && ( + <NodePopover + node={selectedNodeData} + x={popoverPosition.x} + y={popoverPosition.y} + onClose={() => setSelectedNode(null)} + containerBounds={containerRef.current?.getBoundingClientRect()} + onBackdropClick={isSlideshowActive ? onSlideshowStop : undefined} + /> + )} {/* Show welcome screen when no memories exist */} {!isLoading && @@ -452,10 +731,11 @@ export const MemoryGraph = ({ height={containerSize.height} nodes={nodes} highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []} + isSimulationActive={forceSimulation.isActive()} onDoubleClick={handleDoubleClick} - onNodeClick={handleNodeClick} - onNodeDragEnd={handleNodeDragEnd} - onNodeDragMove={handleNodeDragMove} + onNodeClick={handleNodeClickWithPhysics} + onNodeDragEnd={handleNodeDragEndWithPhysics} + onNodeDragMove={handleNodeDragMoveWithNodes} onNodeDragStart={handleNodeDragStartWithNodes} onNodeHover={handleNodeHover} onPanEnd={handlePanEnd} @@ -469,6 +749,7 @@ export const MemoryGraph = ({ panY={panY} width={containerSize.width} zoom={zoom} + selectedNodeId={selectedNode} /> )} |