diff options
| author | Vidya Rupak <[email protected]> | 2025-12-20 17:01:12 -0700 |
|---|---|---|
| committer | Vidya Rupak <[email protected]> | 2025-12-20 17:01:12 -0700 |
| commit | db0f74110ace44d1640b77b6f8f8dcf5e71ecbc1 (patch) | |
| tree | 7a3ea186a1a267d0a41f0ff8fd5ed45ff0db2bb1 | |
| parent | added a changelog.md doc (diff) | |
| download | supermemory-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.tsx | 33 | ||||
| -rw-r--r-- | packages/memory-graph/src/components/memory-graph.tsx | 111 | ||||
| -rw-r--r-- | packages/memory-graph/src/constants.ts | 26 | ||||
| -rw-r--r-- | packages/memory-graph/src/hooks/use-force-simulation.ts | 177 | ||||
| -rw-r--r-- | packages/memory-graph/src/hooks/use-graph-data.ts | 123 | ||||
| -rw-r--r-- | packages/memory-graph/src/types.ts | 12 |
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 { |