mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-19 16:13:19 +00:00
add memory limiting and performance optimizations. reduced k-nn limit to 10.
This commit is contained in:
parent
263d6e57cf
commit
66d8a5baf2
6 changed files with 370 additions and 128 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue