diff options
Diffstat (limited to 'packages/memory-graph/src/components/graph-canvas.tsx')
| -rw-r--r-- | packages/memory-graph/src/components/graph-canvas.tsx | 662 |
1 files changed, 329 insertions, 333 deletions
diff --git a/packages/memory-graph/src/components/graph-canvas.tsx b/packages/memory-graph/src/components/graph-canvas.tsx index 59efa74d..ee4f5885 100644 --- a/packages/memory-graph/src/components/graph-canvas.tsx +++ b/packages/memory-graph/src/components/graph-canvas.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client" import { memo, @@ -7,15 +7,15 @@ import { useLayoutEffect, useMemo, useRef, -} from "react"; -import { colors } from "@/constants"; +} from "react" +import { colors } from "@/constants" import type { DocumentWithMemories, GraphCanvasProps, GraphNode, MemoryEntry, -} from "@/types"; -import { canvasWrapper } from "./canvas-common.css"; +} from "@/types" +import { canvasWrapper } from "./canvas-common.css" export const GraphCanvas = memo<GraphCanvasProps>( ({ @@ -42,160 +42,160 @@ export const GraphCanvas = memo<GraphCanvasProps>( 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); + 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(); - }, []); + 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 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); + const dx = x - screenX + const dy = y - screenY + const distance = Math.sqrt(dx * dx + dy * dy) if (distance <= nodeSize / 2) { - return node.id; + return node.id } } - return null; + return null }, [nodes, panX, panY, zoom], - ); + ) // Handle mouse events const handleMouseMove = useCallback( (e: React.MouseEvent) => { - const canvas = canvasRef.current; - if (!canvas) return; + const canvas = canvasRef.current + if (!canvas) return - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top - mousePos.current = { x, y }; + mousePos.current = { x, y } - const nodeId = getNodeAtPosition(x, y); + const nodeId = getNodeAtPosition(x, y) if (nodeId !== currentHoveredNode.current) { - currentHoveredNode.current = nodeId; - onNodeHover(nodeId); + currentHoveredNode.current = nodeId + onNodeHover(nodeId) } // Handle node dragging if (draggingNodeId) { - onNodeDragMove(e); + onNodeDragMove(e) } }, [getNodeAtPosition, onNodeHover, draggingNodeId, onNodeDragMove], - ); + ) const handleMouseDown = useCallback( (e: React.MouseEvent) => { - const canvas = canvasRef.current; - if (!canvas) return; + const canvas = canvasRef.current + if (!canvas) return - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top - const nodeId = getNodeAtPosition(x, y); + const nodeId = getNodeAtPosition(x, y) if (nodeId) { // When starting a node drag, prevent initiating pan - e.stopPropagation(); - onNodeDragStart(nodeId, e); - return; + e.stopPropagation() + onNodeDragStart(nodeId, e) + return } - onPanStart(e); + onPanStart(e) }, [getNodeAtPosition, onNodeDragStart, onPanStart], - ); + ) const handleClick = useCallback( (e: React.MouseEvent) => { - const canvas = canvasRef.current; - if (!canvas) return; + const canvas = canvasRef.current + if (!canvas) return - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top - const nodeId = getNodeAtPosition(x, y); + const nodeId = getNodeAtPosition(x, y) if (nodeId) { - onNodeClick(nodeId); + onNodeClick(nodeId) } }, [getNodeAtPosition, onNodeClick], - ); + ) // Professional rendering function with LOD const render = useCallback(() => { - const canvas = canvasRef.current; - if (!canvas) return; + const canvas = canvasRef.current + if (!canvas) return - const ctx = canvas.getContext("2d"); - if (!ctx) return; + const ctx = canvas.getContext("2d") + if (!ctx) return - const currentTime = Date.now(); - const _elapsed = currentTime - startTimeRef.current; + const currentTime = Date.now() + const _elapsed = currentTime - startTimeRef.current // Level-of-detail optimization based on zoom - const useSimplifiedRendering = zoom < 0.3; + const useSimplifiedRendering = zoom < 0.3 // Clear canvas - ctx.clearRect(0, 0, width, height); + ctx.clearRect(0, 0, width, height) // Set high quality rendering - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = "high"; + 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; + 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(); + 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(); + 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])); + const nodeMap = new Map(nodes.map((node) => [node.id, node])) // Draw enhanced edges with sophisticated styling - ctx.lineCap = "round"; + ctx.lineCap = "round" edges.forEach((edge) => { - const sourceNode = nodeMap.get(edge.source); - const targetNode = nodeMap.get(edge.target); + 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; + 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 ( @@ -204,7 +204,7 @@ export const GraphCanvas = memo<GraphCanvasProps>( targetX < -100 || targetX > width + 100 ) { - return; + return } // Skip very weak connections when zoomed out for performance @@ -213,368 +213,365 @@ export const GraphCanvas = memo<GraphCanvasProps>( edge.edgeType === "doc-memory" && edge.visualProps.opacity < 0.3 ) { - return; // Skip very weak doc-memory edges when zoomed out + 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); + 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; + 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 + 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; + connectionColor = colors.connection.strong else if (edge.similarity > 0.725) - connectionColor = colors.connection.medium; + 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; + dashPattern = [] + connectionColor = edge.color || colors.relations.updates + opacity = 0.8 + lineWidth = 2 } - ctx.strokeStyle = connectionColor; - ctx.lineWidth = lineWidth; - ctx.globalAlpha = opacity; - ctx.setLineDash(dashPattern); + 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(); + 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(); + 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(); + 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 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); + : Math.min(30, distance * 0.2) - ctx.beginPath(); - ctx.moveTo(sourceX, sourceY); + ctx.beginPath() + ctx.moveTo(sourceX, sourceY) ctx.quadraticCurveTo( midX + controlOffset * (dy / distance), midY - controlOffset * (dx / distance), targetX, targetY, - ); - ctx.stroke(); + ) + 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); + 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; + 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([]); + 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.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([]); + ctx.globalAlpha = 1 + ctx.setLineDash([]) // Prepare highlight set from provided document IDs (customId or internal) - const highlightSet = new Set<string>(highlightDocumentIds ?? []); + 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; + const screenX = node.x * zoom + panX + const screenY = node.y * zoom + panY + const nodeSize = node.size * zoom // Enhanced viewport culling - const margin = nodeSize + 50; + const margin = nodeSize + 50 if ( screenX < -margin || screenX > width + margin || screenY < -margin || screenY > height + margin ) { - return; + return } - const isHovered = currentHoveredNode.current === node.id; - const isDragging = node.isDragging; + 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" || 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; + 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; + : 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; + : colors.document.border + ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1 // Rounded rectangle with enhanced styling - const radius = useSimplifiedRendering ? 6 : 12; - ctx.beginPath(); + const radius = useSimplifiedRendering ? 6 : 12 + ctx.beginPath() ctx.roundRect( screenX - docWidth / 2, screenY - docHeight / 2, docWidth, docHeight, radius, - ); - ctx.fill(); - ctx.stroke(); + ) + 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.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(); + ) + 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.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(); + ) + ctx.stroke() + ctx.setLineDash([]) + ctx.restore() } } else { // Enhanced memory styling with status indicators - const mem = node.data as MemoryEntry; + const mem = node.data as MemoryEntry const isForgotten = mem.isForgotten || (mem.forgetAfter && - new Date(mem.forgetAfter).getTime() < Date.now()); - const isLatest = mem.isLatest; + 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; + 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; + 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; + 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)"; + 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; + borderColor = colors.status.expiring + glowColor = colors.accent.amber } else if (isNew) { - borderColor = colors.status.new; - glowColor = colors.accent.emerald; + borderColor = colors.status.new + glowColor = colors.accent.emerald } if (isDragging) { - fillColor = colors.memory.accent; - borderColor = glowColor; + fillColor = colors.memory.accent + borderColor = glowColor } else if (isHovered) { - fillColor = colors.memory.secondary; + fillColor = colors.memory.secondary } - const radius = nodeSize / 2; + const radius = nodeSize / 2 - ctx.fillStyle = fillColor; - ctx.globalAlpha = isLatest ? 1 : 0.4; - ctx.strokeStyle = borderColor; - ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1.5; + 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(); + 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(); + 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); + 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); + ctx.moveTo(x, y) } else { - ctx.lineTo(x, y); + ctx.lineTo(x, y) } } - ctx.closePath(); - ctx.fill(); - ctx.stroke(); + 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(); + 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); + 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); + ctx.moveTo(x, y) } else { - ctx.lineTo(x, y); + ctx.lineTo(x, y) } } - ctx.closePath(); - ctx.stroke(); + 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(); + 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.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(); + ) + 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; + node.type === "document" ? colors.document.glow : colors.memory.glow - ctx.strokeStyle = glowColor; - ctx.lineWidth = 1; - ctx.setLineDash([3, 3]); - ctx.globalAlpha = 0.6; + ctx.strokeStyle = glowColor + ctx.lineWidth = 1 + ctx.setLineDash([3, 3]) + ctx.globalAlpha = 0.6 - ctx.beginPath(); - const glowSize = nodeSize * 0.7; + ctx.beginPath() + const glowSize = nodeSize * 0.7 if (node.type === "document") { ctx.roundRect( screenX - glowSize, @@ -582,33 +579,33 @@ export const GraphCanvas = memo<GraphCanvasProps>( glowSize * 2, glowSize * 1.4, 15, - ); + ) } else { // Hexagonal glow for memory nodes - const glowRadius = glowSize; - const sides = 6; + 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); + 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); + ctx.moveTo(x, y) } else { - ctx.lineTo(x, y); + ctx.lineTo(x, y) } } - ctx.closePath(); + ctx.closePath() } - ctx.stroke(); - ctx.setLineDash([]); + ctx.stroke() + ctx.setLineDash([]) } - }); + }) - ctx.globalAlpha = 1; - }, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds]); + ctx.globalAlpha = 1 + }, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds]) // Change-based rendering instead of continuous animation - const lastRenderParams = useRef<string>(""); + const lastRenderParams = useRef<string>("") // Create a render key that changes when visual state changes const renderKey = useMemo(() => { @@ -617,9 +614,9 @@ export const GraphCanvas = memo<GraphCanvasProps>( (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}`; + .join("|") + const highlightKey = (highlightDocumentIds ?? []).join("|") + return `${nodePositions}-${edges.length}-${panX}-${panY}-${zoom}-${width}-${height}-${highlightKey}` }, [ nodes, edges.length, @@ -629,33 +626,33 @@ export const GraphCanvas = memo<GraphCanvasProps>( width, height, highlightDocumentIds, - ]); + ]) // Only render when something actually changed useEffect(() => { if (renderKey !== lastRenderParams.current) { - lastRenderParams.current = renderKey; - render(); + lastRenderParams.current = renderKey + render() } - }, [renderKey, render]); + }, [renderKey, render]) // Cleanup any existing animation frames useEffect(() => { return () => { if (animationRef.current) { - cancelAnimationFrame(animationRef.current); + cancelAnimationFrame(animationRef.current) } - }; - }, []); + } + }, []) // Add native wheel event listener to prevent browser zoom useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; + const canvas = canvasRef.current + if (!canvas) return const handleNativeWheel = (e: WheelEvent) => { - e.preventDefault(); - e.stopPropagation(); + e.preventDefault() + e.stopPropagation() // Call the onWheel handler with a synthetic-like event // @ts-expect-error - partial WheelEvent object @@ -668,50 +665,49 @@ export const GraphCanvas = memo<GraphCanvasProps>( nativeEvent: e, preventDefault: () => {}, stopPropagation: () => {}, - } as React.WheelEvent); - }; + } as React.WheelEvent) + } // Add listener with passive: false to ensure preventDefault works - canvas.addEventListener("wheel", handleNativeWheel, { passive: false }); + canvas.addEventListener("wheel", handleNativeWheel, { passive: false }) // Also prevent gesture events for touch devices const handleGesture = (e: Event) => { - e.preventDefault(); - }; + e.preventDefault() + } canvas.addEventListener("gesturestart", handleGesture, { passive: false, - }); + }) canvas.addEventListener("gesturechange", handleGesture, { passive: false, - }); - canvas.addEventListener("gestureend", 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]); + 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; + const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1 useLayoutEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; + 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]); + 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 ( @@ -723,22 +719,22 @@ export const GraphCanvas = memo<GraphCanvasProps>( onMouseDown={handleMouseDown} onMouseLeave={() => { if (draggingNodeId) { - onNodeDragEnd(); + onNodeDragEnd() } else { - onPanEnd(); + onPanEnd() } }} onMouseMove={(e) => { - handleMouseMove(e); + handleMouseMove(e) if (!draggingNodeId) { - onPanMove(e); + onPanMove(e) } }} onMouseUp={() => { if (draggingNodeId) { - onNodeDragEnd(); + onNodeDragEnd() } else { - onPanEnd(); + onPanEnd() } }} onTouchStart={onTouchStart} @@ -757,8 +753,8 @@ export const GraphCanvas = memo<GraphCanvasProps>( }} width={width} /> - ); + ) }, -); +) -GraphCanvas.displayName = "GraphCanvas"; +GraphCanvas.displayName = "GraphCanvas" |