supermemory/packages/memory-graph/src/components/graph-canvas.tsx
nexxeln dfb0c05ab3 add spaces selector with search (#600)
relevant files to review:
\- memory-graph.tsx
\- spaces-dropdown.tsx
\- spaces-dropdown.css.ts
2025-12-02 18:37:24 +00:00

760 lines
20 KiB
TypeScript

"use client"
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react"
import { colors } from "@/constants"
import type {
DocumentWithMemories,
GraphCanvasProps,
GraphNode,
MemoryEntry,
} from "@/types"
import { canvasWrapper } from "./canvas-common.css"
export const GraphCanvas = memo<GraphCanvasProps>(
({
nodes,
edges,
panX,
panY,
zoom,
width,
height,
onNodeHover,
onNodeClick,
onNodeDragStart,
onNodeDragMove,
onNodeDragEnd,
onPanStart,
onPanMove,
onPanEnd,
onWheel,
onDoubleClick,
onTouchStart,
onTouchMove,
onTouchEnd,
draggingNodeId,
highlightDocumentIds,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const animationRef = useRef<number>(0)
const startTimeRef = useRef<number>(Date.now())
const mousePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
const currentHoveredNode = useRef<string | null>(null)
// Initialize start time once
useEffect(() => {
startTimeRef.current = Date.now()
}, [])
// Efficient hit detection
const getNodeAtPosition = useCallback(
(x: number, y: number): string | null => {
// 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
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],
)
// Handle mouse events
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
mousePos.current = { x, y }
const nodeId = getNodeAtPosition(x, y)
if (nodeId !== currentHoveredNode.current) {
currentHoveredNode.current = nodeId
onNodeHover(nodeId)
}
// Handle node dragging
if (draggingNodeId) {
onNodeDragMove(e)
}
},
[getNodeAtPosition, onNodeHover, draggingNodeId, onNodeDragMove],
)
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const nodeId = getNodeAtPosition(x, y)
if (nodeId) {
// When starting a node drag, prevent initiating pan
e.stopPropagation()
onNodeDragStart(nodeId, e)
return
}
onPanStart(e)
},
[getNodeAtPosition, onNodeDragStart, onPanStart],
)
const handleClick = useCallback(
(e: React.MouseEvent) => {
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const nodeId = getNodeAtPosition(x, y)
if (nodeId) {
onNodeClick(nodeId)
}
},
[getNodeAtPosition, onNodeClick],
)
// Professional rendering function with LOD
const render = useCallback(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const currentTime = Date.now()
const _elapsed = currentTime - startTimeRef.current
// Level-of-detail optimization based on zoom
const useSimplifiedRendering = zoom < 0.3
// 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
const gridSpacing = 100 * zoom
const offsetX = panX % gridSpacing
const offsetY = panY % gridSpacing
// Simple, clean grid lines
for (let x = offsetX; x < width; x += gridSpacing) {
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x, height)
ctx.stroke()
}
for (let y = offsetY; y < height; y += gridSpacing) {
ctx.beginPath()
ctx.moveTo(0, y)
ctx.lineTo(width, y)
ctx.stroke()
}
// Create node lookup map
const nodeMap = new Map(nodes.map((node) => [node.id, node]))
// Draw enhanced edges with sophisticated styling
ctx.lineCap = "round"
edges.forEach((edge) => {
const sourceNode = nodeMap.get(edge.source)
const targetNode = nodeMap.get(edge.target)
if (sourceNode && targetNode) {
const sourceX = sourceNode.x * zoom + panX
const sourceY = sourceNode.y * zoom + panY
const targetX = targetNode.x * zoom + panX
const targetY = targetNode.y * zoom + panY
// Enhanced viewport culling with edge type considerations
if (
sourceX < -100 ||
sourceX > width + 100 ||
targetX < -100 ||
targetX > width + 100
) {
return
}
// Skip very weak connections when zoomed out for performance
if (useSimplifiedRendering) {
if (
edge.edgeType === "doc-memory" &&
edge.visualProps.opacity < 0.3
) {
return // Skip very weak doc-memory edges when zoomed out
}
}
// Enhanced connection styling based on edge type
let connectionColor = colors.connection.weak
let dashPattern: number[] = []
let opacity = edge.visualProps.opacity
let lineWidth = Math.max(1, edge.visualProps.thickness * zoom)
if (edge.edgeType === "doc-memory") {
// Doc-memory: Solid thin lines, subtle
dashPattern = []
connectionColor = colors.connection.memory
opacity = 0.9
lineWidth = 1
} 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 = Math.max(0, edge.similarity * 0.5)
lineWidth = Math.max(1, edge.similarity * 2) // Thicker for stronger similarity
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 = 0.8
lineWidth = 2
}
ctx.strokeStyle = connectionColor
ctx.lineWidth = lineWidth
ctx.globalAlpha = opacity
ctx.setLineDash(dashPattern)
if (edge.edgeType === "version") {
// Special double-line rendering for version chains
// First line (outer)
ctx.lineWidth = 3
ctx.globalAlpha = opacity * 0.3
ctx.beginPath()
ctx.moveTo(sourceX, sourceY)
ctx.lineTo(targetX, targetY)
ctx.stroke()
// Second line (inner)
ctx.lineWidth = 1
ctx.globalAlpha = opacity
ctx.beginPath()
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") {
const angle = Math.atan2(targetY - sourceY, targetX - sourceX)
const arrowLength = Math.max(6, 8 * zoom) // Shorter, more subtle
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
const arrowY = targetY - Math.sin(angle) * offsetDistance
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
ctx.beginPath()
ctx.moveTo(0, 0)
ctx.lineTo(-arrowLength, arrowWidth / 2)
ctx.moveTo(0, 0)
ctx.lineTo(-arrowLength, -arrowWidth / 2)
ctx.stroke()
ctx.restore()
}
}
})
ctx.globalAlpha = 1
ctx.setLineDash([])
// Prepare highlight set from provided document IDs (customId or internal)
const highlightSet = new Set<string>(highlightDocumentIds ?? [])
// Draw nodes with enhanced styling and LOD optimization
nodes.forEach((node) => {
const screenX = node.x * zoom + panX
const screenY = node.y * zoom + panY
const nodeSize = node.size * zoom
// Enhanced viewport culling
const margin = nodeSize + 50
if (
screenX < -margin ||
screenX > width + margin ||
screenY < -margin ||
screenY > height + margin
) {
return
}
const isHovered = currentHoveredNode.current === node.id
const isDragging = node.isDragging
const isHighlightedDocument = (() => {
if (node.type !== "document" || highlightSet.size === 0) return false
const doc = node.data as DocumentWithMemories
if (doc.customId && highlightSet.has(doc.customId)) return true
return highlightSet.has(doc.id)
})()
if (node.type === "document") {
// Enhanced glassmorphism document styling
const docWidth = nodeSize * 1.4
const docHeight = nodeSize * 0.9
// Multi-layer glass effect
ctx.fillStyle = isDragging
? colors.document.accent
: isHovered
? colors.document.secondary
: colors.document.primary
ctx.globalAlpha = 1
// Enhanced border with subtle glow
ctx.strokeStyle = isDragging
? colors.document.glow
: isHovered
? colors.document.accent
: colors.document.border
ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1
// Rounded rectangle with enhanced styling
const radius = useSimplifiedRendering ? 6 : 12
ctx.beginPath()
ctx.roundRect(
screenX - docWidth / 2,
screenY - docHeight / 2,
docWidth,
docHeight,
radius,
)
ctx.fill()
ctx.stroke()
// Subtle inner highlight for glass effect (skip when zoomed out)
if (!useSimplifiedRendering && (isHovered || isDragging)) {
ctx.strokeStyle = "rgba(255, 255, 255, 0.1)"
ctx.lineWidth = 1
ctx.beginPath()
ctx.roundRect(
screenX - docWidth / 2 + 1,
screenY - docHeight / 2 + 1,
docWidth - 2,
docHeight - 2,
radius - 1,
)
ctx.stroke()
}
// Highlight ring for search hits
if (isHighlightedDocument) {
ctx.save()
ctx.globalAlpha = 0.9
ctx.strokeStyle = colors.accent.primary
ctx.lineWidth = 3
ctx.setLineDash([6, 4])
const ringPadding = 10
ctx.beginPath()
ctx.roundRect(
screenX - docWidth / 2 - ringPadding,
screenY - docHeight / 2 - ringPadding,
docWidth + ringPadding * 2,
docHeight + ringPadding * 2,
radius + 6,
)
ctx.stroke()
ctx.setLineDash([])
ctx.restore()
}
} else {
// Enhanced memory styling with status indicators
const mem = node.data as MemoryEntry
const isForgotten =
mem.isForgotten ||
(mem.forgetAfter &&
new Date(mem.forgetAfter).getTime() < Date.now())
const isLatest = mem.isLatest
// Check if memory is expiring soon (within 7 days)
const expiringSoon =
mem.forgetAfter &&
!isForgotten &&
new Date(mem.forgetAfter).getTime() - Date.now() <
1000 * 60 * 60 * 24 * 7
// Check if memory is new (created within last 24 hours)
const isNew =
!isForgotten &&
new Date(mem.createdAt).getTime() > Date.now() - 1000 * 60 * 60 * 24
// Determine colors based on status
let fillColor = colors.memory.primary
let borderColor = colors.memory.border
let glowColor = colors.memory.glow
if (isForgotten) {
fillColor = colors.status.forgotten
borderColor = "rgba(220,38,38,0.3)"
glowColor = "rgba(220,38,38,0.2)"
} else if (expiringSoon) {
borderColor = colors.status.expiring
glowColor = colors.accent.amber
} else if (isNew) {
borderColor = colors.status.new
glowColor = colors.accent.emerald
}
if (isDragging) {
fillColor = colors.memory.accent
borderColor = glowColor
} else if (isHovered) {
fillColor = colors.memory.secondary
}
const radius = nodeSize / 2
ctx.fillStyle = fillColor
ctx.globalAlpha = isLatest ? 1 : 0.4
ctx.strokeStyle = borderColor
ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1.5
if (useSimplifiedRendering) {
// Simple circles when zoomed out for performance
ctx.beginPath()
ctx.arc(screenX, screenY, radius, 0, 2 * Math.PI)
ctx.fill()
ctx.stroke()
} else {
// HEXAGONAL memory nodes when zoomed in
const sides = 6
ctx.beginPath()
for (let i = 0; i < sides; i++) {
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 // Start from top
const x = screenX + radius * Math.cos(angle)
const y = screenY + radius * Math.sin(angle)
if (i === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
}
ctx.closePath()
ctx.fill()
ctx.stroke()
// Inner highlight for glass effect
if (isHovered || isDragging) {
ctx.strokeStyle = "rgba(147, 197, 253, 0.3)"
ctx.lineWidth = 1
const innerRadius = radius - 2
ctx.beginPath()
for (let i = 0; i < sides; i++) {
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2
const x = screenX + innerRadius * Math.cos(angle)
const y = screenY + innerRadius * Math.sin(angle)
if (i === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
}
ctx.closePath()
ctx.stroke()
}
}
// Status indicators overlay (always preserve these as required)
if (isForgotten) {
// Cross for forgotten memories
ctx.strokeStyle = "rgba(220,38,38,0.4)"
ctx.lineWidth = 2
const r = nodeSize * 0.25
ctx.beginPath()
ctx.moveTo(screenX - r, screenY - r)
ctx.lineTo(screenX + r, screenY + r)
ctx.moveTo(screenX + r, screenY - r)
ctx.lineTo(screenX - r, screenY + r)
ctx.stroke()
} else if (isNew) {
// Small dot for new memories
ctx.fillStyle = colors.status.new
ctx.beginPath()
ctx.arc(
screenX + nodeSize * 0.25,
screenY - nodeSize * 0.25,
Math.max(2, nodeSize * 0.15), // Scale with node size, minimum 2px
0,
2 * Math.PI,
)
ctx.fill()
}
}
// Enhanced hover glow effect (skip when zoomed out for performance)
if (!useSimplifiedRendering && (isHovered || isDragging)) {
const glowColor =
node.type === "document" ? colors.document.glow : colors.memory.glow
ctx.strokeStyle = glowColor
ctx.lineWidth = 1
ctx.setLineDash([3, 3])
ctx.globalAlpha = 0.6
ctx.beginPath()
const glowSize = nodeSize * 0.7
if (node.type === "document") {
ctx.roundRect(
screenX - glowSize,
screenY - glowSize / 1.4,
glowSize * 2,
glowSize * 1.4,
15,
)
} else {
// Hexagonal glow for memory nodes
const glowRadius = glowSize
const sides = 6
for (let i = 0; i < sides; i++) {
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2
const x = screenX + glowRadius * Math.cos(angle)
const y = screenY + glowRadius * Math.sin(angle)
if (i === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
}
ctx.closePath()
}
ctx.stroke()
ctx.setLineDash([])
}
})
ctx.globalAlpha = 1
}, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds])
// Change-based rendering instead of continuous animation
const lastRenderParams = useRef<string>("")
// Create a render key that changes when visual state changes
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}`
}, [
nodes,
edges.length,
panX,
panY,
zoom,
width,
height,
highlightDocumentIds,
])
// Only render when something actually changed
useEffect(() => {
if (renderKey !== lastRenderParams.current) {
lastRenderParams.current = renderKey
render()
}
}, [renderKey, render])
// Cleanup any existing animation frames
useEffect(() => {
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
}
}, [])
// Add native wheel event listener to prevent browser zoom
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const handleNativeWheel = (e: WheelEvent) => {
e.preventDefault()
e.stopPropagation()
// Call the onWheel handler with a synthetic-like event
// @ts-expect-error - partial WheelEvent object
onWheel({
deltaY: e.deltaY,
deltaX: e.deltaX,
clientX: e.clientX,
clientY: e.clientY,
currentTarget: canvas,
nativeEvent: e,
preventDefault: () => {},
stopPropagation: () => {},
} as React.WheelEvent)
}
// Add listener with passive: false to ensure preventDefault works
canvas.addEventListener("wheel", handleNativeWheel, { passive: false })
// Also prevent gesture events for touch devices
const handleGesture = (e: Event) => {
e.preventDefault()
}
canvas.addEventListener("gesturestart", handleGesture, {
passive: false,
})
canvas.addEventListener("gesturechange", handleGesture, {
passive: false,
})
canvas.addEventListener("gestureend", handleGesture, { passive: false })
return () => {
canvas.removeEventListener("wheel", handleNativeWheel)
canvas.removeEventListener("gesturestart", handleGesture)
canvas.removeEventListener("gesturechange", handleGesture)
canvas.removeEventListener("gestureend", handleGesture)
}
}, [onWheel])
// High-DPI handling --------------------------------------------------
const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1
useLayoutEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
// upscale backing store
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
canvas.width = width * dpr
canvas.height = height * dpr
const ctx = canvas.getContext("2d")
ctx?.scale(dpr, dpr)
}, [width, height, dpr])
// -----------------------------------------------------------------------
return (
<canvas
className={canvasWrapper}
height={height}
onClick={handleClick}
onDoubleClick={onDoubleClick}
onMouseDown={handleMouseDown}
onMouseLeave={() => {
if (draggingNodeId) {
onNodeDragEnd()
} else {
onPanEnd()
}
}}
onMouseMove={(e) => {
handleMouseMove(e)
if (!draggingNodeId) {
onPanMove(e)
}
}}
onMouseUp={() => {
if (draggingNodeId) {
onNodeDragEnd()
} else {
onPanEnd()
}
}}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
ref={canvasRef}
style={{
cursor: draggingNodeId
? "grabbing"
: currentHoveredNode.current
? "grab"
: "move",
touchAction: "none",
userSelect: "none",
WebkitUserSelect: "none",
}}
width={width}
/>
)
},
)
GraphCanvas.displayName = "GraphCanvas"