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, 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";