"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( ({ 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(null); const animationRef = useRef(0); const startTimeRef = useRef(Date.now()); const mousePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const currentHoveredNode = useRef(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(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(""); // 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 ( { 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";