diff options
Diffstat (limited to 'packages/ui/memory-graph/graph-canvas.tsx')
| -rw-r--r-- | packages/ui/memory-graph/graph-canvas.tsx | 762 |
1 files changed, 0 insertions, 762 deletions
diff --git a/packages/ui/memory-graph/graph-canvas.tsx b/packages/ui/memory-graph/graph-canvas.tsx deleted file mode 100644 index c4623c85..00000000 --- a/packages/ui/memory-graph/graph-canvas.tsx +++ /dev/null @@ -1,762 +0,0 @@ -"use client"; - -import { - memo, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, -} from "react"; -import { colors } from "./constants"; -import type { - DocumentWithMemories, - GraphCanvasProps, - GraphNode, - MemoryEntry, -} from "./types"; - -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 - 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="absolute inset-0" - 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"; |