"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( ({ 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 // @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 ( { 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"