aboutsummaryrefslogtreecommitdiff
path: root/packages/memory-graph/src/components/memory-graph.tsx
diff options
context:
space:
mode:
authorVidya Rupak <[email protected]>2025-12-28 11:02:26 -0800
committerGitHub <[email protected]>2025-12-29 00:32:26 +0530
commitd93ffbb93f448236631bb39b7c8cc8dd6b99a573 (patch)
tree187800546d5bdddb61d78682f7207e97023ac94e /packages/memory-graph/src/components/memory-graph.tsx
parenticon in overview (diff)
downloadsupermemory-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.tsx313
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}
/>
)}