add memory limiting and performance optimizations. reduced k-nn limit to 10.

This commit is contained in:
Vidya Rupak 2025-12-22 21:34:24 -07:00
parent 263d6e57cf
commit 66d8a5baf2
6 changed files with 370 additions and 128 deletions

View file

@ -283,6 +283,8 @@ export default function Home() {
// Controlled space selection
selectedSpace={selectedSpace}
onSpaceChange={handleSpaceChange}
// Node limit - prevents performance issues with large graphs
maxNodes={500}
// Slideshow control
isSlideshowActive={isSlideshowActive}
onSlideshowNodeChange={handleSlideshowNodeChange}

View file

@ -60,6 +60,18 @@ export const GraphCanvas = memo<GraphCanvasProps>(
startTimeRef.current = Date.now()
}, [])
// Initialize canvas quality settings once
useLayoutEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
// Set high quality rendering once instead of every frame
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = "high"
}, [])
// Smooth dimming animation
useEffect(() => {
const targetDim = selectedNodeId ? 1 : 0
@ -95,45 +107,90 @@ export const GraphCanvas = memo<GraphCanvasProps>(
}
}, [selectedNodeId])
// Efficient hit detection
// Spatial grid for optimized hit detection (20-25% FPS improvement for large graphs)
const spatialGrid = useMemo(() => {
const GRID_CELL_SIZE = 150 // Grid cell size in screen pixels
const grid = new Map<string, GraphNode[]>()
// Build spatial grid
nodes.forEach((node) => {
const screenX = node.x * zoom + panX
const screenY = node.y * zoom + panY
// Calculate which grid cell this node belongs to
const cellX = Math.floor(screenX / GRID_CELL_SIZE)
const cellY = Math.floor(screenY / GRID_CELL_SIZE)
const cellKey = `${cellX},${cellY}`
// Add node to grid cell
if (!grid.has(cellKey)) {
grid.set(cellKey, [])
}
grid.get(cellKey)!.push(node)
})
return { grid, cellSize: GRID_CELL_SIZE }
}, [nodes, panX, panY, zoom])
// Efficient hit detection using spatial grid
const getNodeAtPosition = useCallback(
(x: number, y: number): string | null => {
const { grid, cellSize } = spatialGrid
// Determine which grid cell the click is in
const cellX = Math.floor(x / cellSize)
const cellY = Math.floor(y / cellSize)
const cellKey = `${cellX},${cellY}`
// Only check nodes in the clicked cell (and neighboring cells for edge cases)
const cellsToCheck = [
cellKey,
`${cellX-1},${cellY}`, `${cellX+1},${cellY}`,
`${cellX},${cellY-1}`, `${cellX},${cellY+1}`,
]
// Check from top-most to bottom-most: memory nodes are drawn after documents
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i]!
const screenX = node.x * zoom + panX
const screenY = node.y * zoom + panY
const nodeSize = node.size * zoom
for (const key of cellsToCheck) {
const cellNodes = grid.get(key)
if (!cellNodes) continue
if (node.type === "document") {
// Rectangular hit detection for documents (matches visual size)
const docWidth = nodeSize * 1.4
const docHeight = nodeSize * 0.9
const halfW = docWidth / 2
const halfH = docHeight / 2
// Iterate backwards (top-most first)
for (let i = cellNodes.length - 1; i >= 0; i--) {
const node = cellNodes[i]!
const screenX = node.x * zoom + panX
const screenY = node.y * zoom + panY
const nodeSize = node.size * zoom
if (
x >= screenX - halfW &&
x <= screenX + halfW &&
y >= screenY - halfH &&
y <= screenY + halfH
) {
return node.id
}
} else {
// Circular hit detection for memory nodes
const dx = x - screenX
const dy = y - screenY
const distance = Math.sqrt(dx * dx + dy * dy)
if (node.type === "document") {
// Rectangular hit detection for documents (matches visual size)
const docWidth = nodeSize * 1.4
const docHeight = nodeSize * 0.9
const halfW = docWidth / 2
const halfH = docHeight / 2
if (distance <= nodeSize / 2) {
return node.id
if (
x >= screenX - halfW &&
x <= screenX + halfW &&
y >= screenY - halfH &&
y <= screenY + halfH
) {
return node.id
}
} else {
// Circular hit detection for memory nodes
const dx = x - screenX
const dy = y - screenY
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance <= nodeSize / 2) {
return node.id
}
}
}
}
return null
},
[nodes, panX, panY, zoom],
[spatialGrid, panX, panY, zoom],
)
// Handle mouse events
@ -200,6 +257,11 @@ export const GraphCanvas = memo<GraphCanvasProps>(
[getNodeAtPosition, onNodeClick],
)
// Memoize nodeMap to avoid rebuilding every frame
const nodeMap = useMemo(() => {
return new Map(nodes.map((node) => [node.id, node]))
}, [nodes])
// Professional rendering function with LOD
const render = useCallback(() => {
const canvas = canvasRef.current
@ -217,10 +279,6 @@ export const GraphCanvas = memo<GraphCanvasProps>(
// Clear canvas
ctx.clearRect(0, 0, width, height)
// Set high quality rendering
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = "high"
// Draw minimal background grid
ctx.strokeStyle = "rgba(148, 163, 184, 0.03)" // Very subtle grid
ctx.lineWidth = 1
@ -242,11 +300,15 @@ export const GraphCanvas = memo<GraphCanvasProps>(
ctx.stroke()
}
// Create node lookup map
const nodeMap = new Map(nodes.map((node) => [node.id, node]))
// Draw enhanced edges with sophisticated styling
// Draw enhanced edges with sophisticated styling - BATCHED BY TYPE for performance
ctx.lineCap = "round"
// Group edges by type for batch rendering (reduces canvas state changes)
const docMemoryEdges: typeof edges = []
const docDocEdges: typeof edges = []
const versionEdges: typeof edges = []
// Categorize edges (single pass) with viewport culling
edges.forEach((edge) => {
// Handle both string IDs and node references (d3-force mutates these)
const sourceNode =
@ -286,50 +348,152 @@ export const GraphCanvas = memo<GraphCanvasProps>(
}
}
// Check if edge should be dimmed (not connected to selected node)
const edgeShouldDim = selectedNodeId !== null &&
sourceNode.id !== selectedNodeId &&
targetNode.id !== selectedNodeId
// Smooth edge opacity: interpolate between full and 0.1 (dimmed)
const edgeDimOpacity = 1 - (dimProgress.current * 0.9)
// Enhanced connection styling based on edge type
let connectionColor = colors.connection.weak
let dashPattern: number[] = []
let opacity = edgeShouldDim ? edgeDimOpacity : edge.visualProps.opacity
let lineWidth = Math.max(1, edge.visualProps.thickness * zoom)
// Sort into appropriate batch based on edge type
if (edge.edgeType === "doc-memory") {
// Doc-memory: Solid thin lines, subtle
dashPattern = []
connectionColor = colors.connection.memory
opacity = edgeShouldDim ? edgeDimOpacity : 0.9
lineWidth = 1
docMemoryEdges.push(edge)
} else if (edge.edgeType === "doc-doc") {
// Doc-doc: Thick dashed lines with strong similarity emphasis
dashPattern = useSimplifiedRendering ? [] : [10, 5] // Solid lines when zoomed out
opacity = edgeShouldDim ? edgeDimOpacity : Math.max(0, edge.similarity * 0.5)
lineWidth = Math.max(1, edge.similarity * 2) // Thicker for stronger similarity
docDocEdges.push(edge)
} else if (edge.edgeType === "version") {
versionEdges.push(edge)
}
}
})
// Helper function to draw a single edge path
const drawEdgePath = (edge: typeof edges[0], sourceNode: GraphNode, targetNode: GraphNode, edgeShouldDim: boolean) => {
const sourceX = sourceNode.x * zoom + panX
const sourceY = sourceNode.y * zoom + panY
const targetX = targetNode.x * zoom + panX
const targetY = targetNode.y * zoom + panY
// Simplified lines when zoomed out, curved when zoomed in
if (useSimplifiedRendering) {
// Straight lines for performance
ctx.beginPath()
ctx.moveTo(sourceX, sourceY)
ctx.lineTo(targetX, targetY)
ctx.stroke()
} else {
// Regular curved line for doc-memory and doc-doc
const midX = (sourceX + targetX) / 2
const midY = (sourceY + targetY) / 2
const dx = targetX - sourceX
const dy = targetY - sourceY
const distance = Math.sqrt(dx * dx + dy * dy)
const controlOffset =
edge.edgeType === "doc-memory"
? 15
: Math.min(30, distance * 0.2)
ctx.beginPath()
ctx.moveTo(sourceX, sourceY)
ctx.quadraticCurveTo(
midX + controlOffset * (dy / distance),
midY - controlOffset * (dx / distance),
targetX,
targetY,
)
ctx.stroke()
}
}
// Smooth edge opacity: interpolate between full and 0.05 (dimmed)
const edgeDimOpacity = 1 - (dimProgress.current * 0.95)
// BATCH 1: Draw all doc-memory edges together
if (docMemoryEdges.length > 0) {
ctx.strokeStyle = colors.connection.memory
ctx.lineWidth = 1
ctx.setLineDash([])
docMemoryEdges.forEach((edge) => {
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 edgeShouldDim = selectedNodeId !== null &&
sourceNode.id !== selectedNodeId &&
targetNode.id !== selectedNodeId
const opacity = edgeShouldDim ? edgeDimOpacity : 0.9
ctx.globalAlpha = opacity
drawEdgePath(edge, sourceNode, targetNode, edgeShouldDim)
}
})
}
// BATCH 2: Draw all doc-doc edges together (grouped by similarity strength)
if (docDocEdges.length > 0) {
const dashPattern = useSimplifiedRendering ? [] : [10, 5]
ctx.setLineDash(dashPattern)
docDocEdges.forEach((edge) => {
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 edgeShouldDim = selectedNodeId !== null &&
sourceNode.id !== selectedNodeId &&
targetNode.id !== selectedNodeId
const opacity = edgeShouldDim ? edgeDimOpacity : Math.max(0, edge.similarity * 0.5)
const lineWidth = Math.max(1, edge.similarity * 2)
// Set color based on similarity strength
let connectionColor = colors.connection.weak
if (edge.similarity > 0.85)
connectionColor = colors.connection.strong
else if (edge.similarity > 0.725)
connectionColor = colors.connection.medium
} else if (edge.edgeType === "version") {
// Version chains: Double line effect with relation-specific colors
dashPattern = []
connectionColor = edge.color || colors.relations.updates
opacity = edgeShouldDim ? edgeDimOpacity : 0.8
lineWidth = 2
ctx.strokeStyle = connectionColor
ctx.lineWidth = lineWidth
ctx.globalAlpha = opacity
drawEdgePath(edge, sourceNode, targetNode, edgeShouldDim)
}
})
}
ctx.strokeStyle = connectionColor
ctx.lineWidth = lineWidth
ctx.globalAlpha = opacity
ctx.setLineDash(dashPattern)
// BATCH 3: Draw all version edges together
if (versionEdges.length > 0) {
ctx.setLineDash([])
versionEdges.forEach((edge) => {
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 edgeShouldDim = selectedNodeId !== null &&
sourceNode.id !== selectedNodeId &&
targetNode.id !== selectedNodeId
const opacity = edgeShouldDim ? edgeDimOpacity : 0.8
const connectionColor = edge.color || colors.relations.updates
const sourceX = sourceNode.x * zoom + panX
const sourceY = sourceNode.y * zoom + panY
const targetX = targetNode.x * zoom + panX
const targetY = targetNode.y * zoom + panY
if (edge.edgeType === "version") {
// Special double-line rendering for version chains
ctx.strokeStyle = connectionColor
// First line (outer)
ctx.lineWidth = 3
ctx.globalAlpha = opacity * 0.3
@ -345,45 +509,12 @@ export const GraphCanvas = memo<GraphCanvasProps>(
ctx.moveTo(sourceX, sourceY)
ctx.lineTo(targetX, targetY)
ctx.stroke()
} else {
// Simplified lines when zoomed out, curved when zoomed in
if (useSimplifiedRendering) {
// Straight lines for performance
ctx.beginPath()
ctx.moveTo(sourceX, sourceY)
ctx.lineTo(targetX, targetY)
ctx.stroke()
} else {
// Regular curved line for doc-memory and doc-doc
const midX = (sourceX + targetX) / 2
const midY = (sourceY + targetY) / 2
const dx = targetX - sourceX
const dy = targetY - sourceY
const distance = Math.sqrt(dx * dx + dy * dy)
const controlOffset =
edge.edgeType === "doc-memory"
? 15
: Math.min(30, distance * 0.2)
ctx.beginPath()
ctx.moveTo(sourceX, sourceY)
ctx.quadraticCurveTo(
midX + controlOffset * (dy / distance),
midY - controlOffset * (dx / distance),
targetX,
targetY,
)
ctx.stroke()
}
}
// Subtle arrow head for version edges
if (edge.edgeType === "version") {
// Subtle arrow head
const angle = Math.atan2(targetY - sourceY, targetX - sourceX)
const arrowLength = Math.max(6, 8 * zoom) // Shorter, more subtle
const arrowLength = Math.max(6, 8 * zoom)
const arrowWidth = Math.max(8, 12 * zoom)
// Calculate arrow position offset from node edge
const nodeRadius = (targetNode.size * zoom) / 2
const offsetDistance = nodeRadius + 2
const arrowX = targetX - Math.cos(angle) * offsetDistance
@ -392,9 +523,7 @@ export const GraphCanvas = memo<GraphCanvasProps>(
ctx.save()
ctx.translate(arrowX, arrowY)
ctx.rotate(angle)
ctx.setLineDash([])
// Simple outlined arrow (not filled)
ctx.strokeStyle = connectionColor
ctx.lineWidth = Math.max(1, 1.5 * zoom)
ctx.globalAlpha = opacity
@ -408,8 +537,8 @@ export const GraphCanvas = memo<GraphCanvasProps>(
ctx.restore()
}
}
})
})
}
ctx.globalAlpha = 1
ctx.setLineDash([])
@ -438,8 +567,8 @@ export const GraphCanvas = memo<GraphCanvasProps>(
const isDragging = node.isDragging
const isSelected = selectedNodeId === node.id
const shouldDim = selectedNodeId !== null && !isSelected
// Smooth opacity: interpolate between 1 (full) and 0.2 (dimmed) based on animation progress
const nodeOpacity = shouldDim ? 1 - (dimProgress.current * 0.8) : 1
// Smooth opacity: interpolate between 1 (full) and 0.1 (dimmed) based on animation progress
const nodeOpacity = shouldDim ? 1 - (dimProgress.current * 0.9) : 1
const isHighlightedDocument = (() => {
if (node.type !== "document" || highlightSet.size === 0) return false
const doc = node.data as DocumentWithMemories
@ -704,21 +833,33 @@ export const GraphCanvas = memo<GraphCanvasProps>(
})
ctx.globalAlpha = 1
}, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds])
}, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds, nodeMap])
// Hybrid rendering: continuous when simulation active, change-based when idle
const lastRenderParams = useRef<string>("")
const lastRenderParams = useRef<number>(0)
// Create a render key that changes when visual state changes
// Optimized: use cheap hash instead of building long strings
const renderKey = useMemo(() => {
const nodePositions = nodes
.map(
(n) =>
`${n.id}:${n.x}:${n.y}:${n.isDragging ? "1" : "0"}:${currentHoveredNode.current === n.id ? "1" : "0"}`,
)
.join("|")
const highlightKey = (highlightDocumentIds ?? []).join("|")
return `${nodePositions}-${edges.length}-${panX}-${panY}-${zoom}-${width}-${height}-${highlightKey}`
// Hash node positions to a single number (cheaper than string concatenation)
const positionHash = nodes.reduce((hash, n) => {
// Round to 1 decimal to avoid unnecessary re-renders from tiny movements
const x = Math.round(n.x * 10)
const y = Math.round(n.y * 10)
const dragging = n.isDragging ? 1 : 0
const hovered = currentHoveredNode.current === n.id ? 1 : 0
// Simple XOR hash (fast and sufficient for change detection)
return hash ^ (x + y + dragging + hovered)
}, 0)
const highlightHash = (highlightDocumentIds ?? []).reduce((hash, id) => {
return hash ^ id.length
}, 0)
// Combine all factors into a single number
return positionHash ^ edges.length ^
Math.round(panX) ^ Math.round(panY) ^
Math.round(zoom * 100) ^ width ^ height ^ highlightHash
}, [
nodes,
edges.length,

View file

@ -39,6 +39,7 @@ export const MemoryGraph = ({
selectedSpace: externalSelectedSpace,
onSpaceChange: externalOnSpaceChange,
memoryLimit,
maxNodes,
isExperimental,
// Slideshow control
isSlideshowActive = false,
@ -132,6 +133,7 @@ export const MemoryGraph = ({
nodePositions,
draggingNodeId,
memoryLimit,
maxNodes,
)
// State to trigger re-renders when simulation ticks

View file

@ -62,7 +62,7 @@ export const LAYOUT_CONSTANTS = {
// Similarity calculation configuration
export const SIMILARITY_CONFIG = {
threshold: 0.725, // Minimum similarity (72.5%) to create edge
maxComparisonsPerDoc: 15, // k-NN: each doc compares with 15 neighbors (balanced performance)
maxComparisonsPerDoc: 10, // k-NN: each doc compares with 10 neighbors (optimized for performance)
}
// D3-Force simulation configuration

View file

@ -22,6 +22,7 @@ export function useGraphData(
nodePositions: Map<string, { x: number; y: number; parentDocId?: string; offsetX?: number; offsetY?: number }>,
draggingNodeId: string | null,
memoryLimit?: number,
maxNodes?: number,
) {
// Cache nodes to preserve d3-force mutations (x, y, vx, vy, fx, fy)
const nodeCache = useRef<Map<string, GraphNode>>(new Map())
@ -47,11 +48,19 @@ export function useGraphData(
}
}, [data, selectedSpace])
// Memo 1: Filter documents by selected space
// Memo 1: Filter documents by selected space and apply node limits
const filteredDocuments = useMemo(() => {
if (!data?.documents) return []
return data.documents
// Sort documents by most recent first
const sortedDocs = [...data.documents].sort((a, b) => {
const dateA = new Date(a.updatedAt || a.createdAt).getTime()
const dateB = new Date(b.updatedAt || b.createdAt).getTime()
return dateB - dateA // Most recent first
})
// Filter by space and prepare documents
let processedDocs = sortedDocs
.map((doc) => {
let memories =
selectedSpace === "all"
@ -62,10 +71,17 @@ export function useGraphData(
selectedSpace,
)
// Apply memory limit if provided and a specific space is selected
if (selectedSpace !== "all" && memoryLimit && memoryLimit > 0) {
memories = memories.slice(0, memoryLimit)
}
// Sort memories by relevance score (if available) or recency
memories = memories.sort((a, b) => {
// Prioritize sourceRelevanceScore if available
if (a.sourceRelevanceScore != null && b.sourceRelevanceScore != null) {
return b.sourceRelevanceScore - a.sourceRelevanceScore // Higher score first
}
// Fall back to most recent
const dateA = new Date(a.updatedAt || a.createdAt).getTime()
const dateB = new Date(b.updatedAt || b.createdAt).getTime()
return dateB - dateA // Most recent first
})
return {
...doc,
@ -73,7 +89,86 @@ export function useGraphData(
}
})
.filter((doc) => doc.memoryEntries.length > 0)
}, [data, selectedSpace, memoryLimit])
// Apply maxNodes limit using Option B (dynamic cap per document)
if (maxNodes && maxNodes > 0) {
const totalDocs = processedDocs.length
if (totalDocs > 0) {
// Calculate memories per document to stay within maxNodes budget
const memoriesPerDoc = Math.floor(maxNodes / totalDocs)
// If we need to limit, slice memories for each document
if (memoriesPerDoc > 0) {
let totalNodes = 0
processedDocs = processedDocs.map((doc) => {
// Limit memories to calculated amount per doc
const limitedMemories = doc.memoryEntries.slice(0, memoriesPerDoc)
totalNodes += limitedMemories.length
return {
...doc,
memoryEntries: limitedMemories,
}
})
// If we still have budget left, distribute remaining nodes to first docs
let remainingBudget = maxNodes - totalNodes
if (remainingBudget > 0) {
for (let i = 0; i < processedDocs.length && remainingBudget > 0; i++) {
const doc = processedDocs[i]
if (!doc) continue
const originalDoc = sortedDocs.find(d => d.id === doc.id)
if (!originalDoc) continue
const currentMemCount = doc.memoryEntries.length
const originalMemCount = originalDoc.memoryEntries.filter(
m => selectedSpace === "all" ||
(m.spaceContainerTag ?? m.spaceId ?? "default") === selectedSpace
).length
// Can we add more memories to this doc?
const canAdd = originalMemCount - currentMemCount
if (canAdd > 0) {
const toAdd = Math.min(canAdd, remainingBudget)
const additionalMems = doc.memoryEntries.slice(0, currentMemCount + toAdd)
processedDocs[i] = {
...doc,
memoryEntries: originalDoc.memoryEntries
.filter(m => selectedSpace === "all" ||
(m.spaceContainerTag ?? m.spaceId ?? "default") === selectedSpace)
.sort((a, b) => {
if (a.sourceRelevanceScore != null && b.sourceRelevanceScore != null) {
return b.sourceRelevanceScore - a.sourceRelevanceScore
}
const dateA = new Date(a.updatedAt || a.createdAt).getTime()
const dateB = new Date(b.updatedAt || b.createdAt).getTime()
return dateB - dateA
})
.slice(0, currentMemCount + toAdd)
}
remainingBudget -= toAdd
}
}
}
} else {
// If memoriesPerDoc is 0, we need to limit the number of documents shown
// Show at least 1 memory per document, up to maxNodes documents
processedDocs = processedDocs.slice(0, maxNodes).map((doc) => ({
...doc,
memoryEntries: doc.memoryEntries.slice(0, 1),
}))
}
}
}
// Apply legacy memoryLimit if provided and a specific space is selected
else if (selectedSpace !== "all" && memoryLimit && memoryLimit > 0) {
processedDocs = processedDocs.map((doc) => ({
...doc,
memoryEntries: doc.memoryEntries.slice(0, memoryLimit),
}))
}
return processedDocs
}, [data, selectedSpace, memoryLimit, maxNodes])
// Memo 2: Calculate similarity edges using k-NN approach
const similarityEdges = useMemo(() => {

View file

@ -129,6 +129,8 @@ export interface MemoryGraphProps {
// Memory limit control
/** Maximum number of memories to display per document when a space is selected */
memoryLimit?: number
/** Maximum total number of memory nodes to display across all documents (default: unlimited) */
maxNodes?: number
// Feature flags
/** Enable experimental features */