updated rendering to hybrid: continuous when simulation active, change-based when idle

This commit is contained in:
Vidya Rupak 2025-12-20 17:01:12 -07:00
parent b7a20093bf
commit db0f74110a
6 changed files with 400 additions and 82 deletions

View file

@ -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(() => {

View file

@ -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
}
}
},
[handleNodeDragMove, nodes],
[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
@ -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}

View file

@ -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: {

View file

@ -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,
}
}

View file

@ -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)

View file

@ -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 {