aboutsummaryrefslogtreecommitdiff
path: root/packages/ui/memory-graph/graph-canvas.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/ui/memory-graph/graph-canvas.tsx')
-rw-r--r--packages/ui/memory-graph/graph-canvas.tsx762
1 files changed, 762 insertions, 0 deletions
diff --git a/packages/ui/memory-graph/graph-canvas.tsx b/packages/ui/memory-graph/graph-canvas.tsx
new file mode 100644
index 00000000..c4623c85
--- /dev/null
+++ b/packages/ui/memory-graph/graph-canvas.tsx
@@ -0,0 +1,762 @@
+"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";