diff options
| author | Mahesh Sanikommu <[email protected]> | 2025-12-06 17:23:42 -0800 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-12-06 17:23:42 -0800 |
| commit | 41d24a4a1cc7a3c054fb5460d19cff7aac1d609d (patch) | |
| tree | c91d2837f43f99cc6979a169a7facfd6e148ce79 /packages/ui/memory-graph | |
| parent | feat(tools): allow passing apiKey via options for browser support (#599) (diff) | |
| download | supermemory-41d24a4a1cc7a3c054fb5460d19cff7aac1d609d.tar.xz supermemory-41d24a4a1cc7a3c054fb5460d19cff7aac1d609d.zip | |
fix ui issues and package issue (#610)
Diffstat (limited to 'packages/ui/memory-graph')
| -rw-r--r-- | packages/ui/memory-graph/constants.ts | 100 | ||||
| -rw-r--r-- | packages/ui/memory-graph/controls.tsx | 67 | ||||
| -rw-r--r-- | packages/ui/memory-graph/graph-canvas.tsx | 762 | ||||
| -rw-r--r-- | packages/ui/memory-graph/graph-webgl-canvas.tsx | 794 | ||||
| -rw-r--r-- | packages/ui/memory-graph/hooks/use-graph-data.ts | 304 | ||||
| -rw-r--r-- | packages/ui/memory-graph/hooks/use-graph-interactions.ts | 564 | ||||
| -rw-r--r-- | packages/ui/memory-graph/index.ts | 19 | ||||
| -rw-r--r-- | packages/ui/memory-graph/legend.tsx | 311 | ||||
| -rw-r--r-- | packages/ui/memory-graph/loading-indicator.tsx | 44 | ||||
| -rw-r--r-- | packages/ui/memory-graph/memory-graph.tsx | 458 | ||||
| -rw-r--r-- | packages/ui/memory-graph/navigation-controls.tsx | 67 | ||||
| -rw-r--r-- | packages/ui/memory-graph/node-detail-panel.tsx | 268 | ||||
| -rw-r--r-- | packages/ui/memory-graph/spaces-dropdown.tsx | 120 | ||||
| -rw-r--r-- | packages/ui/memory-graph/types.ts | 122 |
14 files changed, 4000 insertions, 0 deletions
diff --git a/packages/ui/memory-graph/constants.ts b/packages/ui/memory-graph/constants.ts new file mode 100644 index 00000000..23193601 --- /dev/null +++ b/packages/ui/memory-graph/constants.ts @@ -0,0 +1,100 @@ +// Enhanced glass-morphism color palette +export const colors = { + background: { + primary: "#0f1419", // Deep dark blue-gray + secondary: "#1a1f29", // Slightly lighter + accent: "#252a35", // Card backgrounds + }, + document: { + primary: "rgba(255, 255, 255, 0.06)", // Subtle glass white + secondary: "rgba(255, 255, 255, 0.12)", // More visible + accent: "rgba(255, 255, 255, 0.18)", // Hover state + border: "rgba(255, 255, 255, 0.25)", // Sharp borders + glow: "rgba(147, 197, 253, 0.4)", // Blue glow for interaction + }, + memory: { + primary: "rgba(147, 197, 253, 0.08)", // Subtle glass blue + secondary: "rgba(147, 197, 253, 0.16)", // More visible + accent: "rgba(147, 197, 253, 0.24)", // Hover state + border: "rgba(147, 197, 253, 0.35)", // Sharp borders + glow: "rgba(147, 197, 253, 0.5)", // Blue glow for interaction + }, + connection: { + weak: "rgba(148, 163, 184, 0)", // Very subtle + memory: "rgba(148, 163, 184, 0.3)", // Very subtle + medium: "rgba(148, 163, 184, 0.125)", // Medium visibility + strong: "rgba(148, 163, 184, 0.4)", // Strong connection + }, + text: { + primary: "#ffffff", // Pure white + secondary: "#e2e8f0", // Light gray + muted: "#94a3b8", // Medium gray + }, + accent: { + primary: "rgba(59, 130, 246, 0.7)", // Clean blue + secondary: "rgba(99, 102, 241, 0.6)", // Clean purple + glow: "rgba(147, 197, 253, 0.6)", // Subtle glow + amber: "rgba(251, 165, 36, 0.8)", // Amber for expiring + emerald: "rgba(16, 185, 129, 0.4)", // Emerald for new + }, + status: { + forgotten: "rgba(220, 38, 38, 0.15)", // Red for forgotten + expiring: "rgba(251, 165, 36, 0.8)", // Amber for expiring soon + new: "rgba(16, 185, 129, 0.4)", // Emerald for new memories + }, + relations: { + updates: "rgba(147, 77, 253, 0.5)", // purple + extends: "rgba(16, 185, 129, 0.5)", // green + derives: "rgba(147, 197, 253, 0.5)", // blue + }, +}; + +export const LAYOUT_CONSTANTS = { + centerX: 400, + centerY: 300, + clusterRadius: 300, // Memory "bubble" size around a doc - smaller bubble + spaceSpacing: 1600, // How far apart the *spaces* (groups of docs) sit - push spaces way out + documentSpacing: 1000, // How far the first doc in a space sits from its space-centre - push docs way out + minDocDist: 900, // Minimum distance two documents in the **same space** are allowed to be - sets repulsion radius + memoryClusterRadius: 300, +}; + +// Graph view settings +export const GRAPH_SETTINGS = { + console: { + initialZoom: 0.8, // Higher zoom for console - better overview + initialPanX: 0, + initialPanY: 0, + }, + consumer: { + initialZoom: 0.5, // Changed from 0.1 to 0.5 for better initial visibility + initialPanX: 400, // Pan towards center to compensate for larger layout + initialPanY: 300, // Pan towards center to compensate for larger layout + }, +}; + +// Responsive positioning for different app variants +export const POSITIONING = { + console: { + legend: { + desktop: "bottom-4 right-4", + mobile: "bottom-4 right-4", + }, + loadingIndicator: "top-20 right-4", + + spacesSelector: "top-4 left-4", + viewToggle: "", // Not used in console + nodeDetail: "top-4 right-4", + }, + consumer: { + legend: { + desktop: "top-18 right-4", + mobile: "bottom-[180px] left-4", + }, + loadingIndicator: "top-20 right-4", + + spacesSelector: "", // Hidden in consumer + viewToggle: "top-4 right-4", // Consumer has view toggle + nodeDetail: "top-4 right-4", + }, +}; diff --git a/packages/ui/memory-graph/controls.tsx b/packages/ui/memory-graph/controls.tsx new file mode 100644 index 00000000..899d239a --- /dev/null +++ b/packages/ui/memory-graph/controls.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { cn } from "@repo/lib/utils"; +import { Button } from "@repo/ui/components/button"; +import { GlassMenuEffect } from "@repo/ui/other/glass-effect"; +import { Move, ZoomIn, ZoomOut } from "lucide-react"; +import { memo } from "react"; +import type { ControlsProps } from "./types"; + +export const Controls = memo<ControlsProps>( + ({ onZoomIn, onZoomOut, onResetView, variant = "console" }) => { + // Use explicit classes - controls positioning not defined in constants + // Using a reasonable default position + const getPositioningClasses = () => { + if (variant === "console") { + return "bottom-4 left-4"; + } + if (variant === "consumer") { + return "bottom-20 right-4"; + } + return ""; + }; + + return ( + <div + className={cn( + "absolute z-10 rounded-xl overflow-hidden", + getPositioningClasses(), + )} + > + {/* Glass effect background */} + <GlassMenuEffect rounded="rounded-xl" /> + + <div className="relative z-10 px-4 py-3"> + <div className="flex items-center gap-2"> + <Button + className="h-8 w-8 p-0 text-slate-200 hover:bg-slate-700/40 hover:text-slate-100 transition-colors" + onClick={onZoomIn} + size="sm" + variant="ghost" + > + <ZoomIn className="w-4 h-4" /> + </Button> + <Button + className="h-8 w-8 p-0 text-slate-200 hover:bg-slate-700/40 hover:text-slate-100 transition-colors" + onClick={onZoomOut} + size="sm" + variant="ghost" + > + <ZoomOut className="w-4 h-4" /> + </Button> + <Button + className="h-8 w-8 p-0 text-slate-200 hover:bg-slate-700/40 hover:text-slate-100 transition-colors" + onClick={onResetView} + size="sm" + variant="ghost" + > + <Move className="w-4 h-4" /> + </Button> + </div> + </div> + </div> + ); + }, +); + +Controls.displayName = "Controls"; 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"; diff --git a/packages/ui/memory-graph/graph-webgl-canvas.tsx b/packages/ui/memory-graph/graph-webgl-canvas.tsx new file mode 100644 index 00000000..af13eefc --- /dev/null +++ b/packages/ui/memory-graph/graph-webgl-canvas.tsx @@ -0,0 +1,794 @@ +"use client"; + +import { Application, extend } from "@pixi/react"; +import { Container as PixiContainer, Graphics as PixiGraphics } from "pixi.js"; +import { memo, useCallback, useEffect, useMemo, useRef } from "react"; +import { colors } from "./constants"; +import type { GraphCanvasProps, MemoryEntry } from "./types"; + +// Register Pixi Graphics and Container so they can be used as JSX elements +extend({ Graphics: PixiGraphics, Container: PixiContainer }); + +export const GraphWebGLCanvas = memo<GraphCanvasProps>( + ({ + nodes, + edges, + panX, + panY, + zoom, + width, + height, + onNodeHover, + onNodeClick, + onNodeDragStart, + onNodeDragMove, + onNodeDragEnd, + onPanStart, + onPanMove, + onPanEnd, + onWheel, + onDoubleClick, + onTouchStart, + onTouchMove, + onTouchEnd, + draggingNodeId, + }) => { + const containerRef = useRef<HTMLDivElement>(null); + const isPanningRef = useRef(false); + const currentHoveredRef = useRef<string | null>(null); + const pointerDownPosRef = useRef<{ x: number; y: number } | null>(null); + const pointerMovedRef = useRef(false); + // World container that is transformed instead of redrawing every pan/zoom + const worldContainerRef = useRef<PixiContainer | null>(null); + + // Throttled wheel handling ------------------------------------------- + const pendingWheelDeltaRef = useRef<{ dx: number; dy: number }>({ + dx: 0, + dy: 0, + }); + const wheelRafRef = useRef<number | null>(null); + // Removed bitmap caching due to black-screen issues – throttle already boosts zoom performance + + // Persistent graphics refs + const gridG = useRef<PixiGraphics | null>(null); + const edgesG = useRef<PixiGraphics | null>(null); + const docsG = useRef<PixiGraphics | null>(null); + const memsG = useRef<PixiGraphics | null>(null); + + // ---------- Zoom bucket (reduces redraw frequency) ---------- + const zoomBucket = useMemo(() => Math.round(zoom * 4) / 4, [zoom]); + + // Redraw layers only when their data changes ---------------------- + useEffect(() => { + if (gridG.current) drawGrid(gridG.current); + }, [panX, panY, zoom, width, height]); + + useEffect(() => { + if (edgesG.current) drawEdges(edgesG.current); + }, [edgesG.current, edges, nodes, zoomBucket]); + + useEffect(() => { + if (docsG.current) drawDocuments(docsG.current); + }, [docsG.current, nodes, zoomBucket]); + + useEffect(() => { + if (memsG.current) drawMemories(memsG.current); + }, [memsG.current, nodes, zoomBucket]); + + // Apply pan & zoom via world transform instead of geometry rebuilds + useEffect(() => { + if (worldContainerRef.current) { + worldContainerRef.current.position.set(panX, panY); + worldContainerRef.current.scale.set(zoom); + } + }, [panX, panY, zoom]); + + // No bitmap caching – nothing to clean up + + /* ---------- Helpers ---------- */ + const getNodeAtPosition = useCallback( + (clientX: number, clientY: number): string | null => { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return null; + + const localX = clientX - rect.left; + const localY = clientY - rect.top; + + const worldX = (localX - panX) / zoom; + const worldY = (localY - panY) / zoom; + + for (const node of nodes) { + if (node.type === "document") { + const halfW = (node.size * 1.4) / 2; + const halfH = (node.size * 0.9) / 2; + if ( + worldX >= node.x - halfW && + worldX <= node.x + halfW && + worldY >= node.y - halfH && + worldY <= node.y + halfH + ) { + return node.id; + } + } else if (node.type === "memory") { + const r = node.size / 2; + const dx = worldX - node.x; + const dy = worldY - node.y; + if (dx * dx + dy * dy <= r * r) { + return node.id; + } + } + } + return null; + }, + [nodes, panX, panY, zoom], + ); + + /* ---------- Grid drawing ---------- */ + const drawGrid = useCallback( + (g: PixiGraphics) => { + g.clear(); + + const gridColor = 0x94a3b8; // rgb(148,163,184) + const gridAlpha = 0.03; + const gridSpacing = 100 * zoom; + + // panning offsets + const offsetX = panX % gridSpacing; + const offsetY = panY % gridSpacing; + + g.lineStyle(1, gridColor, gridAlpha); + + // vertical lines + for (let x = offsetX; x < width; x += gridSpacing) { + g.moveTo(x, 0); + g.lineTo(x, height); + } + + // horizontal lines + for (let y = offsetY; y < height; y += gridSpacing) { + g.moveTo(0, y); + g.lineTo(width, y); + } + + // Stroke to render grid lines + g.stroke(); + }, + [panX, panY, zoom, width, height], + ); + + /* ---------- Color parsing ---------- */ + const toHexAlpha = (input: string): { hex: number; alpha: number } => { + if (!input) return { hex: 0xffffff, alpha: 1 }; + const str = input.trim().toLowerCase(); + // rgba() or rgb() + const rgbaMatch = str + .replace(/\s+/g, "") + .match(/rgba?\((\d+),(\d+),(\d+)(?:,(\d*\.?\d+))?\)/i); + if (rgbaMatch) { + const r = Number.parseInt(rgbaMatch[1] || "0"); + const g = Number.parseInt(rgbaMatch[2] || "0"); + const b = Number.parseInt(rgbaMatch[3] || "0"); + const a = + rgbaMatch[4] !== undefined ? Number.parseFloat(rgbaMatch[4]) : 1; + return { hex: (r << 16) + (g << 8) + b, alpha: a }; + } + // #rrggbb or #rrggbbaa + if (str.startsWith("#")) { + const hexBody = str.slice(1); + if (hexBody.length === 6) { + return { hex: Number.parseInt(hexBody, 16), alpha: 1 }; + } + if (hexBody.length === 8) { + const rgb = Number.parseInt(hexBody.slice(0, 6), 16); + const aByte = Number.parseInt(hexBody.slice(6, 8), 16); + return { hex: rgb, alpha: aByte / 255 }; + } + } + // 0xRRGGBB + if (str.startsWith("0x")) { + return { hex: Number.parseInt(str, 16), alpha: 1 }; + } + return { hex: 0xffffff, alpha: 1 }; + }; + + const drawDocuments = useCallback( + (g: PixiGraphics) => { + g.clear(); + + nodes.forEach((node) => { + if (node.type !== "document") return; + + // World-space coordinates – container transform handles pan/zoom + const screenX = node.x; + const screenY = node.y; + const nodeSize = node.size; + + const docWidth = nodeSize * 1.4; + const docHeight = nodeSize * 0.9; + + // Choose colors similar to canvas version + const fill = node.isDragging + ? colors.document.accent + : node.isHovered + ? colors.document.secondary + : colors.document.primary; + + const strokeCol = node.isDragging + ? colors.document.glow + : node.isHovered + ? colors.document.accent + : colors.document.border; + + const { hex: fillHex, alpha: fillAlpha } = toHexAlpha(fill); + const { hex: strokeHex, alpha: strokeAlpha } = toHexAlpha(strokeCol); + + // Stroke first then fill for proper shape borders + const docStrokeWidth = + (node.isDragging ? 3 : node.isHovered ? 2 : 1) / zoom; + g.lineStyle(docStrokeWidth, strokeHex, strokeAlpha); + g.beginFill(fillHex, fillAlpha); + + const radius = zoom < 0.3 ? 6 : 12; + g.drawRoundedRect( + screenX - docWidth / 2, + screenY - docHeight / 2, + docWidth, + docHeight, + radius, + ); + g.endFill(); + + // Inner highlight for glass effect (match GraphCanvas) + if (zoom >= 0.3 && (node.isHovered || node.isDragging)) { + const { hex: hlHex } = toHexAlpha("#ffffff"); + // Inner highlight stroke width constant + const innerStroke = 1 / zoom; + g.lineStyle(innerStroke, hlHex, 0.1); + g.drawRoundedRect( + screenX - docWidth / 2 + 1, + screenY - docHeight / 2 + 1, + docWidth - 2, + docHeight - 2, + radius - 1, + ); + g.stroke(); + } + }); + }, + [nodes, zoom], + ); + + /* ---------- Memories layer ---------- */ + const drawMemories = useCallback( + (g: PixiGraphics) => { + g.clear(); + + nodes.forEach((node) => { + if (node.type !== "memory") return; + + const mem = node.data as MemoryEntry; + const screenX = node.x; + const screenY = node.y; + const nodeSize = node.size; + + const radius = nodeSize / 2; + + // status checks + const isForgotten = + mem?.isForgotten || + (mem?.forgetAfter && + new Date(mem.forgetAfter).getTime() < Date.now()); + const isLatest = mem?.isLatest; + const expiringSoon = + mem?.forgetAfter && + !isForgotten && + new Date(mem.forgetAfter).getTime() - Date.now() < + 1000 * 60 * 60 * 24 * 7; + const isNew = + !isForgotten && + new Date(mem?.createdAt).getTime() > + Date.now() - 1000 * 60 * 60 * 24; + + // colours + 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 (node.isDragging) { + fillColor = colors.memory.accent; + borderColor = glowColor; + } else if (node.isHovered) { + fillColor = colors.memory.secondary; + } + + const { hex: fillHex, alpha: fillAlpha } = toHexAlpha(fillColor); + const { hex: borderHex, alpha: borderAlpha } = + toHexAlpha(borderColor); + + // Match canvas behavior: multiply by isLatest global alpha + const globalAlpha = isLatest ? 1 : 0.4; + const finalFillAlpha = globalAlpha * fillAlpha; + const finalStrokeAlpha = globalAlpha * borderAlpha; + // Stroke first then fill for visible border + const memStrokeW = + (node.isDragging ? 3 : node.isHovered ? 2 : 1.5) / zoom; + g.lineStyle(memStrokeW, borderHex, finalStrokeAlpha); + g.beginFill(fillHex, finalFillAlpha); + + if (zoom < 0.3) { + // simplified circle when zoomed out + g.drawCircle(screenX, screenY, radius); + } else { + // hexagon + const sides = 6; + const points: number[] = []; + for (let i = 0; i < sides; i++) { + const angle = (i * 2 * Math.PI) / sides - Math.PI / 2; + points.push(screenX + radius * Math.cos(angle)); + points.push(screenY + radius * Math.sin(angle)); + } + g.drawPolygon(points); + } + + g.endFill(); + + // Status overlays (forgotten / new) – match GraphCanvas visuals + if (isForgotten) { + const { hex: crossHex, alpha: crossAlpha } = toHexAlpha( + "rgba(220,38,38,0.4)", + ); + // Cross/ dot overlay stroke widths constant + const overlayStroke = 2 / zoom; + g.lineStyle(overlayStroke, crossHex, globalAlpha * crossAlpha); + const rCross = nodeSize * 0.25; + g.moveTo(screenX - rCross, screenY - rCross); + g.lineTo(screenX + rCross, screenY + rCross); + g.moveTo(screenX + rCross, screenY - rCross); + g.lineTo(screenX - rCross, screenY + rCross); + g.stroke(); + } else if (isNew) { + const { hex: dotHex, alpha: dotAlpha } = toHexAlpha( + colors.status.new, + ); + // Dot scales with node (GraphCanvas behaviour) + const dotRadius = Math.max(2, nodeSize * 0.15); + g.beginFill(dotHex, globalAlpha * dotAlpha); + g.drawCircle( + screenX + nodeSize * 0.25, + screenY - nodeSize * 0.25, + dotRadius, + ); + g.endFill(); + } + }); + }, + [nodes, zoom], + ); + + /* ---------- Edges layer ---------- */ + // Helper: draw dashed quadratic curve to approximate canvas setLineDash + const drawDashedQuadratic = useCallback( + ( + g: PixiGraphics, + sx: number, + sy: number, + cx: number, + cy: number, + tx: number, + ty: number, + dash = 10, + gap = 5, + ) => { + // Sample the curve and accumulate lines per dash to avoid overdraw + const curveLength = Math.sqrt((sx - tx) ** 2 + (sy - ty) ** 2); + const totalSamples = Math.max( + 20, + Math.min(120, Math.floor(curveLength / 10)), + ); + let prevX = sx; + let prevY = sy; + let distanceSinceToggle = 0; + let drawSegment = true; + let hasActiveDash = false; + let dashStartX = sx; + let dashStartY = sy; + + for (let i = 1; i <= totalSamples; i++) { + const t = i / totalSamples; + const mt = 1 - t; + const x = mt * mt * sx + 2 * mt * t * cx + t * t * tx; + const y = mt * mt * sy + 2 * mt * t * cy + t * t * ty; + + const dx = x - prevX; + const dy = y - prevY; + const segLen = Math.sqrt(dx * dx + dy * dy); + distanceSinceToggle += segLen; + + if (drawSegment) { + if (!hasActiveDash) { + dashStartX = prevX; + dashStartY = prevY; + hasActiveDash = true; + } + } + + const threshold = drawSegment ? dash : gap; + if (distanceSinceToggle >= threshold) { + // end of current phase + if (drawSegment && hasActiveDash) { + g.moveTo(dashStartX, dashStartY); + g.lineTo(prevX, prevY); + g.stroke(); + hasActiveDash = false; + } + distanceSinceToggle = 0; + drawSegment = !drawSegment; + // If we transition into draw mode, start a new dash at current segment start + if (drawSegment) { + dashStartX = prevX; + dashStartY = prevY; + hasActiveDash = true; + } + } + + prevX = x; + prevY = y; + } + + // Flush any active dash at the end + if (drawSegment && hasActiveDash) { + g.moveTo(dashStartX, dashStartY); + g.lineTo(prevX, prevY); + g.stroke(); + } + }, + [], + ); + const drawEdges = useCallback( + (g: PixiGraphics) => { + g.clear(); + + // Match GraphCanvas LOD behaviour + const useSimplified = zoom < 0.3; + + // quick node lookup + const nodeMap = new Map(nodes.map((n) => [n.id, n])); + + edges.forEach((edge) => { + // Skip very weak doc-memory edges when zoomed out – behaviour copied from GraphCanvas + if ( + useSimplified && + edge.edgeType === "doc-memory" && + (edge.visualProps?.opacity ?? 1) < 0.3 + ) { + return; + } + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (!source || !target) return; + + const sx = source.x; + const sy = source.y; + const tx = target.x; + const ty = target.y; + + // No viewport culling here because container transform handles visibility + + let lineWidth = Math.max(1, edge.visualProps?.thickness ?? 1); + // Use opacity exactly as provided to match GraphCanvas behaviour + let opacity = edge.visualProps.opacity; + let col = edge.color || colors.connection.weak; + + if (edge.edgeType === "doc-memory") { + lineWidth = 1; + opacity = 0.9; + col = colors.connection.memory; + + if (useSimplified && opacity < 0.3) return; + } else if (edge.edgeType === "doc-doc") { + opacity = Math.max(0, edge.similarity * 0.5); + lineWidth = Math.max(1, edge.similarity * 2); + col = colors.connection.medium; + if (edge.similarity > 0.85) col = colors.connection.strong; + } else if (edge.edgeType === "version") { + col = edge.color || colors.relations.updates; + opacity = 0.8; + lineWidth = 2; + } + + const { hex: strokeHex, alpha: colorAlpha } = toHexAlpha(col); + const finalEdgeAlpha = Math.max(0, Math.min(1, opacity * colorAlpha)); + + // Always use round line caps (same as Canvas 2D) + const screenLineWidth = lineWidth / zoom; + g.lineStyle(screenLineWidth, strokeHex, finalEdgeAlpha); + + if (edge.edgeType === "version") { + // double line effect to match canvas (outer thicker, faint + inner thin) + g.lineStyle(3 / zoom, strokeHex, finalEdgeAlpha * 0.3); + g.moveTo(sx, sy); + g.lineTo(tx, ty); + g.stroke(); + + g.lineStyle(1 / zoom, strokeHex, finalEdgeAlpha); + g.moveTo(sx, sy); + g.lineTo(tx, ty); + g.stroke(); + + // arrow head + const angle = Math.atan2(ty - sy, tx - sx); + const arrowLen = Math.max(6 / zoom, 8); + const nodeRadius = target.size / 2; + const ax = tx - Math.cos(angle) * (nodeRadius + 2); + const ay = ty - Math.sin(angle) * (nodeRadius + 2); + + g.moveTo(ax, ay); + g.lineTo( + ax - arrowLen * Math.cos(angle - Math.PI / 6), + ay - arrowLen * Math.sin(angle - Math.PI / 6), + ); + g.moveTo(ax, ay); + g.lineTo( + ax - arrowLen * Math.cos(angle + Math.PI / 6), + ay - arrowLen * Math.sin(angle + Math.PI / 6), + ); + g.stroke(); + } else { + // straight line when zoomed out; dashed curved when zoomed in for doc-doc + if (useSimplified) { + g.moveTo(sx, sy); + g.lineTo(tx, ty); + g.stroke(); + } else { + const midX = (sx + tx) / 2; + const midY = (sy + ty) / 2; + const dx = tx - sx; + const dy = ty - sy; + const dist = Math.sqrt(dx * dx + dy * dy); + const ctrlOffset = + edge.edgeType === "doc-memory" ? 15 : Math.min(30, dist * 0.2); + + const cx = midX + ctrlOffset * (dy / dist); + const cy = midY - ctrlOffset * (dx / dist); + + if (edge.edgeType === "doc-doc") { + if (useSimplified) { + // Straight line when zoomed out (no dash) + g.moveTo(sx, sy); + g.quadraticCurveTo(cx, cy, tx, ty); + g.stroke(); + } else { + // Dash lengths scale with zoom to keep screen size constant + const dash = 10 / zoom; + const gap = 5 / zoom; + drawDashedQuadratic(g, sx, sy, cx, cy, tx, ty, dash, gap); + } + } else { + g.moveTo(sx, sy); + g.quadraticCurveTo(cx, cy, tx, ty); + g.stroke(); + } + } + } + }); + }, + [edges, nodes, zoom, width, drawDashedQuadratic], + ); + + /* ---------- pointer handlers (unchanged) ---------- */ + // Pointer move (pan or drag) + const handlePointerMove = useCallback( + (e: React.PointerEvent<HTMLDivElement>) => { + const mouseEvent = { + clientX: e.clientX, + clientY: e.clientY, + preventDefault: () => {}, + stopPropagation: () => {}, + } as React.MouseEvent; + + if (draggingNodeId) { + // Node dragging handled elsewhere (future steps) + onNodeDragMove(mouseEvent); + } else if (isPanningRef.current) { + onPanMove(mouseEvent); + } + + // Track movement for distinguishing click vs drag/pan + if (pointerDownPosRef.current) { + const dx = e.clientX - pointerDownPosRef.current.x; + const dy = e.clientY - pointerDownPosRef.current.y; + if (Math.sqrt(dx * dx + dy * dy) > 3) pointerMovedRef.current = true; + } + + // Hover detection + const nodeId = getNodeAtPosition(e.clientX, e.clientY); + if (nodeId !== currentHoveredRef.current) { + currentHoveredRef.current = nodeId; + onNodeHover(nodeId); + } + }, + [ + draggingNodeId, + onNodeDragMove, + onPanMove, + onNodeHover, + getNodeAtPosition, + ], + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent<HTMLDivElement>) => { + const mouseEvent = { + clientX: e.clientX, + clientY: e.clientY, + preventDefault: () => {}, + stopPropagation: () => {}, + } as React.MouseEvent; + + const nodeId = getNodeAtPosition(e.clientX, e.clientY); + if (nodeId) { + onNodeDragStart(nodeId, mouseEvent); + // drag handled externally + } else { + onPanStart(mouseEvent); + isPanningRef.current = true; + } + pointerDownPosRef.current = { x: e.clientX, y: e.clientY }; + pointerMovedRef.current = false; + }, + [onPanStart, onNodeDragStart, getNodeAtPosition], + ); + + const handlePointerUp = useCallback( + (e: React.PointerEvent<HTMLDivElement>) => { + const wasPanning = isPanningRef.current; + if (draggingNodeId) onNodeDragEnd(); + else if (wasPanning) onPanEnd(); + + // Consider it a click if not panning and movement was minimal + if (!wasPanning && !pointerMovedRef.current) { + const nodeId = getNodeAtPosition(e.clientX, e.clientY); + if (nodeId) onNodeClick(nodeId); + } + + isPanningRef.current = false; + pointerDownPosRef.current = null; + pointerMovedRef.current = false; + }, + [draggingNodeId, onNodeDragEnd, onPanEnd, getNodeAtPosition, onNodeClick], + ); + + // Click handler – opens detail panel + const handleClick = useCallback( + (e: React.MouseEvent<HTMLDivElement>) => { + if (isPanningRef.current) return; + const nodeId = getNodeAtPosition(e.clientX, e.clientY); + if (nodeId) onNodeClick(nodeId); + }, + [getNodeAtPosition, onNodeClick], + ); + + // Click handled in pointer up to avoid duplicate events + + const handleWheel = useCallback( + (e: React.WheelEvent<HTMLDivElement>) => { + e.preventDefault(); + e.stopPropagation(); + + // Accumulate deltas + pendingWheelDeltaRef.current.dx += e.deltaX; + pendingWheelDeltaRef.current.dy += e.deltaY; + + // Schedule a single update per frame + if (wheelRafRef.current === null) { + wheelRafRef.current = requestAnimationFrame(() => { + const { dx, dy } = pendingWheelDeltaRef.current; + pendingWheelDeltaRef.current = { dx: 0, dy: 0 }; + + // @ts-expect-error + onWheel({ + deltaY: dy, + deltaX: dx, + clientX: e.clientX, + clientY: e.clientY, + currentTarget: containerRef.current, + nativeEvent: e.nativeEvent, + preventDefault: () => {}, + stopPropagation: () => {}, + } as React.WheelEvent); + + wheelRafRef.current = null; + + // nothing else – caching removed + }); + } + }, + [onWheel], + ); + + // Cleanup any pending RAF on unmount + useEffect(() => { + return () => { + if (wheelRafRef.current !== null) { + cancelAnimationFrame(wheelRafRef.current); + } + }; + }, []); + + return ( + <div + className="absolute inset-0" + onDoubleClick={(ev) => + onDoubleClick?.(ev as unknown as React.MouseEvent) + } + onKeyDown={(ev) => { + if (ev.key === "Enter") + handleClick(ev as unknown as React.MouseEvent<HTMLDivElement>); + }} + onPointerDown={handlePointerDown} + onPointerLeave={() => { + if (draggingNodeId) onNodeDragEnd(); + if (isPanningRef.current) onPanEnd(); + isPanningRef.current = false; + pointerDownPosRef.current = null; + pointerMovedRef.current = false; + }} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onTouchStart={onTouchStart} + onTouchMove={onTouchMove} + onTouchEnd={onTouchEnd} + onWheel={handleWheel} + ref={containerRef} + role="application" + style={{ + cursor: draggingNodeId ? "grabbing" : "move", + touchAction: "none", + userSelect: "none", + WebkitUserSelect: "none", + }} + > + <Application + preference="webgl" + antialias + autoDensity + backgroundColor={0x0f1419} + height={height} + resolution={ + typeof window !== "undefined" ? window.devicePixelRatio : 1 + } + width={width} + > + {/* Grid background (not affected by world transform) */} + <pixiGraphics ref={gridG} draw={() => {}} /> + + {/* World container that pans/zooms as a single transform */} + <pixiContainer ref={worldContainerRef}> + {/* Edges */} + <pixiGraphics ref={edgesG} draw={() => {}} /> + + {/* Documents */} + <pixiGraphics ref={docsG} draw={() => {}} /> + + {/* Memories */} + <pixiGraphics ref={memsG} draw={() => {}} /> + </pixiContainer> + </Application> + </div> + ); + }, +); + +GraphWebGLCanvas.displayName = "GraphWebGLCanvas"; diff --git a/packages/ui/memory-graph/hooks/use-graph-data.ts b/packages/ui/memory-graph/hooks/use-graph-data.ts new file mode 100644 index 00000000..3e9fa5cc --- /dev/null +++ b/packages/ui/memory-graph/hooks/use-graph-data.ts @@ -0,0 +1,304 @@ +"use client"; + +import { + calculateSemanticSimilarity, + getConnectionVisualProps, + getMagicalConnectionColor, +} from "@repo/lib/similarity"; +import { useMemo } from "react"; +import { colors, LAYOUT_CONSTANTS } from "../constants"; +import type { + DocumentsResponse, + DocumentWithMemories, + GraphEdge, + GraphNode, + MemoryEntry, + MemoryRelation, +} from "../types"; + +export function useGraphData( + data: DocumentsResponse | null, + selectedSpace: string, + nodePositions: Map<string, { x: number; y: number }>, + draggingNodeId: string | null, +) { + return useMemo(() => { + if (!data?.documents) return { nodes: [], edges: [] }; + + const allNodes: GraphNode[] = []; + const allEdges: GraphEdge[] = []; + + // Filter documents that have memories in selected space + const filteredDocuments = data.documents + .map((doc) => ({ + ...doc, + memoryEntries: + selectedSpace === "all" + ? doc.memoryEntries + : doc.memoryEntries.filter( + (memory) => + (memory.spaceContainerTag ?? memory.spaceId ?? "default") === + selectedSpace, + ), + })) + .filter((doc) => doc.memoryEntries.length > 0); + + // Group documents by space for better clustering + const documentsBySpace = new Map<string, typeof filteredDocuments>(); + filteredDocuments.forEach((doc) => { + const docSpace = + doc.memoryEntries[0]?.spaceContainerTag ?? + doc.memoryEntries[0]?.spaceId ?? + "default"; + if (!documentsBySpace.has(docSpace)) { + documentsBySpace.set(docSpace, []); + } + const spaceDocsArr = documentsBySpace.get(docSpace); + if (spaceDocsArr) { + spaceDocsArr.push(doc); + } + }); + + // Enhanced Layout with Space Separation + const { centerX, centerY, clusterRadius, spaceSpacing, documentSpacing } = + LAYOUT_CONSTANTS; + + /* 1. Build DOCUMENT nodes with space-aware clustering */ + const documentNodes: GraphNode[] = []; + let spaceIndex = 0; + + documentsBySpace.forEach((spaceDocs) => { + const spaceAngle = (spaceIndex / documentsBySpace.size) * Math.PI * 2; + const spaceOffsetX = Math.cos(spaceAngle) * spaceSpacing; + const spaceOffsetY = Math.sin(spaceAngle) * spaceSpacing; + const spaceCenterX = centerX + spaceOffsetX; + const spaceCenterY = centerY + spaceOffsetY; + + spaceDocs.forEach((doc, docIndex) => { + // Create proper circular layout with concentric rings + const docsPerRing = 6; // Start with 6 docs in inner ring + let currentRing = 0; + let docsInCurrentRing = docsPerRing; + let totalDocsInPreviousRings = 0; + + // Find which ring this document belongs to + while (totalDocsInPreviousRings + docsInCurrentRing <= docIndex) { + totalDocsInPreviousRings += docsInCurrentRing; + currentRing++; + docsInCurrentRing = docsPerRing + currentRing * 4; // Each ring has more docs + } + + // Position within the ring + const positionInRing = docIndex - totalDocsInPreviousRings; + const angleInRing = (positionInRing / docsInCurrentRing) * Math.PI * 2; + + // Radius increases significantly with each ring + const baseRadius = documentSpacing * 0.8; + const radius = + currentRing === 0 + ? baseRadius + : baseRadius + currentRing * documentSpacing * 1.2; + + const defaultX = spaceCenterX + Math.cos(angleInRing) * radius; + const defaultY = spaceCenterY + Math.sin(angleInRing) * radius; + + const customPos = nodePositions.get(doc.id); + + documentNodes.push({ + id: doc.id, + type: "document", + x: customPos?.x ?? defaultX, + y: customPos?.y ?? defaultY, + data: doc, + size: 58, + color: colors.document.primary, + isHovered: false, + isDragging: draggingNodeId === doc.id, + } satisfies GraphNode); + }); + + spaceIndex++; + }); + + /* 2. Gentle document collision avoidance with dampening */ + const minDocDist = LAYOUT_CONSTANTS.minDocDist; + + // Reduced iterations and gentler repulsion for smoother movement + for (let iter = 0; iter < 2; iter++) { + documentNodes.forEach((nodeA) => { + documentNodes.forEach((nodeB) => { + if (nodeA.id >= nodeB.id) return; + + // Only repel documents in the same space + const spaceA = + (nodeA.data as DocumentWithMemories).memoryEntries[0] + ?.spaceContainerTag ?? + (nodeA.data as DocumentWithMemories).memoryEntries[0]?.spaceId ?? + "default"; + const spaceB = + (nodeB.data as DocumentWithMemories).memoryEntries[0] + ?.spaceContainerTag ?? + (nodeB.data as DocumentWithMemories).memoryEntries[0]?.spaceId ?? + "default"; + + if (spaceA !== spaceB) return; + + const dx = nodeB.x - nodeA.x; + const dy = nodeB.y - nodeA.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + + if (dist < minDocDist) { + // Much gentler push with dampening + const push = (minDocDist - dist) / 8; + const dampening = Math.max(0.1, Math.min(1, dist / minDocDist)); + const smoothPush = push * dampening * 0.5; + + const nx = dx / dist; + const ny = dy / dist; + nodeA.x -= nx * smoothPush; + nodeA.y -= ny * smoothPush; + nodeB.x += nx * smoothPush; + nodeB.y += ny * smoothPush; + } + }); + }); + } + + allNodes.push(...documentNodes); + + /* 3. Add memories around documents WITH doc-memory connections */ + documentNodes.forEach((docNode) => { + const memoryNodeMap = new Map<string, GraphNode>(); + const doc = docNode.data as DocumentWithMemories; + + doc.memoryEntries.forEach((memory, memIndex) => { + const memoryId = `${memory.id}`; + const customMemPos = nodePositions.get(memoryId); + + const clusterAngle = + (memIndex / doc.memoryEntries.length) * Math.PI * 2; + const variation = Math.sin(memIndex * 2.5) * 0.3 + 0.7; + const distance = clusterRadius * variation; + + const seed = + memIndex * 12345 + Number.parseInt(docNode.id.slice(0, 6), 36); + const offsetX = Math.sin(seed) * 0.5 * 40; + const offsetY = Math.cos(seed) * 0.5 * 40; + + const defaultMemX = + docNode.x + Math.cos(clusterAngle) * distance + offsetX; + const defaultMemY = + docNode.y + Math.sin(clusterAngle) * distance + offsetY; + + if (!memoryNodeMap.has(memoryId)) { + const memoryNode: GraphNode = { + id: memoryId, + type: "memory", + x: customMemPos?.x ?? defaultMemX, + y: customMemPos?.y ?? defaultMemY, + data: memory, + size: Math.max( + 32, + Math.min(48, (memory.memory?.length || 50) * 0.5), + ), + color: colors.memory.primary, + isHovered: false, + isDragging: draggingNodeId === memoryId, + }; + memoryNodeMap.set(memoryId, memoryNode); + allNodes.push(memoryNode); + } + + // Create doc-memory edge with similarity + allEdges.push({ + id: `edge-${docNode.id}-${memory.id}`, + source: docNode.id, + target: memoryId, + similarity: 1, + visualProps: getConnectionVisualProps(1), + color: colors.connection.memory, + edgeType: "doc-memory", + }); + }); + }); + + // Build mapping of memoryId -> nodeId for version chains + const memNodeIdMap = new Map<string, string>(); + allNodes.forEach((n) => { + if (n.type === "memory") { + memNodeIdMap.set((n.data as MemoryEntry).id, n.id); + } + }); + + // Add version-chain edges (old -> new) + data.documents.forEach((doc) => { + doc.memoryEntries.forEach((mem: MemoryEntry) => { + // Support both new object structure and legacy array/single parent fields + let parentRelations: Record<string, MemoryRelation> = {}; + + if ( + mem.memoryRelations && + typeof mem.memoryRelations === "object" && + Object.keys(mem.memoryRelations).length > 0 + ) { + parentRelations = mem.memoryRelations; + } else if (mem.parentMemoryId) { + parentRelations = { + [mem.parentMemoryId]: "updates" as MemoryRelation, + }; + } + Object.entries(parentRelations).forEach(([pid, relationType]) => { + const fromId = memNodeIdMap.get(pid); + const toId = memNodeIdMap.get(mem.id); + if (fromId && toId) { + allEdges.push({ + id: `version-${fromId}-${toId}`, + source: fromId, + target: toId, + similarity: 1, + visualProps: { + opacity: 0.8, + thickness: 1, + glow: 0, + pulseDuration: 3000, + }, + // choose color based on relation type + color: colors.relations[relationType] ?? colors.relations.updates, + edgeType: "version", + relationType: relationType as MemoryRelation, + }); + } + }); + }); + }); + + // Document-to-document similarity edges + for (let i = 0; i < filteredDocuments.length; i++) { + const docI = filteredDocuments[i]; + if (!docI) continue; + + for (let j = i + 1; j < filteredDocuments.length; j++) { + const docJ = filteredDocuments[j]; + if (!docJ) continue; + + const sim = calculateSemanticSimilarity( + docI.summaryEmbedding ? Array.from(docI.summaryEmbedding) : null, + docJ.summaryEmbedding ? Array.from(docJ.summaryEmbedding) : null, + ); + if (sim > 0.725) { + allEdges.push({ + id: `doc-doc-${docI.id}-${docJ.id}`, + source: docI.id, + target: docJ.id, + similarity: sim, + visualProps: getConnectionVisualProps(sim), + color: getMagicalConnectionColor(sim, 200), + edgeType: "doc-doc", + }); + } + } + } + + return { nodes: allNodes, edges: allEdges }; + }, [data, selectedSpace, nodePositions, draggingNodeId]); +} diff --git a/packages/ui/memory-graph/hooks/use-graph-interactions.ts b/packages/ui/memory-graph/hooks/use-graph-interactions.ts new file mode 100644 index 00000000..ec44e83e --- /dev/null +++ b/packages/ui/memory-graph/hooks/use-graph-interactions.ts @@ -0,0 +1,564 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { GRAPH_SETTINGS } from "../constants"; +import type { GraphNode } from "../types"; + +export function useGraphInteractions( + variant: "console" | "consumer" = "console", +) { + const settings = GRAPH_SETTINGS[variant]; + + const [panX, setPanX] = useState(settings.initialPanX); + const [panY, setPanY] = useState(settings.initialPanY); + const [zoom, setZoom] = useState(settings.initialZoom); + const [isPanning, setIsPanning] = useState(false); + const [panStart, setPanStart] = useState({ x: 0, y: 0 }); + const [hoveredNode, setHoveredNode] = useState<string | null>(null); + const [selectedNode, setSelectedNode] = useState<string | null>(null); + const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null); + const [dragStart, setDragStart] = useState({ + x: 0, + y: 0, + nodeX: 0, + nodeY: 0, + }); + const [nodePositions, setNodePositions] = useState< + Map<string, { x: number; y: number }> + >(new Map()); + + // Touch gesture state + const [touchState, setTouchState] = useState<{ + touches: { id: number; x: number; y: number }[]; + lastDistance: number; + lastCenter: { x: number; y: number }; + isGesturing: boolean; + }>({ + touches: [], + lastDistance: 0, + lastCenter: { x: 0, y: 0 }, + isGesturing: false, + }); + + // Animation state for smooth transitions + const animationRef = useRef<number | null>(null); + const [isAnimating, setIsAnimating] = useState(false); + + // Smooth animation helper + const animateToViewState = useCallback( + ( + targetPanX: number, + targetPanY: number, + targetZoom: number, + duration = 300, + ) => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + + const startPanX = panX; + const startPanY = panY; + const startZoom = zoom; + const startTime = Date.now(); + + setIsAnimating(true); + + const animate = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / duration, 1); + + // Ease out cubic function for smooth transitions + const easeOut = 1 - (1 - progress) ** 3; + + const currentPanX = startPanX + (targetPanX - startPanX) * easeOut; + const currentPanY = startPanY + (targetPanY - startPanY) * easeOut; + const currentZoom = startZoom + (targetZoom - startZoom) * easeOut; + + setPanX(currentPanX); + setPanY(currentPanY); + setZoom(currentZoom); + + if (progress < 1) { + animationRef.current = requestAnimationFrame(animate); + } else { + setIsAnimating(false); + animationRef.current = null; + } + }; + + animate(); + }, + [panX, panY, zoom], + ); + + // Node drag handlers + const handleNodeDragStart = useCallback( + (nodeId: string, e: React.MouseEvent, nodes?: GraphNode[]) => { + const node = nodes?.find((n) => n.id === nodeId); + if (!node) return; + + setDraggingNodeId(nodeId); + setDragStart({ + x: e.clientX, + y: e.clientY, + nodeX: node.x, + nodeY: node.y, + }); + }, + [], + ); + + const handleNodeDragMove = useCallback( + (e: React.MouseEvent) => { + if (!draggingNodeId) return; + + const deltaX = (e.clientX - dragStart.x) / zoom; + const deltaY = (e.clientY - dragStart.y) / zoom; + + const newX = dragStart.nodeX + deltaX; + const newY = dragStart.nodeY + deltaY; + + setNodePositions((prev) => + new Map(prev).set(draggingNodeId, { x: newX, y: newY }), + ); + }, + [draggingNodeId, dragStart, zoom], + ); + + const handleNodeDragEnd = useCallback(() => { + setDraggingNodeId(null); + }, []); + + // Pan handlers + const handlePanStart = useCallback( + (e: React.MouseEvent) => { + setIsPanning(true); + setPanStart({ x: e.clientX - panX, y: e.clientY - panY }); + }, + [panX, panY], + ); + + const handlePanMove = useCallback( + (e: React.MouseEvent) => { + if (!isPanning || draggingNodeId) return; + + const newPanX = e.clientX - panStart.x; + const newPanY = e.clientY - panStart.y; + setPanX(newPanX); + setPanY(newPanY); + }, + [isPanning, panStart, draggingNodeId], + ); + + const handlePanEnd = useCallback(() => { + setIsPanning(false); + }, []); + + // Zoom handlers + const handleWheel = useCallback( + (e: React.WheelEvent) => { + // Always prevent default to stop browser navigation + e.preventDefault(); + e.stopPropagation(); + + // Handle horizontal scrolling (trackpad swipe) by converting to pan + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + // Horizontal scroll - pan the graph instead of zooming + const panDelta = e.deltaX * 0.5; + setPanX((prev) => prev - panDelta); + return; + } + + // Vertical scroll - zoom behavior + const delta = e.deltaY > 0 ? 0.97 : 1.03; + const newZoom = Math.max(0.05, Math.min(3, zoom * delta)); + + // Get mouse position relative to the viewport + let mouseX = e.clientX; + let mouseY = e.clientY; + + // Try to get the container bounds to make coordinates relative to the graph container + const target = e.currentTarget; + if (target && "getBoundingClientRect" in target) { + const rect = target.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + mouseY = e.clientY - rect.top; + } + + // Calculate the world position of the mouse cursor + const worldX = (mouseX - panX) / zoom; + const worldY = (mouseY - panY) / zoom; + + // Calculate new pan to keep the mouse position stationary + const newPanX = mouseX - worldX * newZoom; + const newPanY = mouseY - worldY * newZoom; + + setZoom(newZoom); + setPanX(newPanX); + setPanY(newPanY); + }, + [zoom, panX, panY], + ); + + const zoomIn = useCallback( + (centerX?: number, centerY?: number, animate = true) => { + const zoomFactor = 1.2; + const newZoom = Math.min(3, zoom * zoomFactor); // Increased max zoom to 3x + + if (centerX !== undefined && centerY !== undefined) { + // Mouse-centered zoom for programmatic zoom in + const worldX = (centerX - panX) / zoom; + const worldY = (centerY - panY) / zoom; + const newPanX = centerX - worldX * newZoom; + const newPanY = centerY - worldY * newZoom; + + if (animate && !isAnimating) { + animateToViewState(newPanX, newPanY, newZoom, 200); + } else { + setZoom(newZoom); + setPanX(newPanX); + setPanY(newPanY); + } + } else { + if (animate && !isAnimating) { + animateToViewState(panX, panY, newZoom, 200); + } else { + setZoom(newZoom); + } + } + }, + [zoom, panX, panY, isAnimating, animateToViewState], + ); + + const zoomOut = useCallback( + (centerX?: number, centerY?: number, animate = true) => { + const zoomFactor = 0.8; + const newZoom = Math.max(0.05, zoom * zoomFactor); // Decreased min zoom to 0.05x + + if (centerX !== undefined && centerY !== undefined) { + // Mouse-centered zoom for programmatic zoom out + const worldX = (centerX - panX) / zoom; + const worldY = (centerY - panY) / zoom; + const newPanX = centerX - worldX * newZoom; + const newPanY = centerY - worldY * newZoom; + + if (animate && !isAnimating) { + animateToViewState(newPanX, newPanY, newZoom, 200); + } else { + setZoom(newZoom); + setPanX(newPanX); + setPanY(newPanY); + } + } else { + if (animate && !isAnimating) { + animateToViewState(panX, panY, newZoom, 200); + } else { + setZoom(newZoom); + } + } + }, + [zoom, panX, panY, isAnimating, animateToViewState], + ); + + const resetView = useCallback(() => { + setPanX(settings.initialPanX); + setPanY(settings.initialPanY); + setZoom(settings.initialZoom); + setNodePositions(new Map()); + }, [settings]); + + // Auto-fit graph to viewport + const autoFitToViewport = useCallback( + ( + nodes: GraphNode[], + viewportWidth: number, + viewportHeight: number, + options?: { occludedRightPx?: number; animate?: boolean }, + ) => { + if (nodes.length === 0) return; + + // Find the bounds of all nodes + let minX = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + + nodes.forEach((node) => { + minX = Math.min(minX, node.x - node.size / 2); + maxX = Math.max(maxX, node.x + node.size / 2); + minY = Math.min(minY, node.y - node.size / 2); + maxY = Math.max(maxY, node.y + node.size / 2); + }); + + // Calculate the center of the content + const contentCenterX = (minX + maxX) / 2; + const contentCenterY = (minY + maxY) / 2; + + // Calculate the size of the content + const contentWidth = maxX - minX; + const contentHeight = maxY - minY; + + // Add padding (20% on each side) + const paddingFactor = 1.4; + const paddedWidth = contentWidth * paddingFactor; + const paddedHeight = contentHeight * paddingFactor; + + // Account for occluded area on the right (e.g., chat panel) + const occludedRightPx = Math.max(0, options?.occludedRightPx ?? 0); + const availableWidth = Math.max(1, viewportWidth - occludedRightPx); + + // Calculate the zoom needed to fit the content within available width + const zoomX = availableWidth / paddedWidth; + const zoomY = viewportHeight / paddedHeight; + const newZoom = Math.min(Math.max(0.05, Math.min(zoomX, zoomY)), 3); + + // Calculate pan to center the content within available area + const availableCenterX = availableWidth / 2; + const newPanX = availableCenterX - contentCenterX * newZoom; + const newPanY = viewportHeight / 2 - contentCenterY * newZoom; + + // Apply the new view (optional animation) + if (options?.animate) { + const steps = 8; + const durationMs = 160; // snappy + const intervalMs = Math.max(1, Math.floor(durationMs / steps)); + const startZoom = zoom; + const startPanX = panX; + const startPanY = panY; + let i = 0; + const ease = (t: number) => 1 - (1 - t) ** 2; // ease-out quad + const timer = setInterval(() => { + i++; + const t = ease(i / steps); + setZoom(startZoom + (newZoom - startZoom) * t); + setPanX(startPanX + (newPanX - startPanX) * t); + setPanY(startPanY + (newPanY - startPanY) * t); + if (i >= steps) clearInterval(timer); + }, intervalMs); + } else { + setZoom(newZoom); + setPanX(newPanX); + setPanY(newPanY); + } + }, + [zoom, panX, panY], + ); + + // Touch gesture handlers for mobile pinch-to-zoom + const handleTouchStart = useCallback((e: React.TouchEvent) => { + const touches = Array.from(e.touches).map((touch) => ({ + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + })); + + if (touches.length >= 2) { + // Start gesture with two or more fingers + const touch1 = touches[0]!; + const touch2 = touches[1]!; + + const distance = Math.sqrt( + (touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2, + ); + + const center = { + x: (touch1.x + touch2.x) / 2, + y: (touch1.y + touch2.y) / 2, + }; + + setTouchState({ + touches, + lastDistance: distance, + lastCenter: center, + isGesturing: true, + }); + } else { + setTouchState((prev) => ({ ...prev, touches, isGesturing: false })); + } + }, []); + + const handleTouchMove = useCallback( + (e: React.TouchEvent) => { + e.preventDefault(); + + const touches = Array.from(e.touches).map((touch) => ({ + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + })); + + if (touches.length >= 2 && touchState.isGesturing) { + const touch1 = touches[0]!; + const touch2 = touches[1]!; + + const distance = Math.sqrt( + (touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2, + ); + + const center = { + x: (touch1.x + touch2.x) / 2, + y: (touch1.y + touch2.y) / 2, + }; + + // Calculate zoom change based on pinch distance change + const distanceChange = distance / touchState.lastDistance; + const newZoom = Math.max(0.05, Math.min(3, zoom * distanceChange)); + + // Get canvas bounds for center calculation + const canvas = e.currentTarget as HTMLElement; + const rect = canvas.getBoundingClientRect(); + const centerX = center.x - rect.left; + const centerY = center.y - rect.top; + + // Calculate the world position of the pinch center + const worldX = (centerX - panX) / zoom; + const worldY = (centerY - panY) / zoom; + + // Calculate new pan to keep the pinch center stationary + const newPanX = centerX - worldX * newZoom; + const newPanY = centerY - worldY * newZoom; + + // Calculate pan change based on center movement + const centerDx = center.x - touchState.lastCenter.x; + const centerDy = center.y - touchState.lastCenter.y; + + setZoom(newZoom); + setPanX(newPanX + centerDx); + setPanY(newPanY + centerDy); + + setTouchState({ + touches, + lastDistance: distance, + lastCenter: center, + isGesturing: true, + }); + } else if (touches.length === 1 && !touchState.isGesturing && isPanning) { + // Single finger pan (only if not in gesture mode) + const touch = touches[0]!; + const newPanX = touch.x - panStart.x; + const newPanY = touch.y - panStart.y; + setPanX(newPanX); + setPanY(newPanY); + } + }, + [touchState, zoom, panX, panY, isPanning, panStart], + ); + + const handleTouchEnd = useCallback((e: React.TouchEvent) => { + const touches = Array.from(e.touches).map((touch) => ({ + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + })); + + if (touches.length < 2) { + setTouchState((prev) => ({ ...prev, touches, isGesturing: false })); + } else { + setTouchState((prev) => ({ ...prev, touches })); + } + + if (touches.length === 0) { + setIsPanning(false); + } + }, []); + + // Center viewport on a specific world position (with animation) + const centerViewportOn = useCallback( + ( + worldX: number, + worldY: number, + viewportWidth: number, + viewportHeight: number, + animate = true, + ) => { + const newPanX = viewportWidth / 2 - worldX * zoom; + const newPanY = viewportHeight / 2 - worldY * zoom; + + if (animate && !isAnimating) { + animateToViewState(newPanX, newPanY, zoom, 400); + } else { + setPanX(newPanX); + setPanY(newPanY); + } + }, + [zoom, isAnimating, animateToViewState], + ); + + // Node interaction handlers + const handleNodeHover = useCallback((nodeId: string | null) => { + setHoveredNode(nodeId); + }, []); + + const handleNodeClick = useCallback( + (nodeId: string) => { + setSelectedNode(selectedNode === nodeId ? null : nodeId); + }, + [selectedNode], + ); + + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + // Calculate new zoom (zoom in by 1.5x) + const zoomFactor = 1.5; + const newZoom = Math.min(3, zoom * zoomFactor); + + // Get mouse position relative to the container + let mouseX = e.clientX; + let mouseY = e.clientY; + + // Try to get the container bounds to make coordinates relative to the graph container + const target = e.currentTarget; + if (target && "getBoundingClientRect" in target) { + const rect = target.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + mouseY = e.clientY - rect.top; + } + + // Calculate the world position of the clicked point + const worldX = (mouseX - panX) / zoom; + const worldY = (mouseY - panY) / zoom; + + // Calculate new pan to keep the clicked point in the same screen position + const newPanX = mouseX - worldX * newZoom; + const newPanY = mouseY - worldY * newZoom; + + setZoom(newZoom); + setPanX(newPanX); + setPanY(newPanY); + }, + [zoom, panX, panY], + ); + + return { + // State + panX, + panY, + zoom, + hoveredNode, + selectedNode, + draggingNodeId, + nodePositions, + // Handlers + handlePanStart, + handlePanMove, + handlePanEnd, + handleWheel, + handleNodeHover, + handleNodeClick, + handleNodeDragStart, + handleNodeDragMove, + handleNodeDragEnd, + handleDoubleClick, + // Touch handlers + handleTouchStart, + handleTouchMove, + handleTouchEnd, + // Controls + zoomIn, + zoomOut, + resetView, + autoFitToViewport, + centerViewportOn, + setSelectedNode, + }; +} diff --git a/packages/ui/memory-graph/index.ts b/packages/ui/memory-graph/index.ts new file mode 100644 index 00000000..ddc3bec1 --- /dev/null +++ b/packages/ui/memory-graph/index.ts @@ -0,0 +1,19 @@ +// Components + +// Types and constants +export { + colors, + GRAPH_SETTINGS, + LAYOUT_CONSTANTS, + POSITIONING, +} from "./constants"; +export { GraphWebGLCanvas as GraphCanvas } from "./graph-webgl-canvas"; +// Hooks +export { useGraphData } from "./hooks/use-graph-data"; +export { useGraphInteractions } from "./hooks/use-graph-interactions"; +export { Legend } from "./legend"; +export { LoadingIndicator } from "./loading-indicator"; +export { MemoryGraph } from "./memory-graph"; +export { NodeDetailPanel } from "./node-detail-panel"; +export { SpacesDropdown } from "./spaces-dropdown"; +export * from "./types"; diff --git a/packages/ui/memory-graph/legend.tsx b/packages/ui/memory-graph/legend.tsx new file mode 100644 index 00000000..db2495cc --- /dev/null +++ b/packages/ui/memory-graph/legend.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { useIsMobile } from "@hooks/use-mobile"; +import { cn } from "@repo/lib/utils"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@repo/ui/components/collapsible"; +import { GlassMenuEffect } from "@repo/ui/other/glass-effect"; +import { Brain, ChevronDown, ChevronUp, FileText } from "lucide-react"; +import { memo, useEffect, useState } from "react"; +import { colors } from "./constants"; +import type { GraphEdge, GraphNode, LegendProps } from "./types"; + +// Cookie utility functions for legend state +const setCookie = (name: string, value: string, days = 365) => { + if (typeof document === "undefined") return; + const expires = new Date(); + expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000); + document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`; +}; + +const getCookie = (name: string): string | null => { + if (typeof document === "undefined") return null; + const nameEQ = `${name}=`; + const ca = document.cookie.split(";"); + for (let i = 0; i < ca.length; i++) { + let c = ca[i]; + if (!c) continue; + while (c.charAt(0) === " ") c = c.substring(1, c.length); + if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length); + } + return null; +}; + +interface ExtendedLegendProps extends LegendProps { + id?: string; + nodes?: GraphNode[]; + edges?: GraphEdge[]; + isLoading?: boolean; +} + +export const Legend = memo(function Legend({ + variant = "console", + id, + nodes = [], + edges = [], + isLoading = false, +}: ExtendedLegendProps) { + const isMobile = useIsMobile(); + const [isExpanded, setIsExpanded] = useState(true); + const [isInitialized, setIsInitialized] = useState(false); + + // Load saved preference on client side + useEffect(() => { + if (!isInitialized) { + const savedState = getCookie("legendCollapsed"); + if (savedState === "true") { + setIsExpanded(false); + } else if (savedState === "false") { + setIsExpanded(true); + } else { + // Default: collapsed on mobile, expanded on desktop + setIsExpanded(!isMobile); + } + setIsInitialized(true); + } + }, [isInitialized, isMobile]); + + // Save to cookie when state changes + const handleToggleExpanded = (expanded: boolean) => { + setIsExpanded(expanded); + setCookie("legendCollapsed", expanded ? "false" : "true"); + }; + + // Use explicit classes that Tailwind can detect + const getPositioningClasses = () => { + if (variant === "console") { + // Both desktop and mobile use same positioning for console + return "bottom-4 right-4"; + } + if (variant === "consumer") { + return isMobile ? "bottom-48 left-4" : "top-18 right-4"; + } + return ""; + }; + + const getMobileSize = () => { + if (!isMobile) return ""; + return isExpanded ? "max-w-xs" : "w-16 h-12"; + }; + + const hexagonClipPath = + "polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)"; + + // Calculate stats + const memoryCount = nodes.filter((n) => n.type === "memory").length; + const documentCount = nodes.filter((n) => n.type === "document").length; + + return ( + <div + className={cn( + "absolute z-10 rounded-xl overflow-hidden w-fit h-fit", + getPositioningClasses(), + getMobileSize(), + isMobile && "hidden md:block", + )} + id={id} + > + <Collapsible onOpenChange={handleToggleExpanded} open={isExpanded}> + {/* Glass effect background */} + <GlassMenuEffect rounded="rounded-xl" /> + + <div className="relative z-10"> + {/* Mobile and Desktop collapsed state */} + {!isExpanded && ( + <CollapsibleTrigger className="w-full h-full p-2 flex items-center justify-center hover:bg-white/5 transition-colors"> + <div className="flex flex-col items-center gap-1"> + <div className="text-xs text-slate-300 font-medium">?</div> + <ChevronUp className="w-3 h-3 text-slate-400" /> + </div> + </CollapsibleTrigger> + )} + + {/* Expanded state */} + {isExpanded && ( + <> + {/* Header with toggle */} + <div className="flex items-center justify-between px-4 py-3 border-b border-slate-600/50"> + <div className="text-sm font-medium text-slate-100">Legend</div> + <CollapsibleTrigger className="p-1 hover:bg-white/10 rounded"> + <ChevronDown className="w-4 h-4 text-slate-400" /> + </CollapsibleTrigger> + </div> + + <CollapsibleContent> + <div className="text-xs text-slate-200 px-4 py-3 space-y-3"> + {/* Stats Section */} + {!isLoading && ( + <div className="space-y-2"> + <div className="text-xs font-medium text-slate-300 uppercase tracking-wide"> + Statistics + </div> + <div className="space-y-1.5"> + <div className="flex items-center gap-2"> + <Brain className="w-3 h-3 text-blue-400" /> + <span className="text-xs"> + {memoryCount} memories + </span> + </div> + <div className="flex items-center gap-2"> + <FileText className="w-3 h-3 text-slate-300" /> + <span className="text-xs"> + {documentCount} documents + </span> + </div> + <div className="flex items-center gap-2"> + <div className="w-3 h-3 bg-gradient-to-r from-slate-400 to-blue-400 rounded-full" /> + <span className="text-xs"> + {edges.length} connections + </span> + </div> + </div> + </div> + )} + + {/* Node Types */} + <div className="space-y-2"> + <div className="text-xs font-medium text-slate-300 uppercase tracking-wide"> + Nodes + </div> + <div className="space-y-1.5"> + <div className="flex items-center gap-2"> + <div className="w-4 h-3 bg-white/8 border border-white/25 rounded-sm flex-shrink-0" /> + <span className="text-xs">Document</span> + </div> + <div className="flex items-center gap-2"> + <div + className="w-3 h-3 bg-blue-400/10 border border-blue-400/35 flex-shrink-0" + style={{ + clipPath: hexagonClipPath, + }} + /> + <span className="text-xs">Memory (latest)</span> + </div> + <div className="flex items-center gap-2"> + <div + className="w-3 h-3 bg-blue-400/10 border border-blue-400/35 opacity-40 flex-shrink-0" + style={{ + clipPath: hexagonClipPath, + }} + /> + <span className="text-xs">Memory (older)</span> + </div> + </div> + </div> + + {/* Status Indicators */} + <div className="space-y-2"> + <div className="text-xs font-medium text-slate-300 uppercase tracking-wide"> + Status + </div> + <div className="space-y-1.5"> + <div className="flex items-center gap-2"> + <div + className="w-3 h-3 bg-red-500/30 border border-red-500/80 relative flex-shrink-0" + style={{ + clipPath: hexagonClipPath, + }} + > + <div className="absolute inset-0 flex items-center justify-center text-red-400 text-xs leading-none"> + ✕ + </div> + </div> + <span className="text-xs">Forgotten</span> + </div> + <div className="flex items-center gap-2"> + <div + className="w-3 h-3 bg-blue-400/10 border-2 border-amber-500 flex-shrink-0" + style={{ + clipPath: hexagonClipPath, + }} + /> + <span className="text-xs">Expiring soon</span> + </div> + <div className="flex items-center gap-2"> + <div + className="w-3 h-3 bg-blue-400/10 border-2 border-emerald-500 relative flex-shrink-0" + style={{ + clipPath: hexagonClipPath, + }} + > + <div className="absolute -top-1 -right-1 w-2 h-2 bg-emerald-500 rounded-full" /> + </div> + <span className="text-xs">New memory</span> + </div> + </div> + </div> + + {/* Connection Types */} + <div className="space-y-2"> + <div className="text-xs font-medium text-slate-300 uppercase tracking-wide"> + Connections + </div> + <div className="space-y-1.5"> + <div className="flex items-center gap-2"> + <div className="w-4 h-0 border-t border-slate-400 flex-shrink-0" /> + <span className="text-xs">Doc → Memory</span> + </div> + <div className="flex items-center gap-2"> + <div className="w-4 h-0 border-t-2 border-dashed border-slate-400 flex-shrink-0" /> + <span className="text-xs">Doc similarity</span> + </div> + </div> + </div> + + {/* Relation Types */} + <div className="space-y-2"> + <div className="text-xs font-medium text-slate-300 uppercase tracking-wide"> + Relations + </div> + <div className="space-y-1.5"> + {[ + ["updates", colors.relations.updates], + ["extends", colors.relations.extends], + ["derives", colors.relations.derives], + ].map(([label, color]) => ( + <div className="flex items-center gap-2" key={label}> + <div + className="w-4 h-0 border-t-2 flex-shrink-0" + style={{ borderColor: color }} + /> + <span + className="text-xs capitalize" + style={{ color: color }} + > + {label} + </span> + </div> + ))} + </div> + </div> + + {/* Similarity Strength */} + <div className="space-y-2"> + <div className="text-xs font-medium text-slate-300 uppercase tracking-wide"> + Similarity + </div> + <div className="space-y-1.5"> + <div className="flex items-center gap-2"> + <div className="w-3 h-3 rounded-full bg-slate-400/20 flex-shrink-0" /> + <span className="text-xs">Weak</span> + </div> + <div className="flex items-center gap-2"> + <div className="w-3 h-3 rounded-full bg-slate-400/60 flex-shrink-0" /> + <span className="text-xs">Strong</span> + </div> + </div> + </div> + </div> + </CollapsibleContent> + </> + )} + </div> + </Collapsible> + </div> + ); +}); + +Legend.displayName = "Legend"; diff --git a/packages/ui/memory-graph/loading-indicator.tsx b/packages/ui/memory-graph/loading-indicator.tsx new file mode 100644 index 00000000..f4a1930a --- /dev/null +++ b/packages/ui/memory-graph/loading-indicator.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { cn } from "@repo/lib/utils"; +import { GlassMenuEffect } from "@repo/ui/other/glass-effect"; +import { Sparkles } from "lucide-react"; +import { memo } from "react"; +import type { LoadingIndicatorProps } from "./types"; + +export const LoadingIndicator = memo<LoadingIndicatorProps>( + ({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => { + // Use explicit classes that Tailwind can detect + const getPositioningClasses = () => { + // Both variants use the same positioning for loadingIndicator + return "top-20 left-4"; + }; + + if (!isLoading && !isLoadingMore) return null; + + return ( + <div + className={cn( + "absolute z-10 rounded-xl overflow-hidden", + getPositioningClasses(), + )} + > + {/* Glass effect background */} + <GlassMenuEffect rounded="rounded-xl" /> + + <div className="relative z-10 text-slate-200 px-4 py-3"> + <div className="flex items-center gap-2"> + <Sparkles className="w-4 h-4 animate-spin text-blue-400" /> + <span className="text-sm"> + {isLoading + ? "Loading memory graph..." + : `Loading more documents... (${totalLoaded})`} + </span> + </div> + </div> + </div> + ); + }, +); + +LoadingIndicator.displayName = "LoadingIndicator"; diff --git a/packages/ui/memory-graph/memory-graph.tsx b/packages/ui/memory-graph/memory-graph.tsx new file mode 100644 index 00000000..8c1ad3c2 --- /dev/null +++ b/packages/ui/memory-graph/memory-graph.tsx @@ -0,0 +1,458 @@ +"use client"; + +import { GlassMenuEffect } from "@repo/ui/other/glass-effect"; +import { AnimatePresence } from "motion/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { colors } from "./constants"; +import { GraphWebGLCanvas as GraphCanvas } from "./graph-webgl-canvas"; +import { useGraphData } from "./hooks/use-graph-data"; +import { useGraphInteractions } from "./hooks/use-graph-interactions"; +import { Legend } from "./legend"; +import { LoadingIndicator } from "./loading-indicator"; +import { NavigationControls } from "./navigation-controls"; +import { NodeDetailPanel } from "./node-detail-panel"; +import { SpacesDropdown } from "./spaces-dropdown"; + +import type { MemoryGraphProps } from "./types"; + +export const MemoryGraph = ({ + children, + documents, + isLoading, + isLoadingMore, + error, + totalLoaded, + hasMore, + loadMoreDocuments, + showSpacesSelector, + variant = "console", + legendId, + highlightDocumentIds = [], + highlightsVisible = true, + occludedRightPx = 0, + autoLoadOnViewport = true, +}: MemoryGraphProps) => { + // Derive showSpacesSelector from variant if not explicitly provided + // console variant shows spaces selector, consumer variant hides it + const finalShowSpacesSelector = showSpacesSelector ?? (variant === "console"); + + const [selectedSpace, setSelectedSpace] = useState<string>("all"); + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + const containerRef = useRef<HTMLDivElement>(null); + + // Create data object with dummy pagination to satisfy type requirements + const data = useMemo(() => { + return documents && documents.length > 0 + ? { + documents, + pagination: { + currentPage: 1, + limit: documents.length, + totalItems: documents.length, + totalPages: 1, + }, + } + : null; + }, [documents]); + + // Graph interactions with variant-specific settings + const { + panX, + panY, + zoom, + /** hoveredNode currently unused within this component */ + hoveredNode: _hoveredNode, + selectedNode, + draggingNodeId, + nodePositions, + handlePanStart, + handlePanMove, + handlePanEnd, + handleWheel, + handleNodeHover, + handleNodeClick, + handleNodeDragStart, + handleNodeDragMove, + handleNodeDragEnd, + handleDoubleClick, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + setSelectedNode, + autoFitToViewport, + centerViewportOn, + zoomIn, + zoomOut, + } = useGraphInteractions(variant); + + // Graph data + const { nodes, edges } = useGraphData( + data, + selectedSpace, + nodePositions, + draggingNodeId, + ); + + // Auto-fit once per unique highlight set to show the full graph for context + const lastFittedHighlightKeyRef = useRef<string>(""); + useEffect(() => { + const highlightKey = highlightsVisible + ? highlightDocumentIds.join("|") + : ""; + if ( + highlightKey && + highlightKey !== lastFittedHighlightKeyRef.current && + containerSize.width > 0 && + containerSize.height > 0 && + nodes.length > 0 + ) { + autoFitToViewport(nodes, containerSize.width, containerSize.height, { + occludedRightPx, + animate: true, + }); + lastFittedHighlightKeyRef.current = highlightKey; + } + }, [ + highlightsVisible, + highlightDocumentIds, + containerSize.width, + containerSize.height, + nodes.length, + occludedRightPx, + autoFitToViewport, + ]); + + // Auto-fit graph when component mounts or nodes change significantly + const hasAutoFittedRef = useRef(false); + useEffect(() => { + // Only auto-fit once when we have nodes and container size + if ( + !hasAutoFittedRef.current && + nodes.length > 0 && + containerSize.width > 0 && + containerSize.height > 0 + ) { + // Auto-fit to show all content for both variants + // Add a small delay to ensure the canvas is fully initialized + const timer = setTimeout(() => { + autoFitToViewport(nodes, containerSize.width, containerSize.height); + hasAutoFittedRef.current = true; + }, 100); + + return () => clearTimeout(timer); + } + }, [ + nodes, + containerSize.width, + containerSize.height, + autoFitToViewport, + ]); + + // Reset auto-fit flag when nodes array becomes empty (switching views) + useEffect(() => { + if (nodes.length === 0) { + hasAutoFittedRef.current = false; + } + }, [nodes.length]); + + // Extract unique spaces from memories and calculate counts + const { availableSpaces, spaceMemoryCounts } = useMemo(() => { + if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} }; + + const spaceSet = new Set<string>(); + const counts: Record<string, number> = {}; + + data.documents.forEach((doc) => { + doc.memoryEntries.forEach((memory) => { + const spaceId = memory.spaceContainerTag || memory.spaceId || "default"; + spaceSet.add(spaceId); + counts[spaceId] = (counts[spaceId] || 0) + 1; + }); + }); + + return { + availableSpaces: Array.from(spaceSet).sort(), + spaceMemoryCounts: counts, + }; + }, [data]); + + // Handle container resize + useEffect(() => { + const updateSize = () => { + if (containerRef.current) { + const newWidth = containerRef.current.clientWidth; + const newHeight = containerRef.current.clientHeight; + + // Only update if size actually changed and is valid + setContainerSize((prev) => { + if (prev.width !== newWidth || prev.height !== newHeight) { + return { width: newWidth, height: newHeight }; + } + return prev; + }); + } + }; + + // Use a slight delay to ensure DOM is fully rendered + const timer = setTimeout(updateSize, 0); + updateSize(); // Also call immediately + + window.addEventListener("resize", updateSize); + + // Use ResizeObserver for more accurate container size detection + const resizeObserver = new ResizeObserver(updateSize); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => { + clearTimeout(timer); + window.removeEventListener("resize", updateSize); + resizeObserver.disconnect(); + }; + }, []); + + // Enhanced node drag start that includes nodes data + const handleNodeDragStartWithNodes = useCallback( + (nodeId: string, e: React.MouseEvent) => { + handleNodeDragStart(nodeId, e, nodes); + }, + [handleNodeDragStart, nodes], + ); + + // Navigation callbacks + const handleCenter = useCallback(() => { + if (nodes.length > 0) { + // Calculate center of all nodes + let sumX = 0 + let sumY = 0 + let count = 0 + + nodes.forEach((node) => { + sumX += node.x + sumY += node.y + count++ + }) + + if (count > 0) { + const centerX = sumX / count + const centerY = sumY / count + centerViewportOn(centerX, centerY, containerSize.width, containerSize.height) + } + } + }, [nodes, centerViewportOn, containerSize.width, containerSize.height]) + + const handleAutoFit = useCallback(() => { + if (nodes.length > 0 && containerSize.width > 0 && containerSize.height > 0) { + autoFitToViewport(nodes, containerSize.width, containerSize.height, { + occludedRightPx, + animate: true, + }) + } + }, [nodes, containerSize.width, containerSize.height, occludedRightPx, autoFitToViewport]) + + // Get selected node data + const selectedNodeData = useMemo(() => { + if (!selectedNode) return null; + return nodes.find((n) => n.id === selectedNode) || null; + }, [selectedNode, nodes]); + + // Viewport-based loading: load more when most documents are visible (optional) + const checkAndLoadMore = useCallback(() => { + if ( + isLoadingMore || + !hasMore || + !data?.documents || + data.documents.length === 0 + ) + return; + + // Calculate viewport bounds + const viewportBounds = { + left: -panX / zoom - 200, + right: (-panX + containerSize.width) / zoom + 200, + top: -panY / zoom - 200, + bottom: (-panY + containerSize.height) / zoom + 200, + }; + + // Count visible documents + const visibleDocuments = data.documents.filter((doc) => { + const docNodes = nodes.filter( + (node) => node.type === "document" && node.data.id === doc.id, + ); + return docNodes.some( + (node) => + node.x >= viewportBounds.left && + node.x <= viewportBounds.right && + node.y >= viewportBounds.top && + node.y <= viewportBounds.bottom, + ); + }); + + // If 80% or more of documents are visible, load more + const visibilityRatio = visibleDocuments.length / data.documents.length; + if (visibilityRatio >= 0.8) { + loadMoreDocuments(); + } + }, [ + isLoadingMore, + hasMore, + data, + panX, + panY, + zoom, + containerSize.width, + containerSize.height, + nodes, + loadMoreDocuments, + ]); + + // Throttled version to avoid excessive checks + const lastLoadCheckRef = useRef(0); + const throttledCheckAndLoadMore = useCallback(() => { + const now = Date.now(); + if (now - lastLoadCheckRef.current > 1000) { + // Check at most once per second + lastLoadCheckRef.current = now; + checkAndLoadMore(); + } + }, [checkAndLoadMore]); + + // Monitor viewport changes to trigger loading + useEffect(() => { + if (!autoLoadOnViewport) return; + throttledCheckAndLoadMore(); + }, [throttledCheckAndLoadMore, autoLoadOnViewport]); + + // Initial load trigger when graph is first rendered + useEffect(() => { + if (!autoLoadOnViewport) return; + if (data?.documents && data.documents.length > 0 && hasMore) { + // Start loading more documents after initial render + setTimeout(() => { + throttledCheckAndLoadMore(); + }, 500); // Small delay to allow initial layout + } + }, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport]); + + if (error) { + return ( + <div + className="h-full flex items-center justify-center" + style={{ backgroundColor: colors.background.primary }} + > + <div className="rounded-xl overflow-hidden"> + {/* Glass effect background */} + <GlassMenuEffect rounded="rounded-xl" /> + + <div className="relative z-10 text-slate-200 px-6 py-4"> + Error loading documents: {error.message} + </div> + </div> + </div> + ); + } + + return ( + <div + className="h-full rounded-xl overflow-hidden" + style={{ backgroundColor: colors.background.primary }} + > + {/* Spaces selector - only shown for console */} + {finalShowSpacesSelector && availableSpaces.length > 0 && ( + <div className="absolute top-4 left-4 z-10"> + <SpacesDropdown + availableSpaces={availableSpaces} + onSpaceChange={setSelectedSpace} + selectedSpace={selectedSpace} + spaceMemoryCounts={spaceMemoryCounts} + /> + </div> + )} + + {/* Loading indicator */} + <LoadingIndicator + isLoading={isLoading} + isLoadingMore={isLoadingMore} + totalLoaded={totalLoaded} + variant={variant} + /> + + {/* Legend */} + <Legend + edges={edges} + id={legendId} + isLoading={isLoading} + nodes={nodes} + variant={variant} + /> + + {/* Node detail panel */} + <AnimatePresence> + {selectedNodeData && ( + <NodeDetailPanel + node={selectedNodeData} + onClose={() => setSelectedNode(null)} + variant={variant} + /> + )} + </AnimatePresence> + + {/* Show welcome screen when no memories exist */} + {!isLoading && + (!data || nodes.filter((n) => n.type === "document").length === 0) && ( + <>{children}</> + )} + + {/* Graph container */} + <div + className="w-full h-full relative overflow-hidden" + ref={containerRef} + style={{ + touchAction: "none", + userSelect: "none", + WebkitUserSelect: "none", + }} + > + {(containerSize.width > 0 && containerSize.height > 0) && ( + <GraphCanvas + draggingNodeId={draggingNodeId} + edges={edges} + height={containerSize.height} + nodes={nodes} + highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []} + onDoubleClick={handleDoubleClick} + onNodeClick={handleNodeClick} + onNodeDragEnd={handleNodeDragEnd} + onNodeDragMove={handleNodeDragMove} + onNodeDragStart={handleNodeDragStartWithNodes} + onNodeHover={handleNodeHover} + onPanEnd={handlePanEnd} + onPanMove={handlePanMove} + onPanStart={handlePanStart} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + onWheel={handleWheel} + panX={panX} + panY={panY} + width={containerSize.width} + zoom={zoom} + /> + )} + + {/* Navigation controls */} + {containerSize.width > 0 && ( + <NavigationControls + onCenter={handleCenter} + onZoomIn={() => zoomIn(containerSize.width / 2, containerSize.height / 2)} + onZoomOut={() => zoomOut(containerSize.width / 2, containerSize.height / 2)} + onAutoFit={handleAutoFit} + nodes={nodes} + className="absolute bottom-4 left-4" + /> + )} + </div> + </div> + ); +}; diff --git a/packages/ui/memory-graph/navigation-controls.tsx b/packages/ui/memory-graph/navigation-controls.tsx new file mode 100644 index 00000000..b2abd67f --- /dev/null +++ b/packages/ui/memory-graph/navigation-controls.tsx @@ -0,0 +1,67 @@ +"use client" + +import { memo } from "react" +import type { GraphNode } from "./types" + +interface NavigationControlsProps { + onCenter: () => void + onZoomIn: () => void + onZoomOut: () => void + onAutoFit: () => void + nodes: GraphNode[] + className?: string +} + +export const NavigationControls = memo<NavigationControlsProps>(({ + onCenter, + onZoomIn, + onZoomOut, + onAutoFit, + nodes, + className = "", +}) => { + if (nodes.length === 0) { + return null + } + + return ( + <div className={`flex flex-col gap-1 ${className}`}> + <button + type="button" + onClick={onAutoFit} + className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16" + title="Auto-fit graph to viewport" + > + Fit + </button> + <button + type="button" + onClick={onCenter} + className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16" + title="Center view on graph" + > + Center + </button> + <div className="flex flex-col"> + <button + type="button" + onClick={onZoomIn} + className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-t-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16 border-b-0" + title="Zoom in" + > + + + </button> + <button + type="button" + onClick={onZoomOut} + className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-b-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16" + title="Zoom out" + > + − + </button> + </div> + </div> + ) +}) + +NavigationControls.displayName = "NavigationControls"
\ No newline at end of file diff --git a/packages/ui/memory-graph/node-detail-panel.tsx b/packages/ui/memory-graph/node-detail-panel.tsx new file mode 100644 index 00000000..0fdc4801 --- /dev/null +++ b/packages/ui/memory-graph/node-detail-panel.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { cn } from "@repo/lib/utils"; +import { Badge } from "@repo/ui/components/badge"; +import { Button } from "@repo/ui/components/button"; +import { GlassMenuEffect } from "@repo/ui/other/glass-effect"; +import { Brain, Calendar, ExternalLink, FileText, Hash, X } from "lucide-react"; +import { motion } from "motion/react"; +import { memo } from "react"; +import { + GoogleDocs, + GoogleDrive, + GoogleSheets, + GoogleSlides, + MicrosoftExcel, + MicrosoftOneNote, + MicrosoftPowerpoint, + MicrosoftWord, + NotionDoc, + OneDrive, + PDF, +} from "../assets/icons"; +import { HeadingH3Bold } from "../text/heading/heading-h3-bold"; +import type { + DocumentWithMemories, + MemoryEntry, + NodeDetailPanelProps, +} from "./types"; + +const formatDocumentType = (type: string) => { + // Special case for PDF + if (type.toLowerCase() === "pdf") return "PDF"; + + // Replace underscores with spaces and capitalize each word + return type + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +}; + +const getDocumentIcon = (type: string) => { + const iconProps = { className: "w-5 h-5 text-slate-300" }; + + switch (type) { + case "google_doc": + return <GoogleDocs {...iconProps} />; + case "google_sheet": + return <GoogleSheets {...iconProps} />; + case "google_slide": + return <GoogleSlides {...iconProps} />; + case "google_drive": + return <GoogleDrive {...iconProps} />; + case "notion": + case "notion_doc": + return <NotionDoc {...iconProps} />; + case "word": + case "microsoft_word": + return <MicrosoftWord {...iconProps} />; + case "excel": + case "microsoft_excel": + return <MicrosoftExcel {...iconProps} />; + case "powerpoint": + case "microsoft_powerpoint": + return <MicrosoftPowerpoint {...iconProps} />; + case "onenote": + case "microsoft_onenote": + return <MicrosoftOneNote {...iconProps} />; + case "onedrive": + return <OneDrive {...iconProps} />; + case "pdf": + return <PDF {...iconProps} />; + default: + return <FileText {...iconProps} />; + } +}; + +export const NodeDetailPanel = memo<NodeDetailPanelProps>( + ({ node, onClose, variant = "console" }) => { + // Use explicit classes that Tailwind can detect + const getPositioningClasses = () => { + // Both variants use the same positioning for nodeDetail + return "top-4 right-4"; + }; + + if (!node) return null; + + const isDocument = node.type === "document"; + const data = node.data; + + return ( + <motion.div + animate={{ opacity: 1 }} + className={cn( + "absolute w-80 rounded-xl overflow-hidden z-20 max-h-[80vh]", + getPositioningClasses(), + )} + exit={{ opacity: 0 }} + initial={{ opacity: 0 }} + transition={{ + duration: 0.2, + ease: "easeInOut", + }} + > + {/* Glass effect background */} + <GlassMenuEffect rounded="rounded-xl" /> + + <motion.div + animate={{ opacity: 1 }} + className="relative z-10 p-4 overflow-y-auto max-h-[80vh]" + initial={{ opacity: 0 }} + transition={{ delay: 0.05, duration: 0.15 }} + > + <div className="flex items-center justify-between mb-3"> + <div className="flex items-center gap-2"> + {isDocument ? ( + getDocumentIcon((data as DocumentWithMemories).type) + ) : ( + <Brain className="w-5 h-5 text-blue-400" /> + )} + <HeadingH3Bold className="text-slate-100"> + {isDocument ? "Document" : "Memory"} + </HeadingH3Bold> + </div> + <motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}> + <Button + className="h-8 w-8 p-0 text-slate-300 hover:text-slate-100" + onClick={onClose} + size="sm" + variant="ghost" + > + <X className="w-4 h-4" /> + </Button> + </motion.div> + </div> + + <div className="space-y-3"> + {isDocument ? ( + <> + <div> + <span className="text-xs text-slate-400 uppercase tracking-wide"> + Title + </span> + <p className="text-sm text-slate-200 mt-1"> + {(data as DocumentWithMemories).title || + "Untitled Document"} + </p> + </div> + + {(data as DocumentWithMemories).summary && ( + <div> + <span className="text-xs text-slate-400 uppercase tracking-wide"> + Summary + </span> + <p className="text-sm text-slate-300 mt-1 line-clamp-3"> + {(data as DocumentWithMemories).summary} + </p> + </div> + )} + + <div> + <span className="text-xs text-slate-400 uppercase tracking-wide"> + Type + </span> + <p className="text-sm text-slate-200 mt-1"> + {formatDocumentType((data as DocumentWithMemories).type)} + </p> + </div> + + <div> + <span className="text-xs text-slate-400 uppercase tracking-wide"> + Memory Count + </span> + <p className="text-sm text-slate-200 mt-1"> + {(data as DocumentWithMemories).memoryEntries.length}{" "} + memories + </p> + </div> + + {((data as DocumentWithMemories).url || + (data as DocumentWithMemories).customId) && ( + <div> + <span className="text-xs text-slate-400 uppercase tracking-wide"> + URL + </span> + <a + className="text-sm text-indigo-400 hover:text-indigo-300 mt-1 flex items-center gap-1" + href={(() => { + const doc = data as DocumentWithMemories; + if (doc.type === "google_doc" && doc.customId) { + return `https://docs.google.com/document/d/${doc.customId}`; + } + if (doc.type === "google_sheet" && doc.customId) { + return `https://docs.google.com/spreadsheets/d/${doc.customId}`; + } + if (doc.type === "google_slide" && doc.customId) { + return `https://docs.google.com/presentation/d/${doc.customId}`; + } + return doc.url ?? undefined; + })()} + rel="noopener noreferrer" + target="_blank" + > + <ExternalLink className="w-3 h-3" /> + View Document + </a> + </div> + )} + </> + ) : ( + <> + <div> + <span className="text-xs text-slate-400 uppercase tracking-wide"> + Memory + </span> + <p className="text-sm text-slate-200 mt-1"> + {(data as MemoryEntry).memory} + </p> + {(data as MemoryEntry).isForgotten && ( + <Badge className="mt-2" variant="destructive"> + Forgotten + </Badge> + )} + {(data as MemoryEntry).forgetAfter && ( + <p className="text-xs text-slate-400 mt-1"> + Expires:{" "} + {(data as MemoryEntry).forgetAfter + ? new Date( + (data as MemoryEntry).forgetAfter!, + ).toLocaleDateString() + : ""}{" "} + {"forgetReason" in data && + data.forgetReason && + `- ${data.forgetReason}`} + </p> + )} + </div> + + <div> + <span className="text-xs text-slate-400 uppercase tracking-wide"> + Space + </span> + <p className="text-sm text-slate-200 mt-1"> + {(data as MemoryEntry).spaceId || "Default"} + </p> + </div> + </> + )} + + <div className="pt-2 border-t border-slate-700/50"> + <div className="flex items-center gap-4 text-xs text-slate-400"> + <span className="flex items-center gap-1"> + <Calendar className="w-3 h-3" /> + {new Date(data.createdAt).toLocaleDateString()} + </span> + <span className="flex items-center gap-1"> + <Hash className="w-3 h-3" /> + {node.id} + </span> + </div> + </div> + </div> + </motion.div> + </motion.div> + ); + }, +); + +NodeDetailPanel.displayName = "NodeDetailPanel"; diff --git a/packages/ui/memory-graph/spaces-dropdown.tsx b/packages/ui/memory-graph/spaces-dropdown.tsx new file mode 100644 index 00000000..72d5f261 --- /dev/null +++ b/packages/ui/memory-graph/spaces-dropdown.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { cn } from "@repo/lib/utils"; +import { Badge } from "@repo/ui/components/badge"; +import { ChevronDown, Eye } from "lucide-react"; +import { memo, useEffect, useRef, useState } from "react"; +import type { SpacesDropdownProps } from "./types"; + +export const SpacesDropdown = memo<SpacesDropdownProps>( + ({ selectedSpace, availableSpaces, spaceMemoryCounts, onSpaceChange }) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef<HTMLDivElement>(null); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const totalMemories = Object.values(spaceMemoryCounts).reduce( + (sum, count) => sum + count, + 0, + ); + + return ( + <div className="relative" ref={dropdownRef}> + <button + className={cn( + "flex items-center gap-3 px-4 py-3 rounded-xl border-2 border-solid border-transparent", + "[background-image:linear-gradient(#1a1f29,_#1a1f29),_linear-gradient(150.262deg,_#A4E8F5_0%,_#267FFA_26%,_#464646_49%,_#747474_70%,_#A4E8F5_100%)]", + "[background-origin:border-box] [background-clip:padding-box,_border-box]", + "shadow-[inset_0px_2px_1px_rgba(84,84,84,0.15)] backdrop-blur-md", + "transition-all duration-200 hover:shadow-[inset_0px_2px_1px_rgba(84,84,84,0.25)]", + "cursor-pointer min-w-60", + )} + onClick={() => setIsOpen(!isOpen)} + type="button" + > + <Eye className="w-4 h-4 text-slate-300" /> + <div className="flex-1 text-left"> + <span className="text-sm text-slate-200 font-medium"> + {selectedSpace === "all" + ? "All Spaces" + : selectedSpace || "Select space"} + </span> + <div className="text-xs text-slate-400"> + {selectedSpace === "all" + ? `${totalMemories} total memories` + : `${spaceMemoryCounts[selectedSpace] || 0} memories`} + </div> + </div> + <ChevronDown + className={cn( + "w-4 h-4 text-slate-300 transition-transform duration-200", + isOpen && "rotate-180", + )} + /> + </button> + + {isOpen && ( + <div className="absolute top-full left-0 right-0 mt-2 bg-slate-900/95 backdrop-blur-md border border-slate-700/40 rounded-xl shadow-xl z-20 overflow-hidden"> + <div className="p-1"> + <button + className={cn( + "w-full flex items-center justify-between px-3 py-2 rounded-lg text-left transition-colors", + selectedSpace === "all" + ? "bg-blue-500/20 text-blue-300" + : "text-slate-200 hover:bg-slate-700/50", + )} + onClick={() => { + onSpaceChange("all"); + setIsOpen(false); + }} + type="button" + > + <span className="text-sm">All Spaces</span> + <Badge className="bg-slate-700/50 text-slate-300 text-xs"> + {totalMemories} + </Badge> + </button> + {availableSpaces.map((space) => ( + <button + className={cn( + "w-full flex items-center justify-between px-3 py-2 rounded-lg text-left transition-colors", + selectedSpace === space + ? "bg-blue-500/20 text-blue-300" + : "text-slate-200 hover:bg-slate-700/50", + )} + key={space} + onClick={() => { + onSpaceChange(space); + setIsOpen(false); + }} + type="button" + > + <span className="text-sm truncate flex-1">{space}</span> + <Badge className="bg-slate-700/50 text-slate-300 text-xs ml-2"> + {spaceMemoryCounts[space] || 0} + </Badge> + </button> + ))} + </div> + </div> + )} + </div> + ); + }, +); + +SpacesDropdown.displayName = "SpacesDropdown"; diff --git a/packages/ui/memory-graph/types.ts b/packages/ui/memory-graph/types.ts new file mode 100644 index 00000000..a939c619 --- /dev/null +++ b/packages/ui/memory-graph/types.ts @@ -0,0 +1,122 @@ +import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"; +import type { z } from "zod"; + +export type DocumentsResponse = z.infer< + typeof DocumentsWithMemoriesResponseSchema +>; +export type DocumentWithMemories = DocumentsResponse["documents"][0]; +export type MemoryEntry = DocumentWithMemories["memoryEntries"][0]; + +export interface GraphNode { + id: string; + type: "document" | "memory"; + x: number; + y: number; + data: DocumentWithMemories | MemoryEntry; + size: number; + color: string; + isHovered: boolean; + isDragging: boolean; +} + +export type MemoryRelation = "updates" | "extends" | "derives"; + +export interface GraphEdge { + id: string; + source: string; + target: string; + similarity: number; + visualProps: { + opacity: number; + thickness: number; + glow: number; + pulseDuration: number; + }; + color: string; + edgeType: "doc-memory" | "doc-doc" | "version"; + relationType?: MemoryRelation; +} + +export interface SpacesDropdownProps { + selectedSpace: string; + availableSpaces: string[]; + spaceMemoryCounts: Record<string, number>; + onSpaceChange: (space: string) => void; +} + +export interface NodeDetailPanelProps { + node: GraphNode | null; + onClose: () => void; + variant?: "console" | "consumer"; +} + +export interface GraphCanvasProps { + nodes: GraphNode[]; + edges: GraphEdge[]; + panX: number; + panY: number; + zoom: number; + width: number; + height: number; + onNodeHover: (nodeId: string | null) => void; + onNodeClick: (nodeId: string) => void; + onNodeDragStart: (nodeId: string, e: React.MouseEvent) => void; + onNodeDragMove: (e: React.MouseEvent) => void; + onNodeDragEnd: () => void; + onPanStart: (e: React.MouseEvent) => void; + onPanMove: (e: React.MouseEvent) => void; + onPanEnd: () => void; + onWheel: (e: React.WheelEvent) => void; + onDoubleClick: (e: React.MouseEvent) => void; + onTouchStart?: (e: React.TouchEvent) => void; + onTouchMove?: (e: React.TouchEvent) => void; + onTouchEnd?: (e: React.TouchEvent) => void; + draggingNodeId: string | null; + // Optional list of document IDs (customId or internal id) to highlight + highlightDocumentIds?: string[]; +} + +export interface MemoryGraphProps { + children?: React.ReactNode; + documents: DocumentWithMemories[]; + isLoading: boolean; + isLoadingMore: boolean; + error: Error | null; + totalLoaded: number; + hasMore: boolean; + loadMoreDocuments: () => Promise<void>; + // App-specific props + showSpacesSelector?: boolean; // true for console, false for consumer + variant?: "console" | "consumer"; // for different positioning and styling + legendId?: string; // Optional ID for the legend component + // Optional document highlight list (document custom IDs) + highlightDocumentIds?: string[]; + // Whether highlights are currently visible (e.g., chat open) + highlightsVisible?: boolean; + // Pixels occluded on the right side of the viewport (e.g., chat panel) + occludedRightPx?: number; + // Whether to auto-load more documents based on viewport visibility + autoLoadOnViewport?: boolean; +} + +export interface LegendProps { + variant?: "console" | "consumer"; + nodes?: GraphNode[]; + edges?: GraphEdge[]; + isLoading?: boolean; + hoveredNode?: string | null; +} + +export interface LoadingIndicatorProps { + isLoading: boolean; + isLoadingMore: boolean; + totalLoaded: number; + variant?: "console" | "consumer"; +} + +export interface ControlsProps { + onZoomIn: () => void; + onZoomOut: () => void; + onResetView: () => void; + variant?: "console" | "consumer"; +} |