diff options
| author | nexxeln <[email protected]> | 2025-12-04 18:54:40 +0000 |
|---|---|---|
| committer | nexxeln <[email protected]> | 2025-12-04 18:54:40 +0000 |
| commit | 7a2f2cb99c50038e932f898838ab60715c4e47d6 (patch) | |
| tree | 3bc23f5810349ec848a4c8cf71fd0a310efbd4e2 /packages/ui/memory-graph | |
| parent | chore(@supermemory/tools): fix the documentation of withSupermemory (#601) (diff) | |
| download | supermemory-use-memory-graph-package.tar.xz supermemory-use-memory-graph-package.zip | |
use latest graph and remove old graph (#604)use-memory-graph-package
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, 0 insertions, 4000 deletions
diff --git a/packages/ui/memory-graph/constants.ts b/packages/ui/memory-graph/constants.ts deleted file mode 100644 index 23193601..00000000 --- a/packages/ui/memory-graph/constants.ts +++ /dev/null @@ -1,100 +0,0 @@ -// 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 deleted file mode 100644 index 899d239a..00000000 --- a/packages/ui/memory-graph/controls.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"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 deleted file mode 100644 index c4623c85..00000000 --- a/packages/ui/memory-graph/graph-canvas.tsx +++ /dev/null @@ -1,762 +0,0 @@ -"use client"; - -import { - memo, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, -} from "react"; -import { colors } from "./constants"; -import type { - DocumentWithMemories, - GraphCanvasProps, - GraphNode, - MemoryEntry, -} from "./types"; - -export const GraphCanvas = memo<GraphCanvasProps>( - ({ - nodes, - edges, - panX, - panY, - zoom, - width, - height, - onNodeHover, - onNodeClick, - onNodeDragStart, - onNodeDragMove, - onNodeDragEnd, - onPanStart, - onPanMove, - onPanEnd, - onWheel, - onDoubleClick, - onTouchStart, - onTouchMove, - onTouchEnd, - draggingNodeId, - highlightDocumentIds, - }) => { - const canvasRef = useRef<HTMLCanvasElement>(null); - const animationRef = useRef<number>(0); - const startTimeRef = useRef<number>(Date.now()); - const mousePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); - const currentHoveredNode = useRef<string | null>(null); - - // Initialize start time once - useEffect(() => { - startTimeRef.current = Date.now(); - }, []); - - // Efficient hit detection - const getNodeAtPosition = useCallback( - (x: number, y: number): string | null => { - // Check from top-most to bottom-most: memory nodes are drawn after documents - for (let i = nodes.length - 1; i >= 0; i--) { - const node = nodes[i]!; - const screenX = node.x * zoom + panX; - const screenY = node.y * zoom + panY; - const nodeSize = node.size * zoom; - - const dx = x - screenX; - const dy = y - screenY; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance <= nodeSize / 2) { - return node.id; - } - } - return null; - }, - [nodes, panX, panY, zoom], - ); - - // Handle mouse events - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - const canvas = canvasRef.current; - if (!canvas) return; - - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - mousePos.current = { x, y }; - - const nodeId = getNodeAtPosition(x, y); - if (nodeId !== currentHoveredNode.current) { - currentHoveredNode.current = nodeId; - onNodeHover(nodeId); - } - - // Handle node dragging - if (draggingNodeId) { - onNodeDragMove(e); - } - }, - [getNodeAtPosition, onNodeHover, draggingNodeId, onNodeDragMove], - ); - - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - const canvas = canvasRef.current; - if (!canvas) return; - - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - const nodeId = getNodeAtPosition(x, y); - if (nodeId) { - // When starting a node drag, prevent initiating pan - e.stopPropagation(); - onNodeDragStart(nodeId, e); - return; - } - onPanStart(e); - }, - [getNodeAtPosition, onNodeDragStart, onPanStart], - ); - - const handleClick = useCallback( - (e: React.MouseEvent) => { - const canvas = canvasRef.current; - if (!canvas) return; - - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - const nodeId = getNodeAtPosition(x, y); - if (nodeId) { - onNodeClick(nodeId); - } - }, - [getNodeAtPosition, onNodeClick], - ); - - // Professional rendering function with LOD - const render = useCallback(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const currentTime = Date.now(); - const _elapsed = currentTime - startTimeRef.current; - - // Level-of-detail optimization based on zoom - const useSimplifiedRendering = zoom < 0.3; - - // Clear canvas - ctx.clearRect(0, 0, width, height); - - // Set high quality rendering - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = "high"; - - // Draw minimal background grid - ctx.strokeStyle = "rgba(148, 163, 184, 0.03)"; // Very subtle grid - ctx.lineWidth = 1; - const gridSpacing = 100 * zoom; - const offsetX = panX % gridSpacing; - const offsetY = panY % gridSpacing; - - // Simple, clean grid lines - for (let x = offsetX; x < width; x += gridSpacing) { - ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, height); - ctx.stroke(); - } - for (let y = offsetY; y < height; y += gridSpacing) { - ctx.beginPath(); - ctx.moveTo(0, y); - ctx.lineTo(width, y); - ctx.stroke(); - } - - // Create node lookup map - const nodeMap = new Map(nodes.map((node) => [node.id, node])); - - // Draw enhanced edges with sophisticated styling - ctx.lineCap = "round"; - edges.forEach((edge) => { - const sourceNode = nodeMap.get(edge.source); - const targetNode = nodeMap.get(edge.target); - - if (sourceNode && targetNode) { - const sourceX = sourceNode.x * zoom + panX; - const sourceY = sourceNode.y * zoom + panY; - const targetX = targetNode.x * zoom + panX; - const targetY = targetNode.y * zoom + panY; - - // Enhanced viewport culling with edge type considerations - if ( - sourceX < -100 || - sourceX > width + 100 || - targetX < -100 || - targetX > width + 100 - ) { - return; - } - - // Skip very weak connections when zoomed out for performance - if (useSimplifiedRendering) { - if ( - edge.edgeType === "doc-memory" && - edge.visualProps.opacity < 0.3 - ) { - return; // Skip very weak doc-memory edges when zoomed out - } - } - - // Enhanced connection styling based on edge type - let connectionColor = colors.connection.weak; - let dashPattern: number[] = []; - let opacity = edge.visualProps.opacity; - let lineWidth = Math.max(1, edge.visualProps.thickness * zoom); - - if (edge.edgeType === "doc-memory") { - // Doc-memory: Solid thin lines, subtle - dashPattern = []; - connectionColor = colors.connection.memory; - opacity = 0.9; - lineWidth = 1; - } else if (edge.edgeType === "doc-doc") { - // Doc-doc: Thick dashed lines with strong similarity emphasis - dashPattern = useSimplifiedRendering ? [] : [10, 5]; // Solid lines when zoomed out - opacity = Math.max(0, edge.similarity * 0.5); - lineWidth = Math.max(1, edge.similarity * 2); // Thicker for stronger similarity - - if (edge.similarity > 0.85) - connectionColor = colors.connection.strong; - else if (edge.similarity > 0.725) - connectionColor = colors.connection.medium; - } else if (edge.edgeType === "version") { - // Version chains: Double line effect with relation-specific colors - dashPattern = []; - connectionColor = edge.color || colors.relations.updates; - opacity = 0.8; - lineWidth = 2; - } - - ctx.strokeStyle = connectionColor; - ctx.lineWidth = lineWidth; - ctx.globalAlpha = opacity; - ctx.setLineDash(dashPattern); - - if (edge.edgeType === "version") { - // Special double-line rendering for version chains - // First line (outer) - ctx.lineWidth = 3; - ctx.globalAlpha = opacity * 0.3; - ctx.beginPath(); - ctx.moveTo(sourceX, sourceY); - ctx.lineTo(targetX, targetY); - ctx.stroke(); - - // Second line (inner) - ctx.lineWidth = 1; - ctx.globalAlpha = opacity; - ctx.beginPath(); - ctx.moveTo(sourceX, sourceY); - ctx.lineTo(targetX, targetY); - ctx.stroke(); - } else { - // Simplified lines when zoomed out, curved when zoomed in - if (useSimplifiedRendering) { - // Straight lines for performance - ctx.beginPath(); - ctx.moveTo(sourceX, sourceY); - ctx.lineTo(targetX, targetY); - ctx.stroke(); - } else { - // Regular curved line for doc-memory and doc-doc - const midX = (sourceX + targetX) / 2; - const midY = (sourceY + targetY) / 2; - const dx = targetX - sourceX; - const dy = targetY - sourceY; - const distance = Math.sqrt(dx * dx + dy * dy); - const controlOffset = - edge.edgeType === "doc-memory" - ? 15 - : Math.min(30, distance * 0.2); - - ctx.beginPath(); - ctx.moveTo(sourceX, sourceY); - ctx.quadraticCurveTo( - midX + controlOffset * (dy / distance), - midY - controlOffset * (dx / distance), - targetX, - targetY, - ); - ctx.stroke(); - } - } - - // Subtle arrow head for version edges - if (edge.edgeType === "version") { - const angle = Math.atan2(targetY - sourceY, targetX - sourceX); - const arrowLength = Math.max(6, 8 * zoom); // Shorter, more subtle - const arrowWidth = Math.max(8, 12 * zoom); - - // Calculate arrow position offset from node edge - const nodeRadius = (targetNode.size * zoom) / 2; - const offsetDistance = nodeRadius + 2; - const arrowX = targetX - Math.cos(angle) * offsetDistance; - const arrowY = targetY - Math.sin(angle) * offsetDistance; - - ctx.save(); - ctx.translate(arrowX, arrowY); - ctx.rotate(angle); - ctx.setLineDash([]); - - // Simple outlined arrow (not filled) - ctx.strokeStyle = connectionColor; - ctx.lineWidth = Math.max(1, 1.5 * zoom); - ctx.globalAlpha = opacity; - - ctx.beginPath(); - ctx.moveTo(0, 0); - ctx.lineTo(-arrowLength, arrowWidth / 2); - ctx.moveTo(0, 0); - ctx.lineTo(-arrowLength, -arrowWidth / 2); - ctx.stroke(); - - ctx.restore(); - } - } - }); - - ctx.globalAlpha = 1; - ctx.setLineDash([]); - - // Prepare highlight set from provided document IDs (customId or internal) - const highlightSet = new Set<string>(highlightDocumentIds ?? []); - - // Draw nodes with enhanced styling and LOD optimization - nodes.forEach((node) => { - const screenX = node.x * zoom + panX; - const screenY = node.y * zoom + panY; - const nodeSize = node.size * zoom; - - // Enhanced viewport culling - const margin = nodeSize + 50; - if ( - screenX < -margin || - screenX > width + margin || - screenY < -margin || - screenY > height + margin - ) { - return; - } - - const isHovered = currentHoveredNode.current === node.id; - const isDragging = node.isDragging; - const isHighlightedDocument = (() => { - if (node.type !== "document" || highlightSet.size === 0) return false; - const doc = node.data as DocumentWithMemories; - if (doc.customId && highlightSet.has(doc.customId)) return true; - return highlightSet.has(doc.id); - })(); - - if (node.type === "document") { - // Enhanced glassmorphism document styling - const docWidth = nodeSize * 1.4; - const docHeight = nodeSize * 0.9; - - // Multi-layer glass effect - ctx.fillStyle = isDragging - ? colors.document.accent - : isHovered - ? colors.document.secondary - : colors.document.primary; - ctx.globalAlpha = 1; - - // Enhanced border with subtle glow - ctx.strokeStyle = isDragging - ? colors.document.glow - : isHovered - ? colors.document.accent - : colors.document.border; - ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1; - - // Rounded rectangle with enhanced styling - const radius = useSimplifiedRendering ? 6 : 12; - ctx.beginPath(); - ctx.roundRect( - screenX - docWidth / 2, - screenY - docHeight / 2, - docWidth, - docHeight, - radius, - ); - ctx.fill(); - ctx.stroke(); - - // Subtle inner highlight for glass effect (skip when zoomed out) - if (!useSimplifiedRendering && (isHovered || isDragging)) { - ctx.strokeStyle = "rgba(255, 255, 255, 0.1)"; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.roundRect( - screenX - docWidth / 2 + 1, - screenY - docHeight / 2 + 1, - docWidth - 2, - docHeight - 2, - radius - 1, - ); - ctx.stroke(); - } - - // Highlight ring for search hits - if (isHighlightedDocument) { - ctx.save(); - ctx.globalAlpha = 0.9; - ctx.strokeStyle = colors.accent.primary; - ctx.lineWidth = 3; - ctx.setLineDash([6, 4]); - const ringPadding = 10; - ctx.beginPath(); - ctx.roundRect( - screenX - docWidth / 2 - ringPadding, - screenY - docHeight / 2 - ringPadding, - docWidth + ringPadding * 2, - docHeight + ringPadding * 2, - radius + 6, - ); - ctx.stroke(); - ctx.setLineDash([]); - ctx.restore(); - } - } else { - // Enhanced memory styling with status indicators - const mem = node.data as MemoryEntry; - const isForgotten = - mem.isForgotten || - (mem.forgetAfter && - new Date(mem.forgetAfter).getTime() < Date.now()); - const isLatest = mem.isLatest; - - // Check if memory is expiring soon (within 7 days) - const expiringSoon = - mem.forgetAfter && - !isForgotten && - new Date(mem.forgetAfter).getTime() - Date.now() < - 1000 * 60 * 60 * 24 * 7; - - // Check if memory is new (created within last 24 hours) - const isNew = - !isForgotten && - new Date(mem.createdAt).getTime() > - Date.now() - 1000 * 60 * 60 * 24; - - // Determine colors based on status - let fillColor = colors.memory.primary; - let borderColor = colors.memory.border; - let glowColor = colors.memory.glow; - - if (isForgotten) { - fillColor = colors.status.forgotten; - borderColor = "rgba(220,38,38,0.3)"; - glowColor = "rgba(220,38,38,0.2)"; - } else if (expiringSoon) { - borderColor = colors.status.expiring; - glowColor = colors.accent.amber; - } else if (isNew) { - borderColor = colors.status.new; - glowColor = colors.accent.emerald; - } - - if (isDragging) { - fillColor = colors.memory.accent; - borderColor = glowColor; - } else if (isHovered) { - fillColor = colors.memory.secondary; - } - - const radius = nodeSize / 2; - - ctx.fillStyle = fillColor; - ctx.globalAlpha = isLatest ? 1 : 0.4; - ctx.strokeStyle = borderColor; - ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1.5; - - if (useSimplifiedRendering) { - // Simple circles when zoomed out for performance - ctx.beginPath(); - ctx.arc(screenX, screenY, radius, 0, 2 * Math.PI); - ctx.fill(); - ctx.stroke(); - } else { - // HEXAGONAL memory nodes when zoomed in - const sides = 6; - ctx.beginPath(); - for (let i = 0; i < sides; i++) { - const angle = (i * 2 * Math.PI) / sides - Math.PI / 2; // Start from top - const x = screenX + radius * Math.cos(angle); - const y = screenY + radius * Math.sin(angle); - if (i === 0) { - ctx.moveTo(x, y); - } else { - ctx.lineTo(x, y); - } - } - ctx.closePath(); - ctx.fill(); - ctx.stroke(); - - // Inner highlight for glass effect - if (isHovered || isDragging) { - ctx.strokeStyle = "rgba(147, 197, 253, 0.3)"; - ctx.lineWidth = 1; - const innerRadius = radius - 2; - ctx.beginPath(); - for (let i = 0; i < sides; i++) { - const angle = (i * 2 * Math.PI) / sides - Math.PI / 2; - const x = screenX + innerRadius * Math.cos(angle); - const y = screenY + innerRadius * Math.sin(angle); - if (i === 0) { - ctx.moveTo(x, y); - } else { - ctx.lineTo(x, y); - } - } - ctx.closePath(); - ctx.stroke(); - } - } - - // Status indicators overlay (always preserve these as required) - if (isForgotten) { - // Cross for forgotten memories - ctx.strokeStyle = "rgba(220,38,38,0.4)"; - ctx.lineWidth = 2; - const r = nodeSize * 0.25; - ctx.beginPath(); - ctx.moveTo(screenX - r, screenY - r); - ctx.lineTo(screenX + r, screenY + r); - ctx.moveTo(screenX + r, screenY - r); - ctx.lineTo(screenX - r, screenY + r); - ctx.stroke(); - } else if (isNew) { - // Small dot for new memories - ctx.fillStyle = colors.status.new; - ctx.beginPath(); - ctx.arc( - screenX + nodeSize * 0.25, - screenY - nodeSize * 0.25, - Math.max(2, nodeSize * 0.15), // Scale with node size, minimum 2px - 0, - 2 * Math.PI, - ); - ctx.fill(); - } - } - - // Enhanced hover glow effect (skip when zoomed out for performance) - if (!useSimplifiedRendering && (isHovered || isDragging)) { - const glowColor = - node.type === "document" - ? colors.document.glow - : colors.memory.glow; - - ctx.strokeStyle = glowColor; - ctx.lineWidth = 1; - ctx.setLineDash([3, 3]); - ctx.globalAlpha = 0.6; - - ctx.beginPath(); - const glowSize = nodeSize * 0.7; - if (node.type === "document") { - ctx.roundRect( - screenX - glowSize, - screenY - glowSize / 1.4, - glowSize * 2, - glowSize * 1.4, - 15, - ); - } else { - // Hexagonal glow for memory nodes - const glowRadius = glowSize; - const sides = 6; - for (let i = 0; i < sides; i++) { - const angle = (i * 2 * Math.PI) / sides - Math.PI / 2; - const x = screenX + glowRadius * Math.cos(angle); - const y = screenY + glowRadius * Math.sin(angle); - if (i === 0) { - ctx.moveTo(x, y); - } else { - ctx.lineTo(x, y); - } - } - ctx.closePath(); - } - ctx.stroke(); - ctx.setLineDash([]); - } - }); - - ctx.globalAlpha = 1; - }, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds]); - - // Change-based rendering instead of continuous animation - const lastRenderParams = useRef<string>(""); - - // Create a render key that changes when visual state changes - const renderKey = useMemo(() => { - const nodePositions = nodes - .map( - (n) => - `${n.id}:${n.x}:${n.y}:${n.isDragging ? "1" : "0"}:${currentHoveredNode.current === n.id ? "1" : "0"}`, - ) - .join("|"); - const highlightKey = (highlightDocumentIds ?? []).join("|"); - return `${nodePositions}-${edges.length}-${panX}-${panY}-${zoom}-${width}-${height}-${highlightKey}`; - }, [ - nodes, - edges.length, - panX, - panY, - zoom, - width, - height, - highlightDocumentIds, - ]); - - // Only render when something actually changed - useEffect(() => { - if (renderKey !== lastRenderParams.current) { - lastRenderParams.current = renderKey; - render(); - } - }, [renderKey, render]); - - // Cleanup any existing animation frames - useEffect(() => { - return () => { - if (animationRef.current) { - cancelAnimationFrame(animationRef.current); - } - }; - }, []); - - // Add native wheel event listener to prevent browser zoom - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const handleNativeWheel = (e: WheelEvent) => { - e.preventDefault(); - e.stopPropagation(); - - // Call the onWheel handler with a synthetic-like event - onWheel({ - deltaY: e.deltaY, - deltaX: e.deltaX, - clientX: e.clientX, - clientY: e.clientY, - currentTarget: canvas, - nativeEvent: e, - preventDefault: () => {}, - stopPropagation: () => {}, - } as React.WheelEvent); - }; - - // Add listener with passive: false to ensure preventDefault works - canvas.addEventListener("wheel", handleNativeWheel, { passive: false }); - - // Also prevent gesture events for touch devices - const handleGesture = (e: Event) => { - e.preventDefault(); - }; - - canvas.addEventListener("gesturestart", handleGesture, { - passive: false, - }); - canvas.addEventListener("gesturechange", handleGesture, { - passive: false, - }); - canvas.addEventListener("gestureend", handleGesture, { passive: false }); - - return () => { - canvas.removeEventListener("wheel", handleNativeWheel); - canvas.removeEventListener("gesturestart", handleGesture); - canvas.removeEventListener("gesturechange", handleGesture); - canvas.removeEventListener("gestureend", handleGesture); - }; - }, [onWheel]); - - // High-DPI handling -------------------------------------------------- - const dpr = - typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1; - - useLayoutEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - // upscale backing store - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; - canvas.width = width * dpr; - canvas.height = height * dpr; - - const ctx = canvas.getContext("2d"); - ctx?.scale(dpr, dpr); - }, [width, height, dpr]); - // ----------------------------------------------------------------------- - - return ( - <canvas - className="absolute inset-0" - height={height} - onClick={handleClick} - onDoubleClick={onDoubleClick} - onMouseDown={handleMouseDown} - onMouseLeave={() => { - if (draggingNodeId) { - onNodeDragEnd(); - } else { - onPanEnd(); - } - }} - onMouseMove={(e) => { - handleMouseMove(e); - if (!draggingNodeId) { - onPanMove(e); - } - }} - onMouseUp={() => { - if (draggingNodeId) { - onNodeDragEnd(); - } else { - onPanEnd(); - } - }} - onTouchStart={onTouchStart} - onTouchMove={onTouchMove} - onTouchEnd={onTouchEnd} - ref={canvasRef} - style={{ - cursor: draggingNodeId - ? "grabbing" - : currentHoveredNode.current - ? "grab" - : "move", - touchAction: "none", - userSelect: "none", - WebkitUserSelect: "none", - }} - width={width} - /> - ); - }, -); - -GraphCanvas.displayName = "GraphCanvas"; diff --git a/packages/ui/memory-graph/graph-webgl-canvas.tsx b/packages/ui/memory-graph/graph-webgl-canvas.tsx deleted file mode 100644 index af13eefc..00000000 --- a/packages/ui/memory-graph/graph-webgl-canvas.tsx +++ /dev/null @@ -1,794 +0,0 @@ -"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 deleted file mode 100644 index 3e9fa5cc..00000000 --- a/packages/ui/memory-graph/hooks/use-graph-data.ts +++ /dev/null @@ -1,304 +0,0 @@ -"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 deleted file mode 100644 index ec44e83e..00000000 --- a/packages/ui/memory-graph/hooks/use-graph-interactions.ts +++ /dev/null @@ -1,564 +0,0 @@ -"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 deleted file mode 100644 index ddc3bec1..00000000 --- a/packages/ui/memory-graph/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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 deleted file mode 100644 index db2495cc..00000000 --- a/packages/ui/memory-graph/legend.tsx +++ /dev/null @@ -1,311 +0,0 @@ -"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 deleted file mode 100644 index f4a1930a..00000000 --- a/packages/ui/memory-graph/loading-indicator.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"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 deleted file mode 100644 index 8c1ad3c2..00000000 --- a/packages/ui/memory-graph/memory-graph.tsx +++ /dev/null @@ -1,458 +0,0 @@ -"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 deleted file mode 100644 index b2abd67f..00000000 --- a/packages/ui/memory-graph/navigation-controls.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"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 deleted file mode 100644 index 0fdc4801..00000000 --- a/packages/ui/memory-graph/node-detail-panel.tsx +++ /dev/null @@ -1,268 +0,0 @@ -"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 deleted file mode 100644 index 72d5f261..00000000 --- a/packages/ui/memory-graph/spaces-dropdown.tsx +++ /dev/null @@ -1,120 +0,0 @@ -"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 deleted file mode 100644 index a939c619..00000000 --- a/packages/ui/memory-graph/types.ts +++ /dev/null @@ -1,122 +0,0 @@ -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"; -} |