aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorVidya Rupak <[email protected]>2025-12-20 17:01:12 -0700
committerVidya Rupak <[email protected]>2025-12-20 17:01:12 -0700
commitdb0f74110ace44d1640b77b6f8f8dcf5e71ecbc1 (patch)
tree7a3ea186a1a267d0a41f0ff8fd5ed45ff0db2bb1
parentadded a changelog.md doc (diff)
downloadsupermemory-db0f74110ace44d1640b77b6f8f8dcf5e71ecbc1.tar.xz
supermemory-db0f74110ace44d1640b77b6f8f8dcf5e71ecbc1.zip
updated rendering to hybrid: continuous when simulation active, change-based when idle
-rw-r--r--packages/memory-graph/src/components/graph-canvas.tsx33
-rw-r--r--packages/memory-graph/src/components/memory-graph.tsx111
-rw-r--r--packages/memory-graph/src/constants.ts26
-rw-r--r--packages/memory-graph/src/hooks/use-force-simulation.ts177
-rw-r--r--packages/memory-graph/src/hooks/use-graph-data.ts123
-rw-r--r--packages/memory-graph/src/types.ts12
6 files changed, 400 insertions, 82 deletions
diff --git a/packages/memory-graph/src/components/graph-canvas.tsx b/packages/memory-graph/src/components/graph-canvas.tsx
index ee4f5885..70581fa9 100644
--- a/packages/memory-graph/src/components/graph-canvas.tsx
+++ b/packages/memory-graph/src/components/graph-canvas.tsx
@@ -41,6 +41,7 @@ export const GraphCanvas = memo<GraphCanvasProps>(
onTouchEnd,
draggingNodeId,
highlightDocumentIds,
+ isSimulationActive = false,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const animationRef = useRef<number>(0)
@@ -188,8 +189,15 @@ export const GraphCanvas = memo<GraphCanvasProps>(
// Draw enhanced edges with sophisticated styling
ctx.lineCap = "round"
edges.forEach((edge) => {
- const sourceNode = nodeMap.get(edge.source)
- const targetNode = nodeMap.get(edge.target)
+ // Handle both string IDs and node references (d3-force mutates these)
+ const sourceNode =
+ typeof edge.source === "string"
+ ? nodeMap.get(edge.source)
+ : edge.source
+ const targetNode =
+ typeof edge.target === "string"
+ ? nodeMap.get(edge.target)
+ : edge.target
if (sourceNode && targetNode) {
const sourceX = sourceNode.x * zoom + panX
@@ -604,7 +612,7 @@ export const GraphCanvas = memo<GraphCanvasProps>(
ctx.globalAlpha = 1
}, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds])
- // Change-based rendering instead of continuous animation
+ // Hybrid rendering: continuous when simulation active, change-based when idle
const lastRenderParams = useRef<string>("")
// Create a render key that changes when visual state changes
@@ -628,13 +636,28 @@ export const GraphCanvas = memo<GraphCanvasProps>(
highlightDocumentIds,
])
- // Only render when something actually changed
+ // Render based on simulation state
useEffect(() => {
+ if (isSimulationActive) {
+ // Continuous rendering during physics simulation
+ const renderLoop = () => {
+ render()
+ animationRef.current = requestAnimationFrame(renderLoop)
+ }
+ renderLoop()
+
+ return () => {
+ if (animationRef.current) {
+ cancelAnimationFrame(animationRef.current)
+ }
+ }
+ }
+ // Change-based rendering when simulation is idle
if (renderKey !== lastRenderParams.current) {
lastRenderParams.current = renderKey
render()
}
- }, [renderKey, render])
+ }, [isSimulationActive, renderKey, render])
// Cleanup any existing animation frames
useEffect(() => {
diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx
index 7ece4357..1e5f4dc0 100644
--- a/packages/memory-graph/src/components/memory-graph.tsx
+++ b/packages/memory-graph/src/components/memory-graph.tsx
@@ -6,6 +6,7 @@ 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 { useForceSimulation } from "@/hooks/use-force-simulation"
import { injectStyles } from "@/lib/inject-styles"
import { Legend } from "./legend"
import { LoadingIndicator } from "./loading-indicator"
@@ -128,6 +129,30 @@ export const MemoryGraph = ({
memoryLimit,
)
+ // State to trigger re-renders when simulation ticks
+ const [, setSimulationTick] = useState(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
+ setSimulationTick((prev) => prev + 1)
+ },
+ true, // enabled
+ )
+
// Auto-fit once per unique highlight set to show the full graph for context
const lastFittedHighlightKeyRef = useRef<string>("")
useEffect(() => {
@@ -240,20 +265,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],
)
- // Enhanced node drag move that includes nodes data
+ // Physics-enabled node drag move
const handleNodeDragMoveWithNodes = useCallback(
(e: React.MouseEvent) => {
- handleNodeDragMove(e, nodes)
+ 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)
},
- [handleNodeDragMove, nodes],
+ [handleNodeClick],
)
// Navigation callbacks
@@ -460,9 +556,10 @@ export const MemoryGraph = ({
height={containerSize.height}
nodes={nodes}
highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []}
+ isSimulationActive={forceSimulation.isActive()}
onDoubleClick={handleDoubleClick}
- onNodeClick={handleNodeClick}
- onNodeDragEnd={handleNodeDragEnd}
+ onNodeClick={handleNodeClickWithPhysics}
+ onNodeDragEnd={handleNodeDragEndWithPhysics}
onNodeDragMove={handleNodeDragMoveWithNodes}
onNodeDragStart={handleNodeDragStartWithNodes}
onNodeHover={handleNodeHover}
diff --git a/packages/memory-graph/src/constants.ts b/packages/memory-graph/src/constants.ts
index fddfdee5..59dd4607 100644
--- a/packages/memory-graph/src/constants.ts
+++ b/packages/memory-graph/src/constants.ts
@@ -59,6 +59,32 @@ export const LAYOUT_CONSTANTS = {
memoryClusterRadius: 300,
}
+// D3-Force simulation configuration
+export const FORCE_CONFIG = {
+ // Link force (spring between connected nodes)
+ linkStrength: {
+ docMemory: 0.8, // Strong for doc-memory connections
+ version: 1.0, // Strongest for version chains
+ docDocBase: 0.3, // Base for doc-doc similarity
+ },
+ linkDistance: 300, // Desired spring length
+
+ // Charge force (repulsion between nodes)
+ chargeStrength: -1000, // Negative = repulsion, higher magnitude = stronger push
+
+ // Collision force (prevents node overlap)
+ collisionRadius: {
+ document: 80, // Collision radius for document nodes
+ memory: 40, // Collision radius for memory nodes
+ },
+
+ // Simulation behavior
+ alphaDecay: 0.03, // How fast simulation cools down (higher = faster cooldown)
+ alphaMin: 0.001, // Threshold to stop simulation (when alpha drops below this)
+ velocityDecay: 0.6, // Friction/damping (0 = no friction, 1 = instant stop) - increased for less movement
+ alphaTarget: 0.3, // Target alpha when reheating (on drag start)
+}
+
// Graph view settings
export const GRAPH_SETTINGS = {
console: {
diff --git a/packages/memory-graph/src/hooks/use-force-simulation.ts b/packages/memory-graph/src/hooks/use-force-simulation.ts
new file mode 100644
index 00000000..e1d6b19e
--- /dev/null
+++ b/packages/memory-graph/src/hooks/use-force-simulation.ts
@@ -0,0 +1,177 @@
+"use client"
+
+import { useEffect, useRef, useCallback } from "react"
+import * as d3 from "d3-force"
+import { FORCE_CONFIG } from "@/constants"
+import type { GraphNode, GraphEdge } from "@/types"
+
+export interface ForceSimulationControls {
+ /** The d3 simulation instance */
+ simulation: d3.Simulation<GraphNode, GraphEdge> | null
+ /** Reheat the simulation (call on drag start) */
+ reheat: () => void
+ /** Cool down the simulation (call on drag end) */
+ coolDown: () => void
+ /** Check if simulation is currently active */
+ isActive: () => boolean
+ /** Stop the simulation completely */
+ stop: () => void
+ /** Get current alpha value */
+ getAlpha: () => number
+}
+
+/**
+ * Custom hook to manage d3-force simulation lifecycle
+ * Simulation only runs during interactions (drag) for performance
+ */
+export function useForceSimulation(
+ nodes: GraphNode[],
+ edges: GraphEdge[],
+ onTick: (nodes: GraphNode[]) => void,
+ enabled = true,
+): ForceSimulationControls {
+ const simulationRef = useRef<d3.Simulation<GraphNode, GraphEdge> | null>(null)
+
+ // Initialize simulation ONCE
+ useEffect(() => {
+ if (!enabled || nodes.length === 0) {
+ return
+ }
+
+ // Only create simulation once
+ if (!simulationRef.current) {
+ const simulation = d3
+ .forceSimulation<GraphNode>(nodes)
+ .alphaDecay(FORCE_CONFIG.alphaDecay)
+ .alphaMin(FORCE_CONFIG.alphaMin)
+ .velocityDecay(FORCE_CONFIG.velocityDecay)
+ .on("tick", () => {
+ // Trigger re-render by calling onTick
+ // D3 has already mutated node.x and node.y
+ onTick([...nodes])
+ })
+
+ // Configure forces
+ // 1. Link force - spring connections between nodes
+ simulation.force(
+ "link",
+ d3
+ .forceLink<GraphNode, GraphEdge>(edges)
+ .id((d) => d.id)
+ .distance(FORCE_CONFIG.linkDistance)
+ .strength((link) => {
+ // Different strength based on edge type
+ if (link.edgeType === "doc-memory") {
+ return FORCE_CONFIG.linkStrength.docMemory
+ }
+ if (link.edgeType === "version") {
+ return FORCE_CONFIG.linkStrength.version
+ }
+ // doc-doc: variable strength based on similarity
+ return link.similarity * FORCE_CONFIG.linkStrength.docDocBase
+ }),
+ )
+
+ // 2. Charge force - repulsion between nodes
+ simulation.force(
+ "charge",
+ d3.forceManyBody<GraphNode>().strength(FORCE_CONFIG.chargeStrength),
+ )
+
+ // 3. Collision force - prevent node overlap
+ simulation.force(
+ "collide",
+ d3
+ .forceCollide<GraphNode>()
+ .radius((d) =>
+ d.type === "document"
+ ? FORCE_CONFIG.collisionRadius.document
+ : FORCE_CONFIG.collisionRadius.memory,
+ )
+ .strength(0.7),
+ )
+
+ // 4. forceX and forceY - weak centering forces (like reference code)
+ simulation.force("x", d3.forceX().strength(0.05))
+ simulation.force("y", d3.forceY().strength(0.05))
+
+ // Store reference
+ simulationRef.current = simulation
+
+ // Stop simulation immediately after creation
+ // It will only run when explicitly reheated (on drag)
+ simulation.stop()
+ }
+
+ // Cleanup on unmount
+ return () => {
+ if (simulationRef.current) {
+ simulationRef.current.stop()
+ simulationRef.current = null
+ }
+ }
+ // Only run on mount/unmount, not when nodes/edges/onTick change
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [enabled])
+
+ // Update simulation nodes when they change
+ useEffect(() => {
+ if (simulationRef.current && nodes.length > 0) {
+ simulationRef.current.nodes(nodes)
+ }
+ }, [nodes])
+
+ // Update simulation edges when they change
+ useEffect(() => {
+ if (simulationRef.current && edges.length > 0) {
+ const linkForce = simulationRef.current.force<
+ d3.ForceLink<GraphNode, GraphEdge>
+ >("link")
+ if (linkForce) {
+ linkForce.links(edges)
+ }
+ }
+ }, [edges])
+
+ // Reheat simulation (called on drag start)
+ const reheat = useCallback(() => {
+ if (simulationRef.current) {
+ simulationRef.current.alphaTarget(FORCE_CONFIG.alphaTarget).restart()
+ }
+ }, [])
+
+ // Cool down simulation (called on drag end)
+ const coolDown = useCallback(() => {
+ if (simulationRef.current) {
+ simulationRef.current.alphaTarget(0)
+ }
+ }, [])
+
+ // Check if simulation is active
+ const isActive = useCallback(() => {
+ if (!simulationRef.current) return false
+ return simulationRef.current.alpha() > FORCE_CONFIG.alphaMin
+ }, [])
+
+ // Stop simulation completely
+ const stop = useCallback(() => {
+ if (simulationRef.current) {
+ simulationRef.current.stop()
+ }
+ }, [])
+
+ // Get current alpha
+ const getAlpha = useCallback(() => {
+ if (!simulationRef.current) return 0
+ return simulationRef.current.alpha()
+ }, [])
+
+ return {
+ simulation: simulationRef.current,
+ reheat,
+ coolDown,
+ isActive,
+ stop,
+ getAlpha,
+ }
+}
diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts
index a82eea8d..d8c7e5b5 100644
--- a/packages/memory-graph/src/hooks/use-graph-data.ts
+++ b/packages/memory-graph/src/hooks/use-graph-data.ts
@@ -5,7 +5,7 @@ import {
getConnectionVisualProps,
getMagicalConnectionColor,
} from "@/lib/similarity"
-import { useMemo } from "react"
+import { useMemo, useRef } from "react"
import { colors, LAYOUT_CONSTANTS } from "@/constants"
import type {
DocumentsResponse,
@@ -23,6 +23,9 @@ export function useGraphData(
draggingNodeId: string | null,
memoryLimit?: number,
) {
+ // Cache nodes to preserve d3-force mutations (x, y, vx, vy, fx, fy)
+ const nodeCache = useRef<Map<string, GraphNode>>(new Map())
+
return useMemo(() => {
if (!data?.documents) return { nodes: [], edges: [] }
@@ -115,65 +118,38 @@ export function useGraphData(
const customPos = nodePositions.get(doc.id)
- documentNodes.push({
- id: doc.id,
- type: "document",
- x: customPos?.x ?? defaultX,
- y: customPos?.y ?? defaultY,
- data: doc,
- size: 58,
- color: colors.document.primary,
- isHovered: false,
- isDragging: draggingNodeId === doc.id,
- } satisfies GraphNode)
+ // Check if node exists in cache (preserves d3-force mutations)
+ let node = nodeCache.current.get(doc.id)
+ if (node) {
+ // Update existing node's data, preserve physics properties (x, y, vx, vy, fx, fy)
+ node.data = doc
+ node.isDragging = draggingNodeId === doc.id
+ // Don't reset x/y - they're managed by d3-force
+ } else {
+ // Create new node with initial position
+ node = {
+ id: doc.id,
+ type: "document",
+ x: customPos?.x ?? defaultX,
+ y: customPos?.y ?? defaultY,
+ data: doc,
+ size: 58,
+ color: colors.document.primary,
+ isHovered: false,
+ isDragging: draggingNodeId === doc.id,
+ } satisfies GraphNode
+ nodeCache.current.set(doc.id, node)
+ }
+
+ documentNodes.push(node)
})
spaceIndex++
})
- /* 2. Gentle document collision avoidance with dampening */
- const minDocDist = LAYOUT_CONSTANTS.minDocDist
-
- // Reduced iterations and gentler repulsion for smoother movement
- for (let iter = 0; iter < 2; iter++) {
- documentNodes.forEach((nodeA) => {
- documentNodes.forEach((nodeB) => {
- if (nodeA.id >= nodeB.id) return
-
- // Only repel documents in the same space
- const spaceA =
- (nodeA.data as DocumentWithMemories).memoryEntries[0]
- ?.spaceContainerTag ??
- (nodeA.data as DocumentWithMemories).memoryEntries[0]?.spaceId ??
- "default"
- const spaceB =
- (nodeB.data as DocumentWithMemories).memoryEntries[0]
- ?.spaceContainerTag ??
- (nodeB.data as DocumentWithMemories).memoryEntries[0]?.spaceId ??
- "default"
-
- if (spaceA !== spaceB) return
-
- const dx = nodeB.x - nodeA.x
- const dy = nodeB.y - nodeA.y
- const dist = Math.sqrt(dx * dx + dy * dy) || 1
-
- if (dist < minDocDist) {
- // Much gentler push with dampening
- const push = (minDocDist - dist) / 8
- const dampening = Math.max(0.1, Math.min(1, dist / minDocDist))
- const smoothPush = push * dampening * 0.5
-
- const nx = dx / dist
- const ny = dy / dist
- nodeA.x -= nx * smoothPush
- nodeA.y -= ny * smoothPush
- nodeB.x += nx * smoothPush
- nodeB.y += ny * smoothPush
- }
- })
- })
- }
+ /* 2. Manual collision avoidance removed - now handled by d3-force simulation */
+ // The initial circular layout provides good starting positions
+ // D3-force will handle collision avoidance and spacing dynamically
allNodes.push(...documentNodes)
@@ -220,19 +196,30 @@ export function useGraphData(
}
if (!memoryNodeMap.has(memoryId)) {
- const memoryNode: GraphNode = {
- id: memoryId,
- type: "memory",
- x: finalMemX,
- y: finalMemY,
- data: memory,
- size: Math.max(
- 32,
- Math.min(48, (memory.memory?.length || 50) * 0.5),
- ),
- color: colors.memory.primary,
- isHovered: false,
- isDragging: draggingNodeId === memoryId,
+ // Check if memory node exists in cache (preserves d3-force mutations)
+ let memoryNode = nodeCache.current.get(memoryId)
+ if (memoryNode) {
+ // Update existing node's data, preserve physics properties
+ memoryNode.data = memory
+ memoryNode.isDragging = draggingNodeId === memoryId
+ // Don't reset x/y - they're managed by d3-force
+ } else {
+ // Create new node with initial position
+ memoryNode = {
+ id: memoryId,
+ type: "memory",
+ x: finalMemX,
+ y: finalMemY,
+ data: memory,
+ size: Math.max(
+ 32,
+ Math.min(48, (memory.memory?.length || 50) * 0.5),
+ ),
+ color: colors.memory.primary,
+ isHovered: false,
+ isDragging: draggingNodeId === memoryId,
+ }
+ nodeCache.current.set(memoryId, memoryNode)
}
memoryNodeMap.set(memoryId, memoryNode)
allNodes.push(memoryNode)
diff --git a/packages/memory-graph/src/types.ts b/packages/memory-graph/src/types.ts
index 73d0602a..dbdc2e4a 100644
--- a/packages/memory-graph/src/types.ts
+++ b/packages/memory-graph/src/types.ts
@@ -17,14 +17,20 @@ export interface GraphNode {
color: string
isHovered: boolean
isDragging: boolean
+ // D3-force simulation properties
+ vx?: number // velocity x
+ vy?: number // velocity y
+ fx?: number | null // fixed x position (for pinning during drag)
+ fy?: number | null // fixed y position (for pinning during drag)
}
export type MemoryRelation = "updates" | "extends" | "derives"
export interface GraphEdge {
id: string
- source: string
- target: string
+ // D3-force mutates source/target from string IDs to node references during simulation
+ source: string | GraphNode
+ target: string | GraphNode
similarity: number
visualProps: {
opacity: number
@@ -74,6 +80,8 @@ export interface GraphCanvasProps {
draggingNodeId: string | null
// Optional list of document IDs (customId or internal id) to highlight
highlightDocumentIds?: string[]
+ // Physics simulation state
+ isSimulationActive?: boolean
}
export interface MemoryGraphProps {